Compare commits

..

No commits in common. "5b62d189f8bc7789f9a2e8e7d25a736d02d90f9c" and "3467c5e459975702a4b75cd665a42a04486a6b0f" have entirely different histories.

102 changed files with 2410 additions and 6308 deletions

View File

@ -1,10 +1,10 @@
FROM node:18-alpine AS builder FROM node:18-alpine as builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --legacy-peer-deps RUN npm ci
COPY . . COPY . .

154
Product-Fields-Mapping.md Normal file
View File

@ -0,0 +1,154 @@
# 📋 مقایسه فیلدها - سیستم محصولات
## 🔵 فیلدهای محصول (Product Fields)
| فرم ما | API Field | نوع | توضیح |
| ---------------------- | ---------------------- | ---------- | ------------------------ |
| ✅ `name` | ✅ `name` | `string` | نام محصول |
| ✅ `description` | ✅ `description` | `string` | توضیحات محصول |
| ✅ `design_style` | ✅ `design_style` | `string` | استایل طراحی |
| ✅ `enabled` | ✅ `enabled` | `boolean` | فعال/غیرفعال |
| ✅ `category_ids` | ✅ `category_ids` | `number[]` | آرایه شناسه دسته‌بندی‌ها |
| ✅ `product_option_id` | ✅ `product_option_id` | `number` | شناسه گزینه محصول |
| ✅ `total_sold` | ✅ `total_sold` | `number` | تعداد فروخته شده |
| ✅ `type` | ✅ `type` | `number` | نوع محصول (0,1,2,3) |
| ✅ `attributes` | ✅ `attributes` | `object` | ویژگی‌های سفارشی |
| ✅ `variants` | ✅ `variants` | `array` | آرایه variants |
## 🔧 فیلدهای Variant
| فرم ما | API Field | نوع | توضیح |
| ---------------------- | ---------------------- | --------- | -------------------------- |
| ✅ `enabled` | ✅ `enabled` | `boolean` | فعال/غیرفعال variant |
| ✅ `fee_percentage` | ✅ `fee_percentage` | `number` | درصد کارمزد |
| ✅ `profit_percentage` | ✅ `profit_percentage` | `number` | درصد سود |
| ✅ `stock_limit` | ✅ `stock_limit` | `number` | حد کمینه موجودی |
| ✅ `stock_managed` | ✅ `stock_managed` | `boolean` | مدیریت موجودی فعال/غیرفعال |
| ✅ `stock_number` | ✅ `stock_number` | `number` | تعداد موجودی |
| ✅ `weight` | ✅ `weight` | `number` | وزن (گرم) |
| ✅ `attributes` | ✅ `attributes` | `object` | ویژگی‌های variant |
| ✅ `meta` | ✅ `meta` | `object` | Meta data |
## 📊 فیلدهای اضافی در فرم
| فرم ما | API | دلیل |
| --------------- | ------- | ------------------------------ |
| 🆕 `images` | ❌ نیست | برای آپلود و مدیریت تصاویر |
| 🆕 `id` | ❌ نیست | برای ویرایش (از response میاد) |
| 🆕 `created_at` | ❌ نیست | از response میاد |
| 🆕 `updated_at` | ❌ نیست | از response میاد |
## 🎯 مثال API Request کامل
```json
{
"name": "تیشرت مردانه",
"description": "تیشرت با کیفیت بالا",
"design_style": "مدرن",
"enabled": true,
"category_ids": [1, 3],
"product_option_id": 2,
"total_sold": 25,
"type": 1,
"attributes": {
"material": "پنبه",
"season": "تابستان"
},
"variants": [
{
"enabled": true,
"fee_percentage": 5.5,
"profit_percentage": 25,
"stock_managed": true,
"stock_number": 10,
"stock_limit": 2,
"weight": 200,
"attributes": {
"color": "قرمز",
"size": "بزرگ"
},
"meta": {
"supplier": "تامین‌کننده A",
"priority": "high"
}
}
]
}
```
## 🚀 نوع محصولات (Product Types)
| مقدار | نام | توضیح |
| ----- | ----------- | ----------------------- |
| `0` | محصول ساده | محصول بدون variant |
| `1` | محصول متغیر | محصول با variants مختلف |
| `2` | محصول گروهی | مجموعه محصولات |
| `3` | محصول خارجی | لینک به سایت خارجی |
## 🎨 فیلدهای UI (غیر API)
### فیلدهای مدیریت تصاویر:
- **`images`** - آرایه تصاویر محصول
- **`images.id`** - شناسه فایل آپلود شده
- **`images.url`** - آدرس تصویر
- **`images.alt`** - متن جایگزین
- **`images.order`** - ترتیب نمایش
### فیلدهای مدیریت داخلی:
- **`id`** - شناسه محصول (برای ویرایش)
- **`created_at`** - تاریخ ایجاد
- **`updated_at`** - تاریخ آخرین ویرایش
## ✨ خلاصه مطابقت
### ✅ کاملاً مطابق:
- **100% فیلدهای API پیاده‌سازی شده**
- **ساختار دقیقاً مشابه**
- **نوع داده‌ها صحیح**
- **Validation مناسب**
### 🎯 ویژگی‌های اضافی:
- **مدیریت تصاویر** با آپلود و پیش‌نمایش
- **UI/UX بهتر** با validation و error handling
- **Preview زنده** تغییرات
- **Responsive design** برای موبایل
## 📝 نتیجه‌گیری
**✅ تمام فیلدهای API شما دقیقاً پیاده‌سازی شده است**
**🎨 قابلیت‌های اضافی UI/UX برای تجربه بهتر کاربر اضافه شده**
**🚀 سیستم آماده برای استفاده در production**
---
## 📋 فیلدهای حذف شده (که در API نبودند)
### ❌ فیلدهایی که حذف کردیم:
- **`price`** - نه در محصول و نه در variant
- **`sku`** - نه در محصول و نه در variant
- **`status`** - در API نبود
- **`name`** برای variant - در API نبود
### 🔄 تغییرات انجام شده:
1. **Models** به‌روزرسانی شد طبق API دقیق
2. **Product Form** فیلدهای اضافی حذف شد
3. **Variant Manager** ساده‌سازی شد طبق API
4. **Validation** تنظیم شد برای فیلدهای جدید
## 🛠️ فایلهای تغییر یافته:
- `src/pages/products/core/_models.ts`
- `src/pages/products/product-form/ProductFormPage.tsx`
- `src/components/ui/VariantManager.tsx`
## 🎉 نتیجه نهایی:
**سیستم کاملاً آماده و مطابق با API شماست! 🚀**

View File

@ -6,6 +6,7 @@ import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext'; import { ToastProvider } from './contexts/ToastContext';
import { ErrorBoundary } from './components/common/ErrorBoundary'; import { ErrorBoundary } from './components/common/ErrorBoundary';
import { LoadingSpinner } from './components/ui/LoadingSpinner';
import { queryClient } from './lib/queryClient'; import { queryClient } from './lib/queryClient';
import { useAuth } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext';
import { Layout } from './components/layout/Layout'; import { Layout } from './components/layout/Layout';
@ -68,7 +69,6 @@ const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipp
const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage')); const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage'));
const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage')); const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage'));
const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage')); const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage'));
const ContactUsListPage = lazy(() => import('./pages/contact-us/contact-us-list/ContactUsListPage'));
// Payment IPG Page // Payment IPG Page
const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage')); const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'));
@ -79,21 +79,14 @@ const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormP
// Wallet Page // Wallet Page
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage')); const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage'));
// Reports Pages const ProtectedRoute = ({ children }: { children: any }) => {
const DiscountUsageReportPage = lazy(() => import('./pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage'));
const CustomerDiscountUsagePage = lazy(() => import('./pages/reports/discount-statistics/customer-discount-usage/CustomerDiscountUsagePage'));
const PaymentMethodsReportPage = lazy(() => import('./pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage'));
const ShipmentsByMethodReportPage = lazy(() => import('./pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage'));
// Product Comments Page
const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage'));
const ProtectedRoute = ({ children }: { children: React.ReactElement }) => {
const { user, isLoading } = useAuth(); const { user, isLoading } = useAuth();
if (isLoading) { if (isLoading) {
return ( return (
<Layout /> <div className="min-h-screen flex items-center justify-center">
<LoadingSpinner />
</div>
); );
} }
@ -165,19 +158,15 @@ const AppRoutes = () => {
<Route path="shipping-methods" element={<ShippingMethodsListPage />} /> <Route path="shipping-methods" element={<ShippingMethodsListPage />} />
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} /> <Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
<Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} /> <Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} />
<Route path="shipping-methods/shipments-report" element={<ShipmentsByMethodReportPage />} />
<Route path="tickets" element={<TicketsListPage />} /> <Route path="tickets" element={<TicketsListPage />} />
<Route path="tickets/config" element={<TicketConfigPage />} /> <Route path="tickets/config" element={<TicketConfigPage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} /> <Route path="tickets/:id" element={<TicketDetailPage />} />
<Route path="contact-us" element={<ContactUsListPage />} />
{/* Products Routes */} {/* Products Routes */}
<Route path="products/create" element={<ProductFormPage />} /> <Route path="products/create" element={<ProductFormPage />} />
<Route path="products/:id" element={<ProductDetailPage />} /> <Route path="products/:id" element={<ProductDetailPage />} />
<Route path="products/:id/edit" element={<ProductFormPage />} /> <Route path="products/:id/edit" element={<ProductFormPage />} />
<Route path="products/comments" element={<ProductCommentsListPage />} />
{/* Payment IPG Route */} {/* Payment IPG Route */}
<Route path="payment-ipg" element={<IPGListPage />} /> <Route path="payment-ipg" element={<IPGListPage />} />
@ -187,12 +176,6 @@ const AppRoutes = () => {
{/* Wallet Route */} {/* Wallet Route */}
<Route path="wallet" element={<WalletListPage />} /> <Route path="wallet" element={<WalletListPage />} />
{/* Reports Routes */}
<Route path="reports/discount-usage" element={<DiscountUsageReportPage />} />
<Route path="reports/customer-discount-usage" element={<CustomerDiscountUsagePage />} />
<Route path="reports/payment-methods" element={<PaymentMethodsReportPage />} />
<Route path="reports/shipments-by-method" element={<ShipmentsByMethodReportPage />} />
</Route> </Route>
</Routes> </Routes>
); );
@ -206,7 +189,11 @@ const App = () => {
<ToastProvider> <ToastProvider>
<AuthProvider> <AuthProvider>
<Router> <Router>
<Suspense fallback={null}> <Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner />
</div>
}>
<AppRoutes /> <AppRoutes />
</Suspense> </Suspense>
</Router> </Router>

View File

@ -10,19 +10,19 @@ interface PieChartProps {
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => { export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => {
// Custom legend component for left side // Custom legend component for better mobile experience
const CustomLegend = (props: any) => { const CustomLegend = (props: any) => {
const { payload } = props; const { payload } = props;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-wrap justify-center gap-2 mt-3">
{payload.map((entry: any, index: number) => ( {payload.map((entry: any, index: number) => (
<div key={index} className="flex items-center gap-2"> <div key={index} className="flex items-center gap-1 text-xs sm:text-sm">
<div <div
className="w-3 h-3 rounded-full flex-shrink-0 border border-white dark:border-gray-800" className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: entry.color }} style={{ backgroundColor: entry.color }}
/> />
<span className="text-xs sm:text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap"> <span className="text-gray-700 dark:text-gray-300 whitespace-nowrap">
<span className="font-medium">{entry.value}</span>: <span className="font-bold">{Math.round(entry.payload.value)}%</span> {entry.value}: {entry.payload.value}
</span> </span>
</div> </div>
))} ))}
@ -37,32 +37,19 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
{title} {title}
</CardTitle> </CardTitle>
)} )}
<div className="w-full flex items-center gap-4"> <div className="w-full">
{/* Legend on the left */}
<div className="flex-shrink-0">
<CustomLegend payload={data.map((item, index) => ({
value: item.name,
color: colors[index % colors.length],
payload: item
}))} />
</div>
{/* Chart on the right */}
<div className="flex-1">
<ResponsiveContainer width="100%" height={280} minHeight={220}> <ResponsiveContainer width="100%" height={280} minHeight={220}>
<RechartsPieChart> <RechartsPieChart>
<Pie <Pie
data={data} data={data}
cx="50%" cx="50%"
cy="50%" cy="45%"
labelLine={false} labelLine={false}
// Remove the overlapping labels
label={false} label={false}
outerRadius="75%" outerRadius="65%"
innerRadius="35%"
fill="#8884d8" fill="#8884d8"
dataKey="value" dataKey="value"
stroke="#fff"
strokeWidth={3}
> >
{data.map((_, index) => ( {data.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} /> <Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
@ -70,20 +57,24 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
</Pie> </Pie>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.95)', backgroundColor: 'var(--toast-bg)',
color: '#1f2937', color: 'var(--toast-color)',
border: '1px solid #e5e7eb', border: 'none',
borderRadius: '8px', borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
fontSize: '14px', fontSize: '14px',
fontWeight: '500',
}} }}
formatter={(value: any, name: any) => [`${Math.round(value)}%`, name]} formatter={(value, name) => [`${value}`, name]}
/>
<Legend
content={<CustomLegend />}
wrapperStyle={{
paddingTop: '10px'
}}
/> />
</RechartsPieChart> </RechartsPieChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
</div> </div>
</div>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

@ -1,126 +0,0 @@
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

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

View File

@ -28,10 +28,10 @@ export const StatsCard = ({
const isNegative = change && change < 0; const isNegative = change && change < 0;
return ( return (
<div className="card p-4 sm:p-5 lg:p-6 animate-fade-in"> <div className="card p-3 sm:p-4 lg:p-6 animate-fade-in">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className={`p-3 sm:p-4 rounded-xl ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue} shadow-sm`}> <div className={`p-2 sm:p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
<Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" /> <Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" />
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -14,8 +14,8 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
return ( return (
<header className="bg-white dark:bg-gray-800 shadow-md border-b border-gray-200 dark:border-gray-700"> <header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between px-4 sm:px-6 lg:px-8 py-4"> <div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center space-x-4 space-x-reverse"> <div className="flex items-center space-x-4 space-x-reverse">
<button <button
onClick={onMenuClick} onClick={onMenuClick}

View File

@ -1,27 +1,8 @@
import { Suspense, useState } from 'react'; import { useState } from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { Header } from './Header'; import { Header } from './Header';
const ContentSkeleton = () => (
<div className="space-y-6">
<div className="h-10 w-1/3 rounded-lg bg-gray-200 dark:bg-gray-800 animate-pulse" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="h-36 rounded-lg bg-white dark:bg-gray-800 shadow-sm"
>
<div className="h-full w-full rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse" />
</div>
))}
</div>
<div className="h-96 rounded-lg bg-white dark:bg-gray-800 shadow-sm">
<div className="h-full w-full rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse" />
</div>
</div>
);
export const Layout = () => { export const Layout = () => {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
@ -36,10 +17,8 @@ export const Layout = () => {
<Header onMenuClick={() => setSidebarOpen(true)} /> <Header onMenuClick={() => setSidebarOpen(true)} />
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900"> <main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
<div className="min-h-full py-6 px-4 sm:px-6 lg:px-8"> <div className="min-h-full">
<Suspense fallback={<ContentSkeleton />}>
<Outlet /> <Outlet />
</Suspense>
</div> </div>
</main> </main>
</div> </div>

View File

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

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { import {
Home, Home,
Settings, Settings,
@ -19,10 +19,7 @@ import {
X, X,
MessageSquare, MessageSquare,
CreditCard, CreditCard,
Wallet, Wallet
BarChart3,
FileText,
TrendingUp
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { PermissionWrapper } from '../common/PermissionWrapper'; import { PermissionWrapper } from '../common/PermissionWrapper';
@ -75,11 +72,6 @@ const menuItems: MenuItem[] = [
}, },
] ]
}, },
{
title: 'پیام‌های تماس با ما',
icon: FileText,
path: '/contact-us',
},
{ {
title: 'مدیریت محصولات', title: 'مدیریت محصولات',
icon: Package, icon: Package,
@ -99,37 +91,6 @@ const menuItems: MenuItem[] = [
icon: Sliders, icon: Sliders,
path: '/product-options', path: '/product-options',
}, },
{
title: 'نظرات محصولات',
icon: MessageSquare,
path: '/products/comments',
},
]
},
{
title: 'گزارش‌ها',
icon: BarChart3,
children: [
{
title: 'گزارش کدهای تخفیف',
icon: BadgePercent,
path: '/reports/discount-usage',
},
{
title: 'گزارش کاربر و کد تخفیف',
icon: Users,
path: '/reports/customer-discount-usage',
},
{
title: 'گزارش روش‌های پرداخت',
icon: CreditCard,
path: '/reports/payment-methods',
},
{
title: 'گزارش ارسال‌ها',
icon: Truck,
path: '/reports/shipments-by-method',
},
] ]
}, },
{ {
@ -190,58 +151,14 @@ interface SidebarProps {
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => { export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const location = useLocation(); const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
const [expandedItems, setExpandedItems] = React.useState<string[]>(() => {
// Load from localStorage on mount
const saved = localStorage.getItem('sidebar_expanded_items');
return saved ? JSON.parse(saved) : [];
});
// Auto-expand menu items based on current route
React.useEffect(() => {
const currentPath = location.pathname;
setExpandedItems(prev => {
const itemsToExpand: string[] = [];
menuItems.forEach(item => {
if (item.children) {
// Check if any child matches current path
const hasActiveChild = item.children.some(child => {
if (child.path) {
if (child.exact) {
return currentPath === child.path;
}
return currentPath.startsWith(child.path);
}
return false;
});
if (hasActiveChild && !prev.includes(item.title)) {
itemsToExpand.push(item.title);
}
}
});
if (itemsToExpand.length > 0) {
return [...prev, ...itemsToExpand];
}
return prev;
});
}, [location.pathname]);
React.useEffect(() => {
// Save to localStorage whenever expandedItems changes
localStorage.setItem('sidebar_expanded_items', JSON.stringify(expandedItems));
}, [expandedItems]);
const toggleExpanded = (title: string) => { const toggleExpanded = (title: string) => {
setExpandedItems(prev => { setExpandedItems(prev =>
const newItems = prev.includes(title) prev.includes(title)
? prev.filter(item => item !== title) ? prev.filter(item => item !== title)
: [...prev, title]; : [...prev, title]
return newItems; );
});
}; };
const renderMenuItem = (item: MenuItem, depth = 0) => { const renderMenuItem = (item: MenuItem, depth = 0) => {
@ -254,8 +171,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div key={item.title} className="space-y-1"> <div key={item.title} className="space-y-1">
<button <button
onClick={() => toggleExpanded(item.title)} onClick={() => toggleExpanded(item.title)}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors
text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-sm`} text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700`}
style={{ paddingLeft: `${paddingLeft + 16}px` }} style={{ paddingLeft: `${paddingLeft + 16}px` }}
> >
<item.icon className="ml-3 h-5 w-5" /> <item.icon className="ml-3 h-5 w-5" />
@ -286,9 +203,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
} }
}} }}
className={({ isActive }) => className={({ isActive }) =>
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${isActive `w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-colors ${isActive
? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400 shadow-sm' ? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400'
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white hover:shadow-sm' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white'
}` }`
} }
style={{ paddingLeft: `${paddingLeft + 16}px` }} style={{ paddingLeft: `${paddingLeft + 16}px` }}
@ -325,35 +242,35 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
w-64 transform transition-transform duration-300 ease-in-out w-64 transform transition-transform duration-300 ease-in-out
lg:translate-x-0 lg:block lg:translate-x-0 lg:block
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'} ${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
flex flex-col h-screen bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-lg lg:shadow-none flex flex-col bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700
`}> `}>
{/* Mobile close button */} {/* Mobile close button */}
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0"> <div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700">
<SectionTitle> <SectionTitle>
پنل مدیریت پنل مدیریت
</SectionTitle> </SectionTitle>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
> >
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" /> <X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</button> </button>
</div> </div>
{/* Logo - desktop only */} {/* Logo - desktop only */}
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700 flex-shrink-0"> <div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700">
<SectionTitle> <SectionTitle>
پنل مدیریت پنل مدیریت
</SectionTitle> </SectionTitle>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0"> <nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto">
{menuItems.map(item => renderMenuItem(item))} {menuItems.map(item => renderMenuItem(item))}
</nav> </nav>
{/* User Info */} {/* User Info */}
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0"> <div className="border-t border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center space-x-3 space-x-reverse"> <div className="flex items-center space-x-3 space-x-reverse">
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center"> <div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
<span className="text-sm font-medium text-white"> <span className="text-sm font-medium text-white">

View File

@ -23,7 +23,7 @@ export const Button = ({
className = '', className = '',
...rest ...rest
}: ButtonProps) => { }: ButtonProps) => {
const baseClasses = 'inline-flex items-center justify-center rounded-xl font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 shadow-sm hover:shadow-md'; const baseClasses = 'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
const variantClasses = { const variantClasses = {
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500', primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',

View File

@ -5,7 +5,6 @@ import persian from 'react-date-object/calendars/persian';
import persian_fa from 'react-date-object/locales/persian_fa'; import persian_fa from 'react-date-object/locales/persian_fa';
import DateObject from 'react-date-object'; import DateObject from 'react-date-object';
import { Label } from './Typography'; import { Label } from './Typography';
import { X } from 'lucide-react';
interface JalaliDateTimePickerProps { interface JalaliDateTimePickerProps {
label?: string; label?: string;
@ -47,7 +46,6 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
return ( return (
<div className="space-y-1"> <div className="space-y-1">
{label && <Label>{label}</Label>} {label && <Label>{label}</Label>}
<div className="relative">
<DatePicker <DatePicker
value={selected} value={selected}
onChange={(val) => onChange(toIsoLike(val as DateObject | null))} onChange={(val) => onChange(toIsoLike(val as DateObject | null))}
@ -56,7 +54,7 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
locale={persian_fa} locale={persian_fa}
calendarPosition="bottom-center" calendarPosition="bottom-center"
disableDayPicker={false} disableDayPicker={false}
inputClass={`w-full border rounded-lg px-3 py-3 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`} inputClass={`w-full border rounded-lg px-3 py-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`}
containerClassName="w-full" containerClassName="w-full"
placeholder={placeholder || 'تاریخ و ساعت'} placeholder={placeholder || 'تاریخ و ساعت'}
editable={false} editable={false}
@ -65,20 +63,6 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
disableYearPicker={false} disableYearPicker={false}
showOtherDays showOtherDays
/> />
{value && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange(undefined);
}}
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
title="پاک کردن"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{error && ( {error && (
<p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p> <p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
)} )}

View File

@ -58,8 +58,8 @@ export const Modal = ({
<div className={` <div className={`
relative w-full ${sizeClasses[size]} relative w-full ${sizeClasses[size]}
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl bg-white dark:bg-gray-800 rounded-lg shadow-xl
transform transition-all border border-gray-200 dark:border-gray-700 transform transition-all
`}> `}>
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between p-4 sm:p-6 border-b border-gray-200 dark:border-gray-700">
<SectionSubtitle>{title}</SectionSubtitle> <SectionSubtitle>{title}</SectionSubtitle>

View File

@ -95,12 +95,13 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
{/* Selected Items Display */} {/* Selected Items Display */}
<div <div
className={` className={`
w-full px-3 py-3 text-base border rounded-lg
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500 w-full min-h-[42px] px-3 py-2 border rounded-md
cursor-pointer transition-all duration-200 focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
${error ? 'border-red-300 focus-within:border-red-500 focus-within:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus-within:border-primary-500'} cursor-pointer
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'} ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 dark:text-gray-100
`} `}
onClick={handleToggleDropdown} onClick={handleToggleDropdown}
> >

View File

@ -106,12 +106,12 @@ export const SingleSelectAutocomplete: React.FC<SingleSelectAutocompleteProps> =
<div <div
className={` className={`
w-full px-3 py-3 text-base border rounded-lg w-full min-h-[42px] px-3 py-2 border rounded-md
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
cursor-pointer transition-all duration-200 cursor-pointer
${error ? 'border-red-300 focus-within:border-red-500 focus-within:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus-within:border-primary-500'} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'} ${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 dark:text-gray-100
`} `}
onClick={handleToggleDropdown} onClick={handleToggleDropdown}
> >

View File

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

View File

@ -75,7 +75,7 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
return ( return (
<> <>
<div className="hidden md:block card overflow-x-auto"> <div className="hidden md:block card overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <tr>

View File

@ -1,41 +0,0 @@
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

@ -11,7 +11,7 @@ interface LabelProps extends TypographyProps {
// Page Headers // Page Headers
export const PageTitle = ({ children, className = '' }: TypographyProps) => ( export const PageTitle = ({ children, className = '' }: TypographyProps) => (
<h1 className={`text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6 ${className}`}> <h1 className={`text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-gray-100 ${className}`}>
{children} {children}
</h1> </h1>
); );
@ -109,7 +109,7 @@ export const FormHeader = ({ title, subtitle, backButton, actions, className = '
// Page Container with consistent mobile spacing // Page Container with consistent mobile spacing
export const PageContainer = ({ children, className = '' }: TypographyProps) => ( export const PageContainer = ({ children, className = '' }: TypographyProps) => (
<div className={`space-y-6 max-w-none ${className}`}> <div className={`p-4 sm:p-6 lg:p-8 space-y-4 sm:space-y-6 max-w-none ${className}`}>
{children} {children}
</div> </div>
); );

View File

@ -76,7 +76,6 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
enabled: true, enabled: true,
fee_percentage: 0, fee_percentage: 0,
profit_percentage: 0, profit_percentage: 0,
tax_percentage: 0,
stock_limit: 0, stock_limit: 0,
stock_managed: true, stock_managed: true,
stock_number: 0, stock_number: 0,
@ -100,7 +99,6 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
const [weightDisplay, setWeightDisplay] = useState(variant?.weight?.toString() || ''); const [weightDisplay, setWeightDisplay] = useState(variant?.weight?.toString() || '');
const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || ''); const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || '');
const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || ''); const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || '');
const [taxPercentageDisplay, setTaxPercentageDisplay] = useState(variant?.tax_percentage?.toString() || '');
const { mutateAsync: uploadFile } = useFileUpload(); const { mutateAsync: uploadFile } = useFileUpload();
const { mutate: deleteFile } = useFileDelete(); const { mutate: deleteFile } = useFileDelete();
@ -121,14 +119,11 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
if (variant?.profit_percentage !== undefined) { if (variant?.profit_percentage !== undefined) {
setProfitPercentageDisplay(variant.profit_percentage.toString()); setProfitPercentageDisplay(variant.profit_percentage.toString());
} }
if (variant?.tax_percentage !== undefined) {
setTaxPercentageDisplay(variant.tax_percentage.toString());
}
// Load variant attribute value if exists // Load variant attribute value if exists
if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) { if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) {
setVariantAttributeValue(variant.attributes[variantAttributeName].toString()); setVariantAttributeValue(variant.attributes[variantAttributeName].toString());
} }
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.attributes, variantAttributeName]); }, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.attributes, variantAttributeName]);
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => { const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
if (typeof value === 'string') { if (typeof value === 'string') {
@ -266,28 +261,6 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
درصد مالیات
</label>
<input
type="text"
inputMode="decimal"
value={taxPercentageDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
setTaxPercentageDisplay(converted);
}}
onBlur={(e) => {
const converted = persianToEnglish(e.target.value);
const numValue = parseFloat(converted) || 0;
handleInputChange('tax_percentage', numValue);
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
placeholder="مثال: ۹"
/>
</div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وزن (گرم) وزن (گرم)
@ -607,9 +580,6 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
<div> <div>
<strong>درصد سود:</strong> {variant.profit_percentage}% <strong>درصد سود:</strong> {variant.profit_percentage}%
</div> </div>
<div>
<strong>درصد مالیات:</strong> {variant.tax_percentage}%
</div>
<div> <div>
<strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'} <strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
</div> </div>

View File

@ -134,10 +134,6 @@ export const API_ROUTES = {
UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`, UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`, DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
// Contact Us APIs
GET_CONTACT_US_MESSAGES: "contact-us",
DELETE_CONTACT_US_MESSAGE: (id: string) => `contact-us/${id}`,
// Payment IPG APIs // Payment IPG APIs
GET_IPG_STATUS: "payment/ipg/status", GET_IPG_STATUS: "payment/ipg/status",
UPDATE_IPG_STATUS: "payment/ipg/status", UPDATE_IPG_STATUS: "payment/ipg/status",
@ -149,17 +145,4 @@ export const API_ROUTES = {
// Wallet APIs // Wallet APIs
GET_WALLET_STATUS: "wallet/status", GET_WALLET_STATUS: "wallet/status",
UPDATE_WALLET_STATUS: "wallet/status", UPDATE_WALLET_STATUS: "wallet/status",
// Reports APIs
DISCOUNT_REPORTS: "reports/discounts",
DISCOUNT_USAGE_REPORT: "reports/discounts/usage",
CUSTOMER_DISCOUNT_USAGE_REPORT: "reports/discounts/customer-usage",
PAYMENT_METHODS_REPORT: "reports/payments/methods",
PAYMENT_TRANSACTIONS_REPORT: "reports/payments/transactions",
SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method",
// Product Comments APIs
GET_PRODUCT_COMMENTS: "products/comments",
UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`,
DELETE_COMMENT: (commentId: string) => `products/comments/${commentId}`,
}; };

View File

@ -12,14 +12,13 @@ export const ThemeProvider = ({ children }: { children: any }) => {
useEffect(() => { useEffect(() => {
const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null; const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialTheme = savedTheme || 'light'; const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
setMode(initialTheme); setMode(initialTheme);
if (initialTheme === 'dark') { if (initialTheme === 'dark') {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
} }
}, []); }, []);

View File

@ -59,7 +59,7 @@
@layer components { @layer components {
.card { .card {
@apply bg-white dark:bg-gray-800 rounded-xl shadow-md border border-gray-200 dark:border-gray-700 transition-shadow duration-200 hover:shadow-lg; @apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
} }
.btn-primary { .btn-primary {

View File

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

View File

@ -6,9 +6,9 @@ import { Modal } from '../components/ui/Modal';
import { Pagination } from '../components/ui/Pagination'; import { Pagination } from '../components/ui/Pagination';
import { UserForm } from '../components/forms/UserForm'; import { UserForm } from '../components/forms/UserForm';
import { PermissionWrapper } from '../components/common/PermissionWrapper'; import { PermissionWrapper } from '../components/common/PermissionWrapper';
import { LoadingSpinner } from '../components/ui/LoadingSpinner';
import { TableColumn } from '../types'; import { TableColumn } from '../types';
import { UserFormData } from '../utils/validationSchemas'; import { UserFormData } from '../utils/validationSchemas';
import { formatDate } from '../utils/formatters';
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers'; import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
import { useFilters } from '../stores/useAppStore'; import { useFilters } from '../stores/useAppStore';
@ -59,7 +59,7 @@ const Users = () => {
key: 'createdAt', key: 'createdAt',
label: 'تاریخ عضویت', label: 'تاریخ عضویت',
sortable: true, sortable: true,
render: (value) => formatDate(value) render: (value) => new Date(value).toLocaleDateString('fa-IR')
}, },
{ {
key: 'actions', key: 'actions',
@ -211,9 +211,7 @@ const Users = () => {
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <LoadingSpinner />
<Table columns={columns} data={[]} loading={true} />
</div>
) : ( ) : (
<> <>
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden"> <div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">

View File

@ -1,10 +1,10 @@
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText, User } from 'lucide-react'; import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText, User } from 'lucide-react';
import { Button } from '../../../components/ui/Button'; import { Button } from '../../../components/ui/Button';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { useAdminUser } from '../core/_hooks'; import { useAdminUser } from '../core/_hooks';
import { PermissionWrapper } from '../../../components/common/PermissionWrapper'; import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography'; import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
import { formatDate } from '../../../utils/formatters';
const AdminUserDetailPage = () => { const AdminUserDetailPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -12,50 +12,14 @@ const AdminUserDetailPage = () => {
const { data: user, isLoading, error } = useAdminUser(id); const { data: user, isLoading, error } = useAdminUser(id);
if (isLoading) { if (isLoading) return <LoadingSpinner />;
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[...Array(4)].map((_, j) => (
<div key={j}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
</div>
))}
</div>
</div>
))}
</div>
<div className="space-y-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div className="space-y-3">
{[...Array(3)].map((_, j) => (
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</PageContainer>
);
}
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات کاربر</div>; if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات کاربر</div>;
if (!user) return <div>کاربر یافت نشد</div>; if (!user) return <div>کاربر یافت نشد</div>;
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR');
};
const getStatusBadge = (status: string) => { const getStatusBadge = (status: string) => {
const isActive = status === 'active'; const isActive = status === 'active';
return ( return (

View File

@ -9,6 +9,7 @@ import { usePermissions } from '../../permissions/core/_hooks';
import { useRoles } from '../../roles/core/_hooks'; import { useRoles } from '../../roles/core/_hooks';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete"; import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography'; import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
@ -133,19 +134,9 @@ const AdminUserFormPage = () => {
if (isEdit && isLoadingUser) { if (isEdit && isLoadingUser) {
return ( return (
<PageContainer> <div className="flex justify-center items-center h-64">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(6)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
); );
} }
@ -237,7 +228,7 @@ const AdminUserFormPage = () => {
</label> </label>
<select <select
{...register('status')} {...register('status')}
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="active">فعال</option> <option value="active">فعال</option>
<option value="deactive">غیرفعال</option> <option value="deactive">غیرفعال</option>

View File

@ -4,14 +4,84 @@ import { useAdminUsers, useDeleteAdminUser } from '../core/_hooks';
import { AdminUserInfo } from '../core/_models'; import { AdminUserInfo } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Users, UserPlus, Plus } from "lucide-react"; import { Trash2, Edit3, Plus, Eye, Users, UserPlus } from "lucide-react";
import { PageContainer, SectionSubtitle } from '../../../components/ui/Typography'; import { Modal } from "@/components/ui/Modal";
import { TableSkeleton } from '@/components/common/TableSkeleton'; import { PageContainer, PageTitle, SectionSubtitle } from '../../../components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { EmptyState } from '@/components/common/EmptyState'; // Skeleton Loading Component
import { ActionButtons } from '@/components/common/ActionButtons'; const AdminUserTableSkeleton = () => (
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal'; <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
import { formatDate } from '@/utils/formatters'; {/* Desktop Table Skeleton */}
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
نام و نام خانوادگی
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
نام کاربری
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
وضعیت
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
تاریخ ایجاد
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
عملیات
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, index) => (
<tr key={index} className="animate-pulse">
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-32"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-24"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-6 bg-gray-300 dark:bg-gray-600 rounded-full w-16"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-20"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards Skeleton */}
<div className="md:hidden p-4 space-y-4">
{[...Array(3)].map((_, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="space-y-3">
<div className="h-5 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-1/2"></div>
<div className="h-6 bg-gray-300 dark:bg-gray-600 rounded-full w-16"></div>
<div className="flex gap-2 pt-2">
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</div>
</div>
))}
</div>
</div>
);
const AdminUsersListPage = () => { const AdminUsersListPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -66,11 +136,16 @@ const AdminUsersListPage = () => {
return ( return (
<PageContainer> <PageContainer>
<PageHeader {/* Header */}
title="مدیریت کاربران ادمین" <div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
subtitle="مدیریت کاربران دسترسی به پنل ادمین" <div>
icon={Users} <div className="flex items-center gap-2 mb-2">
actions={ <Users className="h-6 w-6" />
<PageTitle>مدیریت کاربران ادمین</PageTitle>
</div>
<p className="text-gray-600 dark:text-gray-400">مدیریت کاربران دسترسی به پنل ادمین</p>
</div>
<button <button
onClick={handleCreate} 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" 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"
@ -78,8 +153,7 @@ const AdminUsersListPage = () => {
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</button> </button>
} </div>
/>
{/* Filters */} {/* Filters */}
<SectionSubtitle>فیلترها</SectionSubtitle> <SectionSubtitle>فیلترها</SectionSubtitle>
@ -104,7 +178,7 @@ const AdminUsersListPage = () => {
<select <select
value={filters.status} value={filters.status}
onChange={handleStatusChange} 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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="">همه</option> <option value="">همه</option>
<option value="active">فعال</option> <option value="active">فعال</option>
@ -116,24 +190,25 @@ const AdminUsersListPage = () => {
{/* Users Table */} {/* Users Table */}
{isLoading ? ( {isLoading ? (
<TableSkeleton columns={5} rows={5} /> <AdminUserTableSkeleton />
) : (users || []).length === 0 ? ( ) : (users || []).length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<EmptyState <div className="text-center py-12">
icon={Users} <Users className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
title="هیچ کاربر ادمین یافت نشد" <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
description={filters.search || filters.status هیچ کاربر ادمین یافت نشد
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
{filters.search || filters.status
? "نتیجه‌ای برای جستجوی شما یافت نشد" ? "نتیجه‌ای برای جستجوی شما یافت نشد"
: "شما هنوز هیچ کاربر ادمین ایجاد نکرده‌اید" : "شما هنوز هیچ کاربر ادمین ایجاد نکرده‌اید"
} }
actionLabel={ </p>
<> <Button onClick={handleCreate}>
<UserPlus className="h-4 w-4 ml-2" /> <UserPlus className="h-4 w-4 ml-2" />
اولین کاربر ادمین را ایجاد کنید اولین کاربر ادمین را ایجاد کنید
</> </Button>
} </div>
onAction={handleCreate}
/>
</div> </div>
) : ( ) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@ -178,14 +253,32 @@ const AdminUsersListPage = () => {
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(user.created_at)} {new Date(user.created_at).toLocaleDateString('fa-IR')}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<ActionButtons <div className="flex items-center gap-2">
onView={() => handleView(user.id)} <button
onEdit={() => handleEdit(user.id)} onClick={() => handleView(user.id)}
onDelete={() => setDeleteUserId(user.id.toString())} className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
/> title="مشاهده"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => handleEdit(user.id)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="ویرایش"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteUserId(user.id.toString())}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@ -215,27 +308,65 @@ const AdminUsersListPage = () => {
</span> </span>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3"> <div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
تاریخ ایجاد: {formatDate(user.created_at)} تاریخ ایجاد: {new Date(user.created_at).toLocaleDateString('fa-IR')}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleView(user.id)}
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
<Eye className="h-3 w-3" />
مشاهده
</button>
<button
onClick={() => handleEdit(user.id)}
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
>
<Edit3 className="h-3 w-3" />
ویرایش
</button>
<button
onClick={() => setDeleteUserId(user.id.toString())}
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
حذف
</button>
</div> </div>
<ActionButtons
onView={() => handleView(user.id)}
onEdit={() => handleEdit(user.id)}
onDelete={() => setDeleteUserId(user.id.toString())}
/>
</div> </div>
))} ))}
</div> </div>
</div> </div>
)} )}
<DeleteConfirmModal {/* Delete Confirmation Modal */}
<Modal
isOpen={!!deleteUserId} isOpen={!!deleteUserId}
onClose={() => setDeleteUserId(null)} onClose={() => setDeleteUserId(null)}
onConfirm={handleDeleteConfirm}
title="حذف کاربر ادمین" title="حذف کاربر ادمین"
message="آیا از حذف این کاربر ادمین اطمینان دارید؟ این عمل قابل بازگشت نیست." >
isLoading={isDeleting} <div className="space-y-4">
/> <p className="text-gray-600 dark:text-gray-400">
آیا از حذف این کاربر ادمین اطمینان دارید؟ این عمل قابل بازگشت نیست.
</p>
<div className="flex justify-end space-x-2 space-x-reverse">
<Button
variant="secondary"
onClick={() => setDeleteUserId(null)}
disabled={isDeleting}
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={isDeleting}
>
حذف
</Button>
</div>
</div>
</Modal>
</PageContainer> </PageContainer>
); );
}; };

View File

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

View File

@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { ArrowRight, FolderOpen } from 'lucide-react'; import { ArrowRight, FolderOpen } from 'lucide-react';
import { Button } from '../../../components/ui/Button'; import { Button } from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input'; import { Input } from '../../../components/ui/Input';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { FileUploader } from '../../../components/ui/FileUploader'; import { FileUploader } from '../../../components/ui/FileUploader';
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload'; import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
@ -115,19 +116,9 @@ const CategoryFormPage = () => {
if (isEdit && isLoadingCategory) { if (isEdit && isLoadingCategory) {
return ( return (
<PageContainer> <div className="flex justify-center items-center h-64">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(4)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
); );
} }

View File

@ -1,173 +0,0 @@
import React, { useMemo, useState } from 'react';
import { MessageSquare, Trash2 } from 'lucide-react';
import { PageContainer } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { Pagination } from '@/components/ui/Pagination';
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
import { englishToPersian } from '@/utils/numberUtils';
import { formatDateTime } from '@/utils/formatters';
import { useContactUsMessages, useDeleteContactUsMessage } from '../core/_hooks';
import { ContactUsFilters, ContactUsMessage } from '../core/_models';
const ContactUsListPage: React.FC = () => {
const [filters, setFilters] = useState<ContactUsFilters>({
limit: 20,
offset: 0,
});
const [deleteTarget, setDeleteTarget] = useState<ContactUsMessage | null>(
null
);
const { data, isLoading, error } = useContactUsMessages(filters);
const deleteMessageMutation = useDeleteContactUsMessage();
const messages = data?.messages || [];
const total = data?.total ?? messages.length;
const limit = filters.limit || 20;
const currentPage = Math.floor((filters.offset || 0) / limit) + 1;
const totalPages = total > 0 ? Math.ceil(total / limit) : 1;
const handlePageChange = (page: number) => {
setFilters((prev) => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleDeleteConfirm = () => {
if (!deleteTarget) return;
deleteMessageMutation.mutate(deleteTarget.ID, {
onSuccess: () => setDeleteTarget(null),
});
};
const columns: TableColumn[] = useMemo(
() => [
{
key: 'id',
label: 'شناسه',
align: 'center',
render: (value: number) => englishToPersian(value),
},
{
key: 'name',
label: 'نام',
align: 'right',
render: (value: string) => value || '-',
},
{
key: 'phone',
label: 'شماره تماس',
align: 'left',
render: (value: string) => {
const display = value ? englishToPersian(value) : '-';
return <span dir="ltr">{display}</span>;
},
},
{
key: 'message',
label: 'پیام',
align: 'right',
render: (value: string) => {
if (!value) return '-';
return value.length > 120 ? `${value.slice(0, 120)}...` : value;
},
},
{
key: 'created_at',
label: 'تاریخ',
align: 'right',
render: (value: string) => formatDateTime(value),
},
{
key: 'actions',
label: 'عملیات',
align: 'center',
render: (_val, row: any) => (
<div className="flex items-center justify-center">
<button
onClick={() => setDeleteTarget(row.raw)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
title="حذف پیام"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
),
},
],
[]
);
const tableData = messages.map((message) => ({
id: message.ID,
name: message.Name || '-',
phone: message.PhoneNumber || '-',
message: message.Message || '-',
created_at: message.CreatedAt,
raw: message,
}));
if (error) {
return (
<PageContainer>
<div className="text-center py-12">
<p className="text-red-600">خطا در دریافت پیامهای تماس با ما</p>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<div className="space-y-6">
<PageHeader
title="پیام‌های تماس با ما"
subtitle="لیست پیام‌های ارسال‌شده توسط کاربران"
icon={MessageSquare}
/>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{isLoading ? (
<Table columns={columns} data={[]} loading={true} />
) : messages.length === 0 ? (
<div className="text-center py-12">
<MessageSquare className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
پیامی یافت نشد
</h3>
<p className="text-gray-600 dark:text-gray-400">
هنوز پیامی برای نمایش وجود ندارد
</p>
</div>
) : (
<Table columns={columns} data={tableData} />
)}
</div>
{messages.length > 0 && totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={limit}
totalItems={total}
/>
)}
</div>
<DeleteConfirmModal
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
title="حذف پیام تماس با ما"
message="آیا از حذف این پیام اطمینان دارید؟ این عمل قابل بازگشت نیست."
isLoading={deleteMessageMutation.isPending}
/>
</PageContainer>
);
};
export default ContactUsListPage;

View File

@ -1,28 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { QUERY_KEYS } from "@/utils/query-key";
import { getContactUsMessages, deleteContactUsMessage } from "./_requests";
import { ContactUsFilters } from "./_models";
export const useContactUsMessages = (filters?: ContactUsFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES, filters],
queryFn: () => getContactUsMessages(filters),
});
};
export const useDeleteContactUsMessage = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => deleteContactUsMessage(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES],
});
toast.success("پیام تماس با ما حذف شد");
},
onError: (error: any) => {
toast.error(error?.message || "خطا در حذف پیام تماس با ما");
},
});
};

View File

@ -1,25 +0,0 @@
export interface ContactUsMessage {
ID: number;
UserID: number;
Name: string;
PhoneNumber: string;
Message: string;
CreatedAt: string;
}
export interface ContactUsListResponse {
messages: ContactUsMessage[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface ContactUsFilters {
limit: number;
offset: number;
}
export interface DeleteContactUsResponse {
success: boolean;
}

View File

@ -1,31 +0,0 @@
import {
APIUrlGenerator,
httpDeleteRequest,
httpGetRequest,
} from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
ContactUsFilters,
ContactUsListResponse,
DeleteContactUsResponse,
} from "./_models";
export const getContactUsMessages = async (filters?: ContactUsFilters) => {
const limitValue = filters?.limit ?? 20;
const queryParams: Record<string, string | number | null> = {
limit: limitValue,
offset: filters?.offset ?? 0,
};
const response = await httpGetRequest<ContactUsListResponse>(
APIUrlGenerator(API_ROUTES.GET_CONTACT_US_MESSAGES, queryParams)
);
return response.data;
};
export const deleteContactUsMessage = async (id: string | number) => {
const response = await httpDeleteRequest<DeleteContactUsResponse>(
APIUrlGenerator(API_ROUTES.DELETE_CONTACT_US_MESSAGE(id.toString()))
);
return response.data;
};

View File

@ -7,13 +7,11 @@ import {
createDiscountCode, createDiscountCode,
updateDiscountCode, updateDiscountCode,
deleteDiscountCode, deleteDiscountCode,
getDiscountReports,
} from "./_requests"; } from "./_requests";
import { import {
CreateDiscountCodeRequest, CreateDiscountCodeRequest,
UpdateDiscountCodeRequest, UpdateDiscountCodeRequest,
DiscountCodeFilters, DiscountCodeFilters,
DiscountReportFilters,
} from "./_models"; } from "./_models";
export const useDiscountCodes = (filters?: DiscountCodeFilters) => { export const useDiscountCodes = (filters?: DiscountCodeFilters) => {
@ -86,13 +84,3 @@ export const useDeleteDiscountCode = () => {
}); });
}; };
export const useDiscountReports = (
filters?: DiscountReportFilters,
enabled: boolean = true
) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_DISCOUNT_REPORTS, filters],
queryFn: () => getDiscountReports(filters),
enabled,
});
};

View File

@ -13,17 +13,6 @@ export type DiscountApplicationLevels =
export type DiscountStatus = "active" | "inactive"; export type DiscountStatus = "active" | "inactive";
export type DiscountReportViewMode = "simple" | "detailed";
export type DiscountReportSortBy =
| "usage_count"
| "amount"
| "date"
| "code"
| "created_at";
export type DiscountReportSortOrder = "asc" | "desc";
export type UserGroup = "new" | "loyal" | "all"; export type UserGroup = "new" | "loyal" | "all";
export interface DiscountUserRestrictions { export interface DiscountUserRestrictions {
@ -84,50 +73,6 @@ export interface DiscountCodeFilters {
active_only?: boolean; active_only?: boolean;
} }
export interface DiscountReportFilters {
view_mode?: DiscountReportViewMode;
discount_code?: string;
discount_id?: number;
user_id?: number;
status?: "active" | "inactive" | "expired";
type?: DiscountCodeType;
application_level?: DiscountApplicationLevel;
from_date?: string;
to_date?: string;
min_usage_count?: number;
include_unused?: boolean;
group_by_code?: boolean;
sort_by?: DiscountReportSortBy;
sort_order?: DiscountReportSortOrder;
limit?: number;
offset?: number;
}
export interface DiscountReportUsage {
discount_id: number;
discount_code: string;
discount_name: string;
usage_count: number;
total_amount: number;
unique_users: number;
first_used_at: string;
last_used_at: string;
}
export interface DiscountReportSummarySimple {
total_usages: number;
total_discount_given: number;
unique_users: number;
unique_codes: number;
}
export interface DiscountReportSimpleResponse {
usages: DiscountReportUsage[];
summary: DiscountReportSummarySimple;
total: number;
has_more: boolean;
}
export interface CreateDiscountCodeRequest { export interface CreateDiscountCodeRequest {
code: string; code: string;
name: string; name: string;

View File

@ -12,8 +12,6 @@ import {
DiscountCode, DiscountCode,
DiscountCodeFilters, DiscountCodeFilters,
PaginatedDiscountCodesResponse, PaginatedDiscountCodesResponse,
DiscountReportFilters,
DiscountReportSimpleResponse,
} from "./_models"; } from "./_models";
export const getDiscountCodes = async (filters?: DiscountCodeFilters) => { export const getDiscountCodes = async (filters?: DiscountCodeFilters) => {
@ -77,39 +75,3 @@ export const deleteDiscountCode = async (id: string) => {
); );
return response.data; return response.data;
}; };
export const getDiscountReports = async (
filters?: DiscountReportFilters
) => {
const queryParams: Record<string, string | number | null> = {};
if (filters?.view_mode) queryParams.view_mode = filters.view_mode;
if (filters?.discount_code) queryParams.discount_code = filters.discount_code;
if (filters?.discount_id) queryParams.discount_id = filters.discount_id;
if (filters?.user_id) queryParams.user_id = filters.user_id;
if (filters?.status) queryParams.status = filters.status;
if (filters?.type) queryParams.type = filters.type;
if (filters?.application_level) {
queryParams.application_level = filters.application_level;
}
if (filters?.from_date) queryParams.from_date = filters.from_date;
if (filters?.to_date) queryParams.to_date = filters.to_date;
if (filters?.min_usage_count)
queryParams.min_usage_count = filters.min_usage_count;
if (typeof filters?.include_unused === "boolean") {
queryParams.include_unused = filters.include_unused ? "true" : "false";
}
if (typeof filters?.group_by_code === "boolean") {
queryParams.group_by_code = filters.group_by_code ? "true" : "false";
}
if (filters?.sort_by) queryParams.sort_by = filters.sort_by;
if (filters?.sort_order) queryParams.sort_order = filters.sort_order;
if (typeof filters?.limit === "number") queryParams.limit = filters.limit;
if (typeof filters?.offset === "number") queryParams.offset = filters.offset;
const response = await httpGetRequest<DiscountReportSimpleResponse>(
APIUrlGenerator(API_ROUTES.DISCOUNT_REPORTS, queryParams)
);
return response.data;
};

View File

@ -8,6 +8,7 @@ import { useDiscountCode, useCreateDiscountCode, useUpdateDiscountCode } from '.
import { CreateDiscountCodeRequest } from '../core/_models'; import { CreateDiscountCodeRequest } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete"; import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
import { SingleSelectAutocomplete } from "@/components/ui/SingleSelectAutocomplete"; import { SingleSelectAutocomplete } from "@/components/ui/SingleSelectAutocomplete";
import { JalaliDateTimePicker } from "@/components/ui/JalaliDateTimePicker"; import { JalaliDateTimePicker } from "@/components/ui/JalaliDateTimePicker";
@ -16,7 +17,6 @@ import { ArrowRight, BadgePercent, Calendar, Settings, Users, Tag, Info } from '
import { useUsers, useSearchUsers } from '../../users-admin/core/_hooks'; import { useUsers, useSearchUsers } from '../../users-admin/core/_hooks';
import { useSearchProducts } from '../../products/core/_hooks'; import { useSearchProducts } from '../../products/core/_hooks';
import { useSearchCategories } from '../../categories/core/_hooks'; import { useSearchCategories } from '../../categories/core/_hooks';
import { formatDateTimeLocal } from '../../../utils/formatters';
const schema = yup.object({ const schema = yup.object({
code: yup.string().min(3, 'کد باید حداقل ۳ کاراکتر باشد').max(50, 'کد نباید بیشتر از ۵۰ کاراکتر باشد').required('کد الزامی است'), code: yup.string().min(3, 'کد باید حداقل ۳ کاراکتر باشد').max(50, 'کد نباید بیشتر از ۵۰ کاراکتر باشد').required('کد الزامی است'),
@ -51,6 +51,22 @@ const schema = yup.object({
valid_to: yup.string().nullable(), 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) // Convert input value (YYYY-MM-DDTHH:mm) to API format (YYYY-MM-DDTHH:mm:00Z)
const toApiDateTime = (value?: string): string | undefined => { const toApiDateTime = (value?: string): string | undefined => {
if (!value) return undefined; if (!value) return undefined;
@ -242,23 +258,7 @@ const DiscountCodeFormPage = () => {
} }
}; };
if (isEdit && dcLoading) { if (isEdit && dcLoading) return <LoadingSpinner />;
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(6)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
))}
</div>
</div>
</PageContainer>
);
}
const isLoading = creating || updating; const isLoading = creating || updating;
return ( return (
@ -339,7 +339,7 @@ const DiscountCodeFormPage = () => {
<div className="space-y-2"> <div className="space-y-2">
<Label>نوع تخفیف</Label> <Label>نوع تخفیف</Label>
<select <select
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" className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 transition-colors"
{...register('type')} {...register('type')}
data-testid="discount-type-select" data-testid="discount-type-select"
> >
@ -363,7 +363,7 @@ const DiscountCodeFormPage = () => {
<div className="space-y-2"> <div className="space-y-2">
<Label>وضعیت</Label> <Label>وضعیت</Label>
<select <select
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" className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 transition-colors"
{...register('status')} {...register('status')}
data-testid="discount-status-select" data-testid="discount-status-select"
required required
@ -696,7 +696,7 @@ const DiscountCodeFormPage = () => {
<div className="space-y-2"> <div className="space-y-2">
<Label>گروه کاربری</Label> <Label>گروه کاربری</Label>
<select <select
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" className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-gray-100 transition-colors"
{...register('user_restrictions.user_group')} {...register('user_restrictions.user_group')}
> >
<option value="loyal">وفادار (loyal)</option> <option value="loyal">وفادار (loyal)</option>

View File

@ -1,60 +1,23 @@
import React, { useCallback, useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useDiscountCodes, useDeleteDiscountCode, useDiscountReports } from '../core/_hooks'; import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks';
import { DiscountCode } from '../core/_models'; import { DiscountCode } from '../core/_models';
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { Table } from "@/components/ui/Table"; import { Table } from "@/components/ui/Table";
import { TableColumn } from "@/types"; import { TableColumn } from "@/types";
import { BadgePercent, Plus, Ticket, Hash, DollarSign, Users } from 'lucide-react'; import { Percent, BadgePercent, Trash2, Edit3, Plus, Ticket } from 'lucide-react';
import { PageContainer } from "@/components/ui/Typography";
import { PageHeader } from "@/components/layout/PageHeader";
import { FiltersSection } from "@/components/common/FiltersSection";
import { EmptyState } from "@/components/common/EmptyState";
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
import { ActionButtons } from "@/components/common/ActionButtons";
import { StatusBadge } from "@/components/ui/StatusBadge";
import { Modal } from "@/components/ui/Modal";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { formatCurrency, formatDate, formatDateTime } from "@/utils/formatters";
import { formatWithThousands } from "@/utils/numberUtils";
const DiscountCodesListPage = () => { const DiscountCodesListPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [deleteId, setDeleteId] = useState<string | null>(null); const [deleteId, setDeleteId] = useState<string | null>(null);
const [filters, setFilters] = useState({ code: '' }); const [filters, setFilters] = useState({ code: '' });
const [selectedDiscount, setSelectedDiscount] = useState<DiscountCode | null>(null);
const [isUsageModalOpen, setIsUsageModalOpen] = useState(false);
const { data: discountCodes, isLoading, error } = useDiscountCodes(filters); const { data: discountCodes, isLoading, error } = useDiscountCodes(filters);
const { mutate: deleteDiscount, isPending: isDeleting } = useDeleteDiscountCode(); const { mutate: deleteDiscount, isPending: isDeleting } = useDeleteDiscountCode();
const summaryFilters = useMemo(
() => ({
view_mode: 'simple' as const,
limit: 1,
offset: 0,
}),
[]
);
const { data: discountReport, isLoading: isReportLoading, error: reportError } =
useDiscountReports(summaryFilters);
const usageFilters = useMemo(() => {
if (!selectedDiscount) return undefined;
return {
view_mode: 'simple' as const,
discount_id: selectedDiscount.id,
include_unused: true,
limit: 1,
offset: 0,
};
}, [selectedDiscount]);
const {
data: usageReport,
isLoading: isUsageLoading,
error: usageError,
} = useDiscountReports(usageFilters, isUsageModalOpen && !!selectedDiscount);
const selectedUsage = usageReport?.usages?.[0];
const handleCreate = useCallback(() => navigate('/discount-codes/create'), [navigate]); const handleCreate = () => navigate('/discount-codes/create');
const handleEdit = useCallback((id: number) => navigate(`/discount-codes/${id}/edit`), [navigate]); const handleEdit = (id: number) => navigate(`/discount-codes/${id}/edit`);
const handleDeleteConfirm = () => { const handleDeleteConfirm = () => {
if (deleteId) { if (deleteId) {
@ -62,16 +25,6 @@ const DiscountCodesListPage = () => {
} }
}; };
const handleOpenUsageModal = useCallback((discount: DiscountCode) => {
setSelectedDiscount(discount);
setIsUsageModalOpen(true);
}, []);
const handleCloseUsageModal = () => {
setIsUsageModalOpen(false);
setSelectedDiscount(null);
};
const columns: TableColumn[] = useMemo(() => [ const columns: TableColumn[] = useMemo(() => [
{ key: 'code', label: 'کد', sortable: true }, { key: 'code', label: 'کد', sortable: true },
{ key: 'name', label: 'نام', sortable: true }, { key: 'name', label: 'نام', sortable: true },
@ -88,14 +41,18 @@ const DiscountCodesListPage = () => {
{ {
key: 'status', key: 'status',
label: 'وضعیت', label: 'وضعیت',
render: (val: string) => <StatusBadge status={val} type="discount" /> render: (val: string) => (
<span className={`px-2 py-1 rounded-full text-xs ${val === 'active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
{val === 'active' ? 'فعال' : 'غیرفعال'}
</span>
)
}, },
{ {
key: 'period', key: 'period',
label: 'بازه زمانی', label: 'بازه زمانی',
render: (_val, row: any) => ( render: (_val, row: any) => (
<span> <span>
{row.valid_from ? formatDate(row.valid_from) : '-'} تا {row.valid_to ? formatDate(row.valid_to) : '-'} {row.valid_from ? new Date(row.valid_from).toLocaleDateString('fa-IR') : '-'} تا {row.valid_to ? new Date(row.valid_to).toLocaleDateString('fa-IR') : '-'}
</span> </span>
) )
}, },
@ -103,15 +60,25 @@ const DiscountCodesListPage = () => {
key: 'actions', key: 'actions',
label: 'عملیات', label: 'عملیات',
render: (_val, row: any) => ( render: (_val, row: any) => (
<ActionButtons <div className="flex items-center gap-2">
onView={() => handleOpenUsageModal(row as DiscountCode)} <button
viewTitle="آمار استفاده" onClick={() => handleEdit(row.id)}
onEdit={() => handleEdit(row.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
onDelete={() => setDeleteId(row.id.toString())} title="ویرایش"
/> >
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteId(row.id.toString())}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
) )
} }
], [handleEdit, handleOpenUsageModal]); ], [navigate]);
if (error) { if (error) {
return ( return (
@ -123,7 +90,16 @@ const DiscountCodesListPage = () => {
); );
} }
const createButton = ( return (
<div className="p-6 space-y-6">
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<BadgePercent className="h-6 w-6" />
مدیریت کدهای تخفیف
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">ایجاد و مدیریت کدهای تخفیف</p>
</div>
<button <button
onClick={handleCreate} 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" 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"
@ -132,19 +108,10 @@ const DiscountCodesListPage = () => {
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</button> </button>
); </div>
return ( <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<PageContainer> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-6">
<PageHeader
title="مدیریت کدهای تخفیف"
subtitle="ایجاد و مدیریت کدهای تخفیف"
icon={BadgePercent}
actions={createButton}
/>
<FiltersSection isLoading={isLoading} columns={3}>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد</label> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد</label>
<input <input
@ -155,167 +122,37 @@ const DiscountCodesListPage = () => {
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" 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>
</FiltersSection>
{isReportLoading ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<LoadingSpinner size="sm" text="در حال بارگذاری آمار..." />
</div>
) : reportError ? (
<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>
</div>
) : discountReport?.summary ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<Hash className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کل استفادهها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(discountReport.summary.total_usages)}
</p>
</div> </div>
</div> </div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع تخفیف داده شده</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(discountReport.summary.total_discount_given)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کاربران یونیک</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(discountReport.summary.unique_users)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<Hash className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کدهای یونیک</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(discountReport.summary.unique_codes)}
</p>
</div>
</div>
</div>
</div>
) : null}
{isLoading ? ( {isLoading ? (
<Table columns={columns} data={Array.isArray(discountCodes) ? (discountCodes as any[]) : []} loading={true} /> <Table columns={columns} data={Array.isArray(discountCodes) ? (discountCodes as any[]) : []} loading={true} />
) : !discountCodes || discountCodes.length === 0 ? ( ) : !discountCodes || discountCodes.length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<EmptyState <div className="text-center py-12">
icon={Ticket} <Ticket className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
title="هیچ کد تخفیفی یافت نشد" <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">هیچ کد تخفیفی یافت نشد</h3>
description="برای شروع یک کد تخفیف ایجاد کنید" <p className="text-gray-600 dark:text-gray-400 mb-4">برای شروع یک کد تخفیف ایجاد کنید</p>
actionLabel={ <Button onClick={handleCreate} className="flex items-center gap-2">
<>
<Plus className="h-4 w-4 ml-2" /> <Plus className="h-4 w-4 ml-2" />
ایجاد کد تخفیف ایجاد کد تخفیف
</> </Button>
} </div>
onAction={handleCreate}
/>
</div> </div>
) : ( ) : (
<Table columns={columns} data={discountCodes as any[]} /> <Table columns={columns} data={discountCodes as any[]} />
)} )}
<Modal <Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف کد تخفیف">
isOpen={isUsageModalOpen}
onClose={handleCloseUsageModal}
title={`آمار استفاده - ${selectedDiscount?.code || ''}`}
size="lg"
>
{isUsageLoading ? (
<LoadingSpinner size="sm" text="در حال بارگذاری آمار..." />
) : usageError ? (
<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>
</div>
) : selectedUsage ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> <p className="text-gray-600 dark:text-gray-400">آیا از حذف این کد تخفیف اطمینان دارید؟ این عمل قابل بازگشت نیست.</p>
<p className="text-sm text-gray-600 dark:text-gray-400">نام کد تخفیف</p> <div className="flex justify-end space-x-2 space-x-reverse">
<p className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <Button variant="secondary" onClick={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
{selectedDiscount?.name || '-'} <Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">تعداد استفاده</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(selectedUsage.usage_count)}
</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع تخفیف</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(selectedUsage.total_amount)}
</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">کاربران یونیک</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(selectedUsage.unique_users)}
</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">اولین استفاده</p>
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
{selectedUsage.first_used_at ? formatDateTime(selectedUsage.first_used_at) : '-'}
</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">آخرین استفاده</p>
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
{selectedUsage.last_used_at ? formatDateTime(selectedUsage.last_used_at) : '-'}
</p>
</div> </div>
</div> </div>
</div>
) : (
<div className="bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
<p className="text-gray-600 dark:text-gray-400">هیچ استفادهای ثبت نشده است</p>
</div>
)}
</Modal> </Modal>
<DeleteConfirmModal
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDeleteConfirm}
title="حذف کد تخفیف"
message="آیا از حذف این کد تخفیف اطمینان دارید؟ این عمل قابل بازگشت نیست."
isLoading={isDeleting}
/>
</div> </div>
</PageContainer>
); );
}; };

View File

@ -7,10 +7,10 @@ import { FileUploader } from "@/components/ui/FileUploader";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { useLandingHero, useUpdateLandingHero } from "./core/_hooks"; import { useLandingHero, useUpdateLandingHero } from "./core/_hooks";
import { LandingHeroData, HeroImage } from "./core/_models"; import { LandingHeroData, HeroImage } from "./core/_models";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { PlusCircle, Trash2, Save } from "lucide-react"; import { PlusCircle, Trash2, Save } from "lucide-react";
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload"; import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { PageContainer } from "@/components/ui/Typography";
const heroImageSchema = yup.object({ const heroImageSchema = yup.object({
alt_text: yup.string().required("متن ALT الزامی است"), alt_text: yup.string().required("متن ALT الزامی است"),
@ -92,19 +92,9 @@ export const HeroSliderPage = () => {
if (isLoading) { if (isLoading) {
return ( return (
<PageContainer> <div className="min-h-screen flex items-center justify-center">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(5)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
); );
} }

View File

@ -4,6 +4,7 @@ import { useOrder, useUpdateOrderStatus } from '../core/_hooks';
import { OrderStatus } from '../core/_models'; import { OrderStatus } from '../core/_models';
import { useShippingMethods } from '@/pages/shipping-methods/core/_hooks'; import { useShippingMethods } from '@/pages/shipping-methods/core/_hooks';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography"; import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
import { import {
@ -21,7 +22,6 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { englishToPersian } from '@/utils/numberUtils'; import { englishToPersian } from '@/utils/numberUtils';
import { API_GATE_WAY } from '@/constant/routes'; import { API_GATE_WAY } from '@/constant/routes';
import { formatCurrency, formatDateTime } from '@/utils/formatters';
const resolveImageUrl = (imageUrl?: string): string => { const resolveImageUrl = (imageUrl?: string): string => {
if (!imageUrl) return ''; if (!imageUrl) return '';
@ -56,15 +56,25 @@ const getStatusText = (status: OrderStatus) => {
return text[status] || status; return text[status] || status;
}; };
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('fa-IR').format(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatPaymentType = (type?: string) => { const formatPaymentType = (type?: string) => {
if (!type) return ''; if (!type) return '';
const key = type.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-'); const key = type.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-');
const mapping: Record<string, string> = { const mapping: Record<string, string> = {
'bank-topup': 'افزایش موجودی کیف پول', 'card-to-card': 'کارت به کارت',
'card-to-card': 'پرداخت به روش کارت به کارت',
'debit-rial-wallet': 'پرداخت از کیف ریالی',
'debit-gold18k-wallet': 'پرداخت از کیف طلا',
'credit-card': 'پرداخت بانکی', 'credit-card': 'پرداخت بانکی',
'debit-card': 'کارت بانکی', 'debit-card': 'کارت بانکی',
'bank-transfer': 'حواله بانکی', 'bank-transfer': 'حواله بانکی',
@ -104,41 +114,7 @@ const OrderDetailPage = () => {
} }
}; };
if (isLoading) { if (isLoading) return <LoadingSpinner />;
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64"></div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
<div className="space-y-3">
{[...Array(4)].map((_, j) => (
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
<div className="space-y-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div className="space-y-3">
{[...Array(3)].map((_, j) => (
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</PageContainer>
);
}
if (error || !order) { if (error || !order) {
return ( return (
<PageContainer> <PageContainer>
@ -159,7 +135,7 @@ const OrderDetailPage = () => {
<div> <div>
<PageTitle>سفارش #{order?.order_number || 'نامشخص'}</PageTitle> <PageTitle>سفارش #{order?.order_number || 'نامشخص'}</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1"> <p className="text-gray-600 dark:text-gray-400 mt-1">
تاریخ ثبت: {order?.created_at ? formatDateTime(order.created_at) : 'نامشخص'} تاریخ ثبت: {order?.created_at ? formatDate(order.created_at) : 'نامشخص'}
</p> </p>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@ -355,7 +331,7 @@ const OrderDetailPage = () => {
</div> </div>
<div> <div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ ثبت</h4> <h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ ثبت</h4>
<p className="text-gray-600 dark:text-gray-400">{order?.created_at ? formatDateTime(order.created_at) : 'نامشخص'}</p> <p className="text-gray-600 dark:text-gray-400">{order?.created_at ? formatDate(order.created_at) : 'نامشخص'}</p>
</div> </div>
<div> <div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">شناسه فاکتور</h4> <h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">شناسه فاکتور</h4>
@ -367,7 +343,7 @@ const OrderDetailPage = () => {
</div> </div>
<div> <div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ آخرین بروزرسانی</h4> <h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ آخرین بروزرسانی</h4>
<p className="text-gray-600 dark:text-gray-400">{order?.updated_at ? formatDateTime(order.updated_at) : 'نامشخص'}</p> <p className="text-gray-600 dark:text-gray-400">{order?.updated_at ? formatDate(order.updated_at) : 'نامشخص'}</p>
</div> </div>
{/* روش حمل و نقل در داده‌های فعلی وجود ندارد */} {/* روش حمل و نقل در داده‌های فعلی وجود ندارد */}
{order?.tracking_number && ( {order?.tracking_number && (
@ -379,7 +355,7 @@ const OrderDetailPage = () => {
{order?.estimated_delivery && ( {order?.estimated_delivery && (
<div> <div>
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ تحویل تخمینی</h4> <h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ تحویل تخمینی</h4>
<p className="text-gray-600 dark:text-gray-400">{formatDateTime(order.estimated_delivery)}</p> <p className="text-gray-600 dark:text-gray-400">{formatDate(order.estimated_delivery)}</p>
</div> </div>
)} )}
{order?.shipping_method_id !== undefined && order?.shipping_method_id !== null && ( {order?.shipping_method_id !== undefined && order?.shipping_method_id !== null && (
@ -591,7 +567,7 @@ const OrderDetailPage = () => {
<select <select
value={newStatus} value={newStatus}
onChange={(e) => setNewStatus(e.target.value as OrderStatus)} onChange={(e) => setNewStatus(e.target.value as OrderStatus)}
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="pending">در انتظار</option> <option value="pending">در انتظار</option>
<option value="processing">در حال پردازش</option> <option value="processing">در حال پردازش</option>

View File

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

View File

@ -3,12 +3,10 @@ import { useForm, Controller } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup'; import * as yup from 'yup';
import { CreditCard } from 'lucide-react'; import { CreditCard } from 'lucide-react';
import { PageContainer } from '@/components/ui/Typography'; import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { formatDateTime } from '@/utils/formatters';
import { usePaymentCard, useUpdatePaymentCard } from '../core/_hooks'; import { usePaymentCard, useUpdatePaymentCard } from '../core/_hooks';
import { persianToEnglish } from '@/utils/numberUtils'; import { persianToEnglish } from '@/utils/numberUtils';
@ -37,6 +35,41 @@ const formatCardNumber = (value: string): string => {
return groups ? groups.join(' ') : cleaned; 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 CardFormPage = () => {
const { data, isLoading, error } = usePaymentCard(); const { data, isLoading, error } = usePaymentCard();
const { mutate: updateCard, isPending } = useUpdatePaymentCard(); const { mutate: updateCard, isPending } = useUpdatePaymentCard();
@ -94,23 +127,23 @@ 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) { if (isLoading) {
return ( return (
<PageContainer> <PageContainer>
<PageContainer> <div className="flex justify-center items-center h-64">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(4)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
</PageContainer> </PageContainer>
); );
} }
@ -129,16 +162,22 @@ const CardFormPage = () => {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
title="پرداخت کارت به کارت" <div>
subtitle="مدیریت اطلاعات کارت و فعال/غیرفعال کردن روش پرداخت" <PageTitle className="flex items-center gap-2">
icon={CreditCard} <CreditCard className="h-6 w-6" />
/> پرداخت کارت به کارت
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
مدیریت اطلاعات کارت و فعال/غیرفعال کردن روش پرداخت
</p>
</div>
</div>
{data?.updated_at && ( {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"> <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"> <p className="text-sm text-blue-800 dark:text-blue-200">
آخرین بهروزرسانی: {formatDateTime(data.updated_at)} آخرین بهروزرسانی: {formatDate(data.updated_at)}
</p> </p>
</div> </div>
)} )}

View File

@ -32,5 +32,3 @@ export const useUpdatePaymentCard = () => {

View File

@ -21,5 +21,3 @@ export interface UpdatePaymentCardResponse {

View File

@ -23,5 +23,3 @@ export const updatePaymentCard = async (

View File

@ -36,5 +36,3 @@ export const useUpdateIPGStatus = () => {

View File

@ -31,5 +31,3 @@ export const IPG_LABELS: Record<IPGType, string> = {

View File

@ -23,5 +23,3 @@ export const updateIPGStatus = async (

View File

@ -1,96 +1,58 @@
import React, { useMemo, useState } from 'react'; import React from 'react';
import { CreditCard, Loader2, TrendingUp, CheckCircle, XCircle, DollarSign } from 'lucide-react'; import { CreditCard, Loader2 } from 'lucide-react';
import { PageContainer } from '@/components/ui/Typography'; import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { Modal } from '@/components/ui/Modal';
import { Table } from '@/components/ui/Table';
import { formatDateTime } from '@/utils/formatters';
import { formatCurrency } from '@/utils/formatters';
import { formatWithThousands } from '@/utils/numberUtils';
import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks'; import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks';
import { IPGStatus, IPG_LABELS } from '../core/_models'; import { IPGStatus, IPG_LABELS } from '../core/_models';
import { usePaymentMethodsReport, usePaymentTransactionsReport } from '@/pages/reports/payment-statistics/core/_hooks';
import { TableColumn } from '@/types';
const getPaymentTypeLabel = (type: string): string => { const formatDate = (dateString: string) => {
const labels: Record<string, string> = { return new Date(dateString).toLocaleDateString('fa-IR', {
'bank-topup': 'افزایش موجودی کیف پول', year: 'numeric',
'card-to-card': 'پرداخت به روش کارت به کارت', month: 'long',
'debit-rial-wallet': 'پرداخت از کیف ریالی', day: 'numeric',
'debit-gold18k-wallet': 'پرداخت از کیف طلا', hour: '2-digit',
unknown: 'نامشخص', minute: '2-digit',
});
}; };
return labels[type] || type;
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 IPGListPage = () => {
const { data, isLoading, error } = useIPGStatus(); const { data, isLoading, error } = useIPGStatus();
const { mutate: updateStatus, isPending } = useUpdateIPGStatus(); const { mutate: updateStatus, isPending } = useUpdateIPGStatus();
const [selectedPaymentType, setSelectedPaymentType] = useState<string | null>(null);
const [isTransactionsModalOpen, setIsTransactionsModalOpen] = useState(false);
const paymentReportFilters = useMemo(
() => ({
limit: 50,
offset: 0,
group_by_user: false,
}),
[]
);
const {
data: paymentMethodsReport,
isLoading: isPaymentReportLoading,
error: paymentReportError,
} = usePaymentMethodsReport(paymentReportFilters);
const transactionFilters = useMemo(() => {
if (!selectedPaymentType) {
return { limit: 0, offset: 0 } as const;
}
return {
payment_type: selectedPaymentType,
limit: 20,
offset: 0,
sort_by: 'date' as const,
sort_order: 'desc' as const,
};
}, [selectedPaymentType]);
const {
data: paymentTransactionsReport,
isLoading: isTransactionsLoading,
error: paymentTransactionsError,
} = usePaymentTransactionsReport(transactionFilters, isTransactionsModalOpen && !!selectedPaymentType);
const transactionColumns: TableColumn[] = useMemo(
() => [
{ key: 'order_number', label: 'شماره سفارش', align: 'right' },
{ key: 'customer_name', label: 'نام مشتری', align: 'right' },
{ key: 'amount', label: 'مبلغ', align: 'right' },
{ key: 'status', label: 'وضعیت', align: 'right' },
{ key: 'created_at', label: 'تاریخ', align: 'right' },
],
[]
);
const transactionTableData = (paymentTransactionsReport?.transactions || []).map((tx) => ({
order_number: tx.order_number || '-',
customer_name: tx.customer_name || '-',
amount: formatCurrency(tx.amount),
status: tx.status,
created_at: formatDateTime(tx.created_at),
}));
const handleOpenTransactionsModal = (paymentType: string) => {
setSelectedPaymentType(paymentType);
setIsTransactionsModalOpen(true);
};
const handleCloseTransactionsModal = () => {
setIsTransactionsModalOpen(false);
setSelectedPaymentType(null);
};
const handleToggle = (ipg: IPGStatus, newStatus: boolean) => { const handleToggle = (ipg: IPGStatus, newStatus: boolean) => {
updateStatus({ updateStatus({
@ -102,32 +64,8 @@ const IPGListPage = () => {
if (isLoading) { if (isLoading) {
return ( return (
<PageContainer> <PageContainer>
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <div className="flex justify-center items-center h-64">
<div> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-56 mb-2 animate-pulse"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-64 animate-pulse"></div>
</div>
</div>
<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">
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg animate-pulse"
>
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48 mt-2"></div>
</div>
<div className="h-6 w-11 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
</div>
))}
</div>
</div>
</div> </div>
</PageContainer> </PageContainer>
); );
@ -149,11 +87,17 @@ const IPGListPage = () => {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
title="مدیریت درگاه‌های پرداخت" <div>
subtitle="فعال یا غیرفعال کردن درگاه‌های پرداخت" <PageTitle className="flex items-center gap-2">
icon={CreditCard} <CreditCard className="h-6 w-6" />
/> مدیریت درگاههای پرداخت
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
فعال یا غیرفعال کردن درگاههای پرداخت
</p>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <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"> <div className="p-6">
@ -179,7 +123,7 @@ const IPGListPage = () => {
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
آخرین بهروزرسانی: {formatDateTime(ipg.updated_at)} آخرین بهروزرسانی: {formatDate(ipg.updated_at)}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -197,195 +141,6 @@ const IPGListPage = () => {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-6 space-y-6">
{isPaymentReportLoading ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<div className="text-sm text-gray-500 dark:text-gray-400">در حال بارگذاری گزارش پرداختها...</div>
</div>
) : paymentReportError ? (
<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>
</div>
) : paymentMethodsReport?.summary ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<CreditCard className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کل تراکنشها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(paymentMethodsReport.summary.total_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">موفق</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(paymentMethodsReport.summary.successful_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">ناموفق</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(paymentMethodsReport.summary.failed_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<TrendingUp className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">نرخ موفقیت</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(paymentMethodsReport.summary.overall_success_rate.toFixed(2))}%
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 md:col-span-2 lg:col-span-1">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<DollarSign className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع مبلغ</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(paymentMethodsReport.summary.total_amount)}
</p>
</div>
</div>
</div>
</div>
{paymentMethodsReport.summary.by_payment_type &&
Object.keys(paymentMethodsReport.summary.by_payment_type).length > 0 && (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
آمار تفکیکی روشهای پرداخت
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(paymentMethodsReport.summary.by_payment_type).map(([type, stats]) => (
<div
key={type}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
>
<div className="flex items-center justify-between mb-3">
<h4 className="font-semibold text-gray-900 dark:text-gray-100">
{getPaymentTypeLabel(type)}
</h4>
<button
onClick={() => handleOpenTransactionsModal(type)}
className="text-xs text-primary-600 hover:text-primary-700"
>
مشاهده تراکنشها
</button>
</div>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">کل:</span>
<span className="font-medium">{formatWithThousands(stats.count)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">موفق:</span>
<span className="font-medium text-green-600">
{formatWithThousands(stats.success_count)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">ناموفق:</span>
<span className="font-medium text-red-600">
{formatWithThousands(stats.failed_count)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">نرخ موفقیت:</span>
<span className="font-medium">
{formatWithThousands(stats.success_rate.toFixed(2))}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</>
) : null}
</div>
<Modal
isOpen={isTransactionsModalOpen}
onClose={handleCloseTransactionsModal}
title={`تراکنش‌ها - ${selectedPaymentType ? getPaymentTypeLabel(selectedPaymentType) : ''}`}
size="xl"
>
{isTransactionsLoading ? (
<div className="text-sm text-gray-500 dark:text-gray-400">در حال بارگذاری تراکنشها...</div>
) : paymentTransactionsError ? (
<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>
</div>
) : paymentTransactionsReport ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">کل تراکنشها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(paymentTransactionsReport.summary.total_transactions)}
</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">موفق</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(paymentTransactionsReport.summary.successful_count)}
</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">ناموفق</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(paymentTransactionsReport.summary.failed_count)}
</p>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین مبلغ</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(paymentTransactionsReport.summary.average_transaction_amount)}
</p>
</div>
</div>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
<Table columns={transactionColumns} data={transactionTableData} />
</div>
</div>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400">دادهای یافت نشد</div>
)}
</Modal>
</PageContainer> </PageContainer>
); );
}; };
@ -396,5 +151,3 @@ export default IPGListPage;

View File

@ -7,6 +7,7 @@ import { usePermission, useCreatePermission, useUpdatePermission } from '../core
import { PermissionFormData } from '../core/_models'; import { PermissionFormData } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography'; import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
@ -80,19 +81,9 @@ const PermissionFormPage = () => {
if (isEdit && isLoadingPermission) { if (isEdit && isLoadingPermission) {
return ( return (
<PageContainer> <div className="flex justify-center items-center h-64">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(4)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
); );
} }

View File

@ -1,12 +1,72 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { usePermissions } from '../core/_hooks'; import { usePermissions } from '../core/_hooks';
import { Permission } from '../core/_models'; import { Permission } from '../core/_models';
import { Shield } from "lucide-react";
import { TableSkeleton } from '@/components/common/TableSkeleton'; import { Shield, Plus } from "lucide-react";
import { PageHeader } from '@/components/layout/PageHeader';
import { EmptyState } from '@/components/common/EmptyState'; // Skeleton Loading Component
import { FiltersSection } from '@/components/common/FiltersSection'; const PermissionsTableSkeleton = () => (
import { formatDate } from '@/utils/formatters'; <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Desktop Table Skeleton */}
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
عنوان
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
توضیحات
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
تاریخ ایجاد
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, index) => (
<tr key={index} className="animate-pulse">
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-32"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-48"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-20"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Mobile Cards Skeleton */}
<div className="md:hidden p-4 space-y-4">
{[...Array(3)].map((_, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="space-y-3">
<div className="h-5 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-full"></div>
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
<div className="flex gap-2 pt-2">
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
</div>
</div>
</div>
))}
</div>
</div>
);
const PermissionsListPage = () => { const PermissionsListPage = () => {
const [filters, setFilters] = useState({ const [filters, setFilters] = useState({
@ -31,13 +91,22 @@ const PermissionsListPage = () => {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<PageHeader {/* Header */}
title="لیست دسترسی‌ها" <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
subtitle="نمایش دسترسی‌های سیستم" <div>
icon={Shield} <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
/> <Shield className="h-6 w-6" />
لیست دسترسیها
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
نمایش دسترسیهای سیستم
</p>
</div>
</div>
<FiltersSection> {/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو جستجو
@ -50,21 +119,26 @@ const PermissionsListPage = () => {
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" 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>
</FiltersSection> </div>
</div>
{/* Permissions Table */} {/* Permissions Table */}
{isLoading ? ( {isLoading ? (
<TableSkeleton columns={4} rows={5} /> <PermissionsTableSkeleton />
) : (permissions || []).length === 0 ? ( ) : (permissions || []).length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<EmptyState <div className="text-center py-12">
icon={Shield} <Shield className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
title="هیچ دسترسی یافت نشد" <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
description={filters.search هیچ دسترسی یافت نشد
</h3>
<p className="text-gray-600 dark:text-gray-400">
{filters.search
? "نتیجه‌ای برای جستجوی شما یافت نشد" ? "نتیجه‌ای برای جستجوی شما یافت نشد"
: "دسترسی‌های سیستم در اینجا نمایش داده می‌شوند" : "دسترسی‌های سیستم در اینجا نمایش داده می‌شوند"
} }
/> </p>
</div>
</div> </div>
) : ( ) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@ -98,7 +172,7 @@ const PermissionsListPage = () => {
{permission.description} {permission.description}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(permission.created_at)} {new Date(permission.created_at).toLocaleDateString('fa-IR')}
</td> </td>
</tr> </tr>
))} ))}
@ -122,7 +196,7 @@ const PermissionsListPage = () => {
</div> </div>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400"> <div className="text-xs text-gray-500 dark:text-gray-400">
تاریخ ایجاد: {formatDate(permission.created_at)} تاریخ ایجاد: {new Date(permission.created_at).toLocaleDateString('fa-IR')}
</div> </div>
</div> </div>
))} ))}

View File

@ -7,6 +7,7 @@ import { useProductOption, useCreateProductOption, useUpdateProductOption } from
import { ProductOptionFormData } from '../core/_models'; import { ProductOptionFormData } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { FileUploader } from "@/components/ui/FileUploader"; import { FileUploader } from "@/components/ui/FileUploader";
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload"; import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
import { ArrowRight, Plus, Trash2 } from "lucide-react"; import { ArrowRight, Plus, Trash2 } from "lucide-react";
@ -141,19 +142,9 @@ const ProductOptionFormPage = () => {
if (isLoadingOption) { if (isLoadingOption) {
return ( return (
<PageContainer> <div className="flex items-center justify-center min-h-screen">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(4)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
); );
} }

View File

@ -2,14 +2,58 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useProductOptions, useDeleteProductOption } from '../core/_hooks'; import { useProductOptions, useDeleteProductOption } from '../core/_hooks';
import { ProductOption } from '../core/_models'; import { ProductOption } from '../core/_models';
import { Settings, Tag, Plus } from "lucide-react"; import { Button } from "@/components/ui/Button";
import { TableSkeleton } from '@/components/common/TableSkeleton';
import { PageHeader } from '@/components/layout/PageHeader'; import { Trash2, Edit3, Plus, Settings, Tag } from "lucide-react";
import { EmptyState } from '@/components/common/EmptyState'; import { Modal } from "@/components/ui/Modal";
import { ActionButtons } from '@/components/common/ActionButtons';
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal'; const ProductOptionsTableSkeleton = () => (
import { FiltersSection } from '@/components/common/FiltersSection'; <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
import { formatDate } from '@/utils/formatters'; <div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
نام گزینه
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
مقادیر
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
تاریخ ایجاد
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
عملیات
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
const ProductOptionsListPage = () => { const ProductOptionsListPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -55,11 +99,17 @@ const ProductOptionsListPage = () => {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<PageHeader {/* Header */}
title="مدیریت گزینه‌های محصول" <div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
subtitle="تنظیمات گزینه‌های قابل انتخاب برای محصولات" <div>
icon={Settings} <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
actions={ <Settings className="h-6 w-6" />
مدیریت گزینههای محصول
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
تنظیمات گزینههای قابل انتخاب برای محصولات
</p>
</div>
<button <button
onClick={handleCreate} 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" 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"
@ -67,10 +117,11 @@ const ProductOptionsListPage = () => {
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</button> </button>
} </div>
/>
<FiltersSection> {/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو جستجو
@ -83,11 +134,12 @@ const ProductOptionsListPage = () => {
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" 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>
</FiltersSection> </div>
</div>
{/* Product Options Table */} {/* Product Options Table */}
{isLoading ? ( {isLoading ? (
<TableSkeleton columns={4} rows={5} /> <ProductOptionsTableSkeleton />
) : ( ) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Desktop Table */} {/* Desktop Table */}
@ -135,13 +187,25 @@ const ProductOptionsListPage = () => {
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatDate(option.created_at)} {new Date(option.created_at).toLocaleDateString('fa-IR')}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<ActionButtons <div className="flex items-center gap-2">
onEdit={() => handleEdit(option.id)} <button
onDelete={() => setDeleteOptionId(option.id.toString())} onClick={() => handleEdit(option.id)}
/> className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="ویرایش"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteOptionId(option.id.toString())}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@ -178,26 +242,77 @@ const ProductOptionsListPage = () => {
</div> </div>
</div> </div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3"> <div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
تاریخ ایجاد: {formatDate(option.created_at)} تاریخ ایجاد: {new Date(option.created_at).toLocaleDateString('fa-IR')}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(option.id)}
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
>
<Edit3 className="h-3 w-3" />
ویرایش
</button>
<button
onClick={() => setDeleteOptionId(option.id.toString())}
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
حذف
</button>
</div> </div>
<ActionButtons
onEdit={() => handleEdit(option.id)}
onDelete={() => setDeleteOptionId(option.id.toString())}
/>
</div> </div>
))} ))}
</div> </div>
{/* Empty State */}
{(!productOptions || productOptions.length === 0) && !isLoading && (
<div className="text-center py-12">
<Settings className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
گزینهای موجود نیست
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
برای شروع، اولین گزینه محصول خود را ایجاد کنید.
</p>
<div className="mt-6">
<Button onClick={handleCreate} className="flex items-center gap-2 mx-auto">
<Plus className="h-4 w-4" />
ایجاد گزینه جدید
</Button>
</div>
</div>
)}
</div> </div>
)} )}
<DeleteConfirmModal {/* Delete Confirmation Modal */}
<Modal
isOpen={!!deleteOptionId} isOpen={!!deleteOptionId}
onClose={() => setDeleteOptionId(null)} onClose={() => setDeleteOptionId(null)}
onConfirm={handleDeleteConfirm}
title="حذف گزینه محصول" title="حذف گزینه محصول"
message="آیا از حذف این گزینه محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که از این گزینه استفاده می‌کنند تأثیر بگذارد." >
isLoading={isDeleting} <div className="space-y-4">
/> <p className="text-gray-600 dark:text-gray-400">
آیا از حذف این گزینه محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که از این گزینه استفاده میکنند تأثیر بگذارد.
</p>
<div className="flex justify-end space-x-2 space-x-reverse">
<Button
variant="secondary"
onClick={() => setDeleteOptionId(null)}
disabled={isDeleting}
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={isDeleting}
>
حذف
</Button>
</div>
</div>
</Modal>
</div> </div>
); );
}; };

View File

@ -1,348 +0,0 @@
import React, { useState } from 'react';
import { useProductComments, useUpdateCommentStatus, useDeleteComment } from '../core/_hooks';
import { ProductCommentFilters, CommentStatus } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { Modal } from '@/components/ui/Modal';
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';
import { formatDateTime } from '@/utils/formatters';
const getStatusColor = (status: CommentStatus) => {
const colors = {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
approved: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
rejected: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
};
return colors[status] || colors.pending;
};
const getStatusText = (status: CommentStatus) => {
const text = {
pending: 'در انتظار',
approved: 'تایید شده',
rejected: 'رد شده',
};
return text[status] || status;
};
const ProductCommentsListPage = () => {
const [filters, setFilters] = useState<ProductCommentFilters>({
limit: 20,
offset: 0,
});
const [statusUpdateId, setStatusUpdateId] = useState<number | null>(null);
const [newStatus, setNewStatus] = useState<'approved' | 'rejected'>('approved');
const [deleteId, setDeleteId] = useState<number | null>(null);
const { data, isLoading, error } = useProductComments(filters);
const { mutate: updateStatus, isPending: isUpdating } = useUpdateCommentStatus();
const { mutate: deleteComment, isPending: isDeleting } = useDeleteComment();
const handleFilterChange = (key: keyof ProductCommentFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
offset: 0,
}));
};
const handleNumericFilterChange = (key: 'productId' | 'userId', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
handleFilterChange(key, numeric);
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleStatusUpdate = () => {
if (statusUpdateId === null) return;
updateStatus(
{ commentId: statusUpdateId.toString(), payload: { status: newStatus } },
{
onSuccess: () => {
setStatusUpdateId(null);
},
}
);
};
const handleDeleteConfirm = () => {
if (deleteId === null) return;
deleteComment(deleteId.toString(), {
onSuccess: () => {
setDeleteId(null);
},
});
};
const columns: TableColumn[] = [
{
key: 'user_name',
label: 'کاربر',
align: 'right',
},
{
key: 'product_id',
label: 'شناسه محصول',
align: 'right',
},
{
key: 'rating',
label: 'امتیاز',
align: 'right',
render: (_val, row) => '⭐'.repeat(row.rating) + ` (${row.rating})`,
},
{
key: 'subject',
label: 'موضوع',
align: 'right',
},
{
key: 'comment',
label: 'نظر',
align: 'right',
render: (_val, row) => row.comment.length > 50
? row.comment.substring(0, 50) + '...'
: row.comment,
},
{
key: 'comment_status',
label: 'وضعیت',
align: 'right',
render: (_val, row) => (
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(row.comment_status)}`}>
{getStatusText(row.comment_status)}
</span>
),
},
{
key: 'created_at',
label: 'تاریخ ایجاد',
align: 'right',
render: (val) => formatDateTime(val),
},
{
key: 'actions',
label: 'عملیات',
align: 'center',
render: (_val, row) => (
<div className="flex items-center justify-center gap-2">
{row.comment_status === 'pending' && (
<>
<button
onClick={() => {
setStatusUpdateId(row.id);
setNewStatus('approved');
}}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300 p-1"
title="تایید"
>
<CheckCircle className="h-4 w-4" />
</button>
<button
onClick={() => {
setStatusUpdateId(row.id);
setNewStatus('rejected');
}}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
title="رد"
>
<XCircle className="h-4 w-4" />
</button>
</>
)}
<button
onClick={() => setDeleteId(row.id)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
),
},
];
const tableData = (data?.comments || []).map(comment => ({
id: comment.id,
user_name: comment.user
? `${comment.user.first_name} ${comment.user.last_name}`.trim() || '-'
: '-',
product_id: formatWithThousands(comment.product_id),
rating: comment.rating,
subject: comment.subject || '-',
comment: comment.comment,
comment_status: comment.comment_status,
created_at: comment.created_at,
})) || [];
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1;
return (
<PageContainer>
<PageTitle>مدیریت نظرات محصولات</PageTitle>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center gap-2 mb-4">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
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="pending">در انتظار</option>
<option value="approved">تایید شده</option>
<option value="rejected">رد شده</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه محصول
</label>
<Input
value={filters.productId?.toString() || ''}
onChange={(e) => handleNumericFilterChange('productId', e.target.value)}
placeholder="مثلاً 123"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر
</label>
<Input
value={filters.userId?.toString() || ''}
onChange={(e) => handleNumericFilterChange('userId', e.target.value)}
placeholder="مثلاً 456"
numeric
/>
</div>
</div>
</div>
{/* Table */}
{isLoading ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={[]} loading={true} />
</div>
) : 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>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<MessageSquare className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400">نظری یافت نشد</p>
</div>
)}
</>
)}
{/* Status Update Modal */}
<Modal
isOpen={statusUpdateId !== null}
onClose={() => setStatusUpdateId(null)}
title="تغییر وضعیت نظر"
>
<div className="space-y-4">
<p className="text-gray-700 dark:text-gray-300">
آیا میخواهید وضعیت این نظر را به{' '}
<span className="font-semibold">
{newStatus === 'approved' ? 'تایید شده' : 'رد شده'}
</span>{' '}
تغییر دهید؟
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => setStatusUpdateId(null)}
>
انصراف
</Button>
<Button
onClick={handleStatusUpdate}
loading={isUpdating}
variant={newStatus === 'approved' ? 'success' : 'danger'}
>
تایید
</Button>
</div>
</div>
</Modal>
{/* Delete Modal */}
<Modal
isOpen={deleteId !== null}
onClose={() => setDeleteId(null)}
title="حذف نظر"
>
<div className="space-y-4">
<p className="text-gray-700 dark:text-gray-300">
آیا از حذف این نظر اطمینان دارید؟ این عمل قابل بازگشت نیست.
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => setDeleteId(null)}
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={isDeleting}
>
حذف
</Button>
</div>
</div>
</Modal>
</PageContainer>
);
};
export default ProductCommentsListPage;

View File

@ -1,56 +0,0 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import toast from "react-hot-toast";
import {
getProductComments,
updateCommentStatus,
deleteComment,
} from "./_requests";
import {
ProductCommentFilters,
UpdateCommentStatusRequest,
} from "./_models";
export const useProductComments = (filters: ProductCommentFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS, filters],
queryFn: () => getProductComments(filters),
});
};
export const useUpdateCommentStatus = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
commentId,
payload,
}: {
commentId: string;
payload: UpdateCommentStatusRequest;
}) => updateCommentStatus(commentId, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS] });
toast.success("وضعیت نظر با موفقیت تغییر کرد");
},
onError: () => {
toast.error("خطا در تغییر وضعیت نظر");
},
});
};
export const useDeleteComment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (commentId: string) => deleteComment(commentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS] });
toast.success("نظر با موفقیت حذف شد");
},
onError: () => {
toast.error("خطا در حذف نظر");
},
});
};

View File

@ -1,47 +0,0 @@
export type CommentStatus = 'pending' | 'approved' | 'rejected';
export interface ProductCommentFilters {
status?: CommentStatus;
productId?: number;
userId?: number;
limit: number;
offset: number;
}
export interface User {
first_name: string;
last_name: string;
}
export interface ProductComment {
id: number;
user_id: number;
product_id: number;
rating: number;
subject: string;
comment: string;
comment_status: CommentStatus;
created_at: string; // ISO 8601
updated_at: string; // ISO 8601
user?: User;
}
export interface ProductCommentsResponse {
comments: ProductComment[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface UpdateCommentStatusRequest {
status: 'approved' | 'rejected';
}
export interface UpdateCommentStatusResponse extends ProductComment {}
export interface DeleteCommentResponse {
message: string;
deleted: boolean;
}

View File

@ -1,52 +0,0 @@
import {
httpGetRequest,
httpPutRequest,
httpDeleteRequest,
APIUrlGenerator,
} from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
ProductCommentFilters,
ProductCommentsResponse,
UpdateCommentStatusRequest,
UpdateCommentStatusResponse,
DeleteCommentResponse,
} from "./_models";
export const getProductComments = async (
filters: ProductCommentFilters
): Promise<ProductCommentsResponse> => {
const queryParams: Record<string, string | number> = {};
if (filters.status) queryParams.status = filters.status;
if (filters.productId) queryParams.productId = filters.productId;
if (filters.userId) queryParams.userId = filters.userId;
queryParams.limit = filters.limit;
queryParams.offset = filters.offset;
const response = await httpGetRequest<ProductCommentsResponse>(
APIUrlGenerator(API_ROUTES.GET_PRODUCT_COMMENTS, queryParams)
);
return response.data;
};
export const updateCommentStatus = async (
commentId: string,
payload: UpdateCommentStatusRequest
): Promise<UpdateCommentStatusResponse> => {
const response = await httpPutRequest<UpdateCommentStatusResponse>(
APIUrlGenerator(API_ROUTES.UPDATE_COMMENT_STATUS(commentId)),
payload
);
return response.data;
};
export const deleteComment = async (
commentId: string
): Promise<DeleteCommentResponse> => {
const response = await httpDeleteRequest<DeleteCommentResponse>(
APIUrlGenerator(API_ROUTES.DELETE_COMMENT(commentId))
);
return response.data;
};

View File

@ -17,7 +17,6 @@ export interface ProductVariant {
enabled: boolean; enabled: boolean;
fee_percentage: number; fee_percentage: number;
profit_percentage: number; profit_percentage: number;
tax_percentage: number;
stock_limit: number; stock_limit: number;
stock_managed: boolean; stock_managed: boolean;
stock_number: number; stock_number: number;
@ -78,7 +77,6 @@ export interface ProductVariantFormData {
enabled: boolean; enabled: boolean;
fee_percentage: number; fee_percentage: number;
profit_percentage: number; profit_percentage: number;
tax_percentage: number;
stock_limit: number; stock_limit: number;
stock_managed: boolean; stock_managed: boolean;
stock_number: number; stock_number: number;
@ -135,7 +133,6 @@ export interface CreateVariantRequest {
enabled: boolean; enabled: boolean;
fee_percentage: number; fee_percentage: number;
profit_percentage: number; profit_percentage: number;
tax_percentage: number;
stock_limit: number; stock_limit: number;
stock_managed: boolean; stock_managed: boolean;
stock_number: number; stock_number: number;
@ -150,7 +147,6 @@ export interface UpdateVariantRequest {
enabled: boolean; enabled: boolean;
fee_percentage: number; fee_percentage: number;
profit_percentage: number; profit_percentage: number;
tax_percentage: number;
stock_limit: number; stock_limit: number;
stock_managed: boolean; stock_managed: boolean;
stock_number: number; stock_number: number;

View File

@ -3,10 +3,10 @@ import { useState } from 'react';
import { Modal } from '../../../components/ui/Modal'; import { Modal } from '../../../components/ui/Modal';
import { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign, Hash, Layers, Settings } from 'lucide-react'; import { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign, Hash, Layers, Settings } from 'lucide-react';
import { Button } from '../../../components/ui/Button'; import { Button } from '../../../components/ui/Button';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { useProduct } from '../core/_hooks'; import { useProduct } from '../core/_hooks';
import { PRODUCT_TYPE_LABELS } from '../core/_models'; import { PRODUCT_TYPE_LABELS } from '../core/_models';
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography'; import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
import { formatPrice, formatDate } from '../../../utils/formatters';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes'; import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
type NormalizedMedia = { type NormalizedMedia = {
@ -25,47 +25,14 @@ const ProductDetailPage = () => {
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: 'image' | 'video' } | null>(null); const [previewMedia, setPreviewMedia] = useState<{ url: string; type: 'image' | 'video' } | null>(null);
if (isLoading) { if (isLoading) return <LoadingSpinner />;
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
<div className="space-y-3">
{[...Array(4)].map((_, j) => (
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
<div className="space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div className="space-y-3">
{[...Array(3)].map((_, j) => (
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</PageContainer>
);
}
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>; if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>;
if (!product) return <div>محصول یافت نشد</div>; if (!product) return <div>محصول یافت نشد</div>;
const formatPrice = (price: number) => {
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
};
const formatNumber = (num: number) => { const formatNumber = (num: number) => {
return new Intl.NumberFormat('fa-IR').format(num); return new Intl.NumberFormat('fa-IR').format(num);
}; };
@ -433,12 +400,6 @@ const ProductDetailPage = () => {
{formatNumber(variant.profit_percentage)}% {formatNumber(variant.profit_percentage)}%
</span> </span>
</div> </div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">درصد مالیات</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatNumber(variant.tax_percentage || 0)}%
</span>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded"> <div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">وزن</span> <span className="text-xs text-gray-600 dark:text-gray-400 block">وزن</span>
<span className="font-medium text-gray-900 dark:text-gray-100"> <span className="font-medium text-gray-900 dark:text-gray-100">
@ -683,7 +644,7 @@ const ProductDetailPage = () => {
</span> </span>
</div> </div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100"> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatDate(product.created_at)} {new Date(product.created_at).toLocaleDateString('fa-IR')}
</p> </p>
</div> </div>
@ -695,7 +656,7 @@ const ProductDetailPage = () => {
</span> </span>
</div> </div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100"> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatDate(product.updated_at)} {new Date(product.updated_at).toLocaleDateString('fa-IR')}
</p> </p>
</div> </div>
</div> </div>

View File

@ -11,12 +11,11 @@ import { ProductFormData, ProductImage, ProductVariantFormData, PRODUCT_TYPES, P
import { MultiSelectAutocomplete } from "@/components/ui/MultiSelectAutocomplete"; import { MultiSelectAutocomplete } from "@/components/ui/MultiSelectAutocomplete";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { FileUploader } from "@/components/ui/FileUploader"; import { FileUploader } from "@/components/ui/FileUploader";
import { VariantManager } from "@/components/ui/VariantManager"; import { VariantManager } from "@/components/ui/VariantManager";
import { ArrowRight, X } from "lucide-react"; import { ArrowRight, X } from "lucide-react";
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography'; import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
import { FormSection } from '@/components/forms/FormSection';
import { FormActions } from '@/components/forms/FormActions';
import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils'; import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes'; import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
@ -179,7 +178,6 @@ const ProductFormPage = () => {
enabled: variant.enabled, enabled: variant.enabled,
fee_percentage: variant.fee_percentage, fee_percentage: variant.fee_percentage,
profit_percentage: variant.profit_percentage, profit_percentage: variant.profit_percentage,
tax_percentage: variant.tax_percentage || 0,
stock_limit: variant.stock_limit, stock_limit: variant.stock_limit,
stock_managed: variant.stock_managed, stock_managed: variant.stock_managed,
stock_number: variant.stock_number, stock_number: variant.stock_number,
@ -353,7 +351,6 @@ const ProductFormPage = () => {
enabled: variant.enabled, enabled: variant.enabled,
fee_percentage: variant.fee_percentage, fee_percentage: variant.fee_percentage,
profit_percentage: variant.profit_percentage, profit_percentage: variant.profit_percentage,
tax_percentage: variant.tax_percentage || 0,
stock_limit: variant.stock_limit, stock_limit: variant.stock_limit,
stock_managed: variant.stock_managed, stock_managed: variant.stock_managed,
stock_number: variant.stock_number, stock_number: variant.stock_number,
@ -383,7 +380,6 @@ const ProductFormPage = () => {
enabled: variant.enabled, enabled: variant.enabled,
fee_percentage: variant.fee_percentage, fee_percentage: variant.fee_percentage,
profit_percentage: variant.profit_percentage, profit_percentage: variant.profit_percentage,
tax_percentage: variant.tax_percentage || 0,
stock_limit: variant.stock_limit, stock_limit: variant.stock_limit,
stock_managed: variant.stock_managed, stock_managed: variant.stock_managed,
stock_number: variant.stock_number, stock_number: variant.stock_number,
@ -421,19 +417,9 @@ const ProductFormPage = () => {
if (isEdit && isLoadingProduct) { if (isEdit && isLoadingProduct) {
return ( return (
<PageContainer> <div className="flex justify-center items-center h-64">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(8)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
); );
} }
@ -473,7 +459,11 @@ const ProductFormPage = () => {
{/* Form */} {/* Form */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
<FormSection title="اطلاعات پایه"> {/* Basic Information */}
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
اطلاعات پایه
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="md:col-span-2"> <div className="md:col-span-2">
<Input <Input
@ -534,9 +524,13 @@ const ProductFormPage = () => {
)} )}
</div> </div>
</div> </div>
</FormSection> </div>
<FormSection title="دسته‌بندی و گزینه‌ها"> {/* Categories and Product Options */}
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
دستهبندی و گزینهها
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<MultiSelectAutocomplete <MultiSelectAutocomplete
label="دسته‌بندی‌ها" label="دسته‌بندی‌ها"
@ -558,7 +552,7 @@ const ProductFormPage = () => {
) : ( ) : (
<select <select
{...register('product_option_id')} {...register('product_option_id')}
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="">بدون گزینه</option> <option value="">بدون گزینه</option>
{productOptionOptions.map((option) => ( {productOptionOptions.map((option) => (
@ -576,9 +570,13 @@ const ProductFormPage = () => {
)} )}
</div> </div>
</div> </div>
</FormSection> </div>
<FormSection title="تصاویر محصول"> {/* Images */}
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
تصاویر محصول
</h3>
<FileUploader <FileUploader
onUpload={handleFileUpload} onUpload={handleFileUpload}
@ -631,9 +629,12 @@ const ProductFormPage = () => {
</div> </div>
</div> </div>
)} )}
</FormSection> </div>
<FormSection title="فایل‌های Explorer"> <div>
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
فایلهای Explorer
</h3>
<FileUploader <FileUploader
onUpload={handleExplorerUpload} onUpload={handleExplorerUpload}
onRemove={handleExplorerRemove} onRemove={handleExplorerRemove}
@ -696,16 +697,19 @@ const ProductFormPage = () => {
</span> </span>
</div> </div>
)} )}
</FormSection> </div>
<FormSection title="مدیریت Variants"> {/* Variants Management */}
<div>
<VariantManager <VariantManager
variants={watch('variants') || []} variants={watch('variants') || []}
onChange={(variants) => setValue('variants', variants, { shouldValidate: true, shouldDirty: true })} onChange={(variants) => setValue('variants', variants, { shouldValidate: true, shouldDirty: true })}
productOptions={productOptionOptions} productOptions={productOptionOptions}
variantAttributeName={watch('variant_attribute_name')} variantAttributeName={watch('variant_attribute_name')}
/> />
</FormSection> </div>
{/* Preview */} {/* Preview */}
{formValues.name && ( {formValues.name && (
@ -781,13 +785,24 @@ const ProductFormPage = () => {
</div> </div>
)} )}
<FormActions {/* Submit Buttons */}
onCancel={handleBack} <div className="flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600">
cancelLabel="انصراف" <Button
submitLabel={isEdit ? 'به‌روزرسانی' : 'ایجاد محصول'} type="button"
isLoading={isLoading} variant="secondary"
isDisabled={!isValid || isLoading || isUploading || isExplorerUploading} onClick={handleBack}
/> disabled={isLoading}
>
انصراف
</Button>
<Button
type="submit"
loading={isLoading}
disabled={!isValid || isLoading || isUploading || isExplorerUploading}
>
{isEdit ? 'به‌روزرسانی' : 'ایجاد محصول'}
</Button>
</div>
</form> </form>
</div> </div>

View File

@ -4,18 +4,65 @@ import { useProducts, useDeleteProduct } from '../core/_hooks';
import { useCategories } from '../../categories/core/_hooks'; import { useCategories } from '../../categories/core/_hooks';
import { Product } from '../core/_models'; import { Product } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { PageContainer } from "@/components/ui/Typography";
import { Plus, Package, Image } from "lucide-react"; import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react";
import { Modal } from "@/components/ui/Modal";
import { persianToEnglish } from '../../../utils/numberUtils'; import { persianToEnglish } from '../../../utils/numberUtils';
import { Pagination } from "@/components/ui/Pagination"; import { Pagination } from "@/components/ui/Pagination";
import { PageHeader } from "@/components/layout/PageHeader";
import { FiltersSection } from "@/components/common/FiltersSection"; const ProductsTableSkeleton = () => (
import { TableSkeleton } from "@/components/common/TableSkeleton"; <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
import { EmptyState } from "@/components/common/EmptyState"; <div className="hidden md:block">
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; <div className="overflow-x-auto">
import { ActionButtons } from "@/components/common/ActionButtons"; <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
import { StatusBadge } from "@/components/ui/StatusBadge"; <thead className="bg-gray-50 dark:bg-gray-700">
import { formatPrice } from "@/utils/formatters"; <tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
محصول
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
قیمت
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
دستهبندی
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
وضعیت
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
عملیات
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex gap-2">
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
const ProductsListPage = () => { const ProductsListPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -75,12 +122,29 @@ const ProductsListPage = () => {
setFilters(prev => ({ ...prev, status: e.target.value, page: 1 })); setFilters(prev => ({ ...prev, status: e.target.value, page: 1 }));
}; };
const formatPrice = (price: number) => {
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
};
const getFirstImageUrl = (p: any): string | null => { const getFirstImageUrl = (p: any): string | null => {
if (p.file_ids && p.file_ids.length > 0) return p.file_ids[0].url; if (p.file_ids && p.file_ids.length > 0) return p.file_ids[0].url;
if (p.files && p.files.length > 0) return p.files[0].url; if (p.files && p.files.length > 0) return p.files[0].url;
return null; return null;
}; };
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">فعال</span>;
case 'inactive':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">غیرفعال</span>;
case 'draft':
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">پیشنویس</span>;
default:
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">{status}</span>;
}
};
const total = productsData?.total || 0; const total = productsData?.total || 0;
const currentPage = productsData?.page || filters.page; const currentPage = productsData?.page || filters.page;
const perPage = productsData?.per_page || filters.limit; const perPage = productsData?.per_page || filters.limit;
@ -88,15 +152,27 @@ const ProductsListPage = () => {
if (error) { if (error) {
return ( return (
<PageContainer> <div className="p-6">
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری محصولات</p> <p className="text-red-600 dark:text-red-400">خطا در بارگذاری محصولات</p>
</div> </div>
</PageContainer> </div>
); );
} }
const createButton = ( return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
<Package className="h-6 w-6" />
مدیریت محصولات
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
مدیریت محصولات، قیمتها و موجودی
</p>
</div>
<button <button
onClick={handleCreate} 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" 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"
@ -104,19 +180,11 @@ const ProductsListPage = () => {
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</button> </button>
); </div>
return ( {/* Filters */}
<PageContainer> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="space-y-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<PageHeader
title="مدیریت محصولات"
subtitle="مدیریت محصولات، قیمت‌ها و موجودی"
icon={Package}
actions={createButton}
/>
<FiltersSection isLoading={isLoading} columns={4}>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
جستجو جستجو
@ -126,7 +194,7 @@ const ProductsListPage = () => {
placeholder="جستجو در نام محصول..." placeholder="جستجو در نام محصول..."
value={filters.search} value={filters.search}
onChange={handleSearchChange} 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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/> />
</div> </div>
<div> <div>
@ -136,7 +204,7 @@ const ProductsListPage = () => {
<select <select
value={filters.category_id} value={filters.category_id}
onChange={handleCategoryChange} 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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="">همه دستهبندیها</option> <option value="">همه دستهبندیها</option>
{(categories || []).map((category) => ( {(categories || []).map((category) => (
@ -153,7 +221,7 @@ const ProductsListPage = () => {
<select <select
value={filters.status} value={filters.status}
onChange={handleStatusChange} 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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="">همه وضعیتها</option> <option value="">همه وضعیتها</option>
<option value="active">فعال</option> <option value="active">فعال</option>
@ -175,7 +243,7 @@ const ProductsListPage = () => {
const converted = persianToEnglish(e.target.value); const converted = persianToEnglish(e.target.value);
setFilters(prev => ({ ...prev, min_price: converted, page: 1 })); 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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/> />
<input <input
type="text" type="text"
@ -186,29 +254,16 @@ const ProductsListPage = () => {
const converted = persianToEnglish(e.target.value); const converted = persianToEnglish(e.target.value);
setFilters(prev => ({ ...prev, max_price: converted, page: 1 })); 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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/> />
</div> </div>
</div> </div>
</FiltersSection> </div>
</div>
{/* Products Table */}
{isLoading ? ( {isLoading ? (
<TableSkeleton columns={5} rows={5} /> <ProductsTableSkeleton />
) : products.length === 0 ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<EmptyState
icon={Package}
title="محصولی موجود نیست"
description="برای شروع، اولین محصول خود را ایجاد کنید."
actionLabel={
<>
<Plus className="h-4 w-4" />
ایجاد محصول جدید
</>
}
onAction={handleCreate}
/>
</div>
) : ( ) : (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Desktop Table */} {/* Desktop Table */}
@ -271,14 +326,32 @@ const ProductsListPage = () => {
{product.category?.name || 'بدون دسته‌بندی'} {product.category?.name || 'بدون دسته‌بندی'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={product.status || ''} type="product" /> {getStatusBadge(product.status || '')}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<ActionButtons <div className="flex items-center gap-2">
onView={() => handleView(product.id)} <button
onEdit={() => handleEdit(product.id)} onClick={() => handleView(product.id)}
onDelete={() => setDeleteProductId(product.id.toString())} className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
/> title="مشاهده"
>
<Eye className="h-4 w-4" />
</button>
<button
onClick={() => handleEdit(product.id)}
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
title="ویرایش"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => setDeleteProductId(product.id.toString())}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@ -313,7 +386,7 @@ const ProductsListPage = () => {
{formatPrice(product.price || 0)} {formatPrice(product.price || 0)}
</p> </p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<StatusBadge status={product.status || ''} type="product" size="sm" /> {getStatusBadge(product.status || '')}
{product.category && ( {product.category && (
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{product.category.name} {product.category.name}
@ -322,16 +395,51 @@ const ProductsListPage = () => {
</div> </div>
</div> </div>
</div> </div>
<ActionButtons <div className="flex items-center gap-2">
onView={() => handleView(product.id)} <button
onEdit={() => handleEdit(product.id)} onClick={() => handleView(product.id)}
onDelete={() => setDeleteProductId(product.id.toString())} className="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
showLabels={true} >
size="sm" <Eye className="h-3 w-3" />
/> مشاهده
</button>
<button
onClick={() => handleEdit(product.id)}
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
>
<Edit3 className="h-3 w-3" />
ویرایش
</button>
<button
onClick={() => setDeleteProductId(product.id.toString())}
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
>
<Trash2 className="h-3 w-3" />
حذف
</button>
</div>
</div> </div>
))} ))}
</div> </div>
{/* Empty State */}
{(!products || products.length === 0) && !isLoading && (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
محصولی موجود نیست
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
برای شروع، اولین محصول خود را ایجاد کنید.
</p>
<div className="mt-6">
<Button onClick={handleCreate} className="flex items-center gap-2 mx-auto">
<Plus className="h-4 w-4" />
ایجاد محصول جدید
</Button>
</div>
</div>
)}
</div> </div>
)} )}
@ -344,16 +452,35 @@ const ProductsListPage = () => {
onPageChange={(page) => setFilters(prev => ({ ...prev, page }))} onPageChange={(page) => setFilters(prev => ({ ...prev, page }))}
/> />
<DeleteConfirmModal {/* Delete Confirmation Modal */}
<Modal
isOpen={!!deleteProductId} isOpen={!!deleteProductId}
onClose={() => setDeleteProductId(null)} onClose={() => setDeleteProductId(null)}
onConfirm={handleDeleteConfirm}
title="حذف محصول" title="حذف محصول"
message="آیا از حذف این محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام اطلاعات مربوط به محصول از جمله نسخه‌ها و تصاویر حذف خواهد شد." >
isLoading={isDeleting} <div className="space-y-4">
/> <p className="text-gray-600 dark:text-gray-400">
آیا از حذف این محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام اطلاعات مربوط به محصول از جمله نسخهها و تصاویر حذف خواهد شد.
</p>
<div className="flex justify-end space-x-2 space-x-reverse">
<Button
variant="secondary"
onClick={() => setDeleteProductId(null)}
disabled={isDeleting}
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={isDeleting}
>
حذف
</Button>
</div>
</div>
</Modal>
</div> </div>
</PageContainer>
); );
}; };

View File

@ -1,29 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import {
getDiscountUsageReport,
getCustomerDiscountUsageReport,
} from "./_requests";
import {
DiscountUsageFilters,
CustomerDiscountUsageFilters,
} from "./_models";
export const useDiscountUsageReport = (filters: DiscountUsageFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_DISCOUNT_USAGE_REPORT, filters],
queryFn: () => getDiscountUsageReport(filters),
enabled: filters.limit > 0,
});
};
export const useCustomerDiscountUsageReport = (
filters: CustomerDiscountUsageFilters
) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_CUSTOMER_DISCOUNT_USAGE_REPORT, filters],
queryFn: () => getCustomerDiscountUsageReport(filters),
enabled: filters.user_id > 0 && filters.limit > 0,
});
};

View File

@ -1,82 +0,0 @@
export interface DateRange {
from?: string; // ISO 8601
to?: string; // ISO 8601
}
export interface DiscountUsageFilters {
date_range?: DateRange;
discount_code?: string;
discount_id?: number;
user_id?: number;
group_by_code?: boolean;
limit: number;
offset: number;
}
export interface DiscountUsage {
discount_id: number;
discount_code: string;
discount_name: string;
usage_count: number;
total_amount: number; // ریال
unique_users: number;
first_used_at: string; // ISO 8601
last_used_at: string; // ISO 8601
}
export interface DiscountUsageSummary {
total_usages: number;
total_discount_given: number; // ریال
unique_users: number;
unique_codes: number;
most_used_code: string;
most_used_code_count: number;
}
export interface DiscountUsageResponse {
usages: DiscountUsage[] | null;
summary: DiscountUsageSummary;
total: number;
has_more: boolean;
limit: number;
offset: number;
}
export interface CustomerDiscountUsageFilters {
user_id: number; // Required
date_range?: DateRange;
discount_code?: string;
discount_id?: number;
limit: number;
offset: number;
}
export interface CustomerDiscountUsage {
discount_usage_id: number;
user_id: number;
customer_name: string;
discount_id: number;
discount_code: string;
discount_name: string;
order_id: number;
order_number: string;
amount: number; // ریال
used_at: string; // ISO 8601
}
export interface CustomerDiscountUsageSummary {
total_usages: number;
total_discount_amount: number; // ریال
unique_codes: number;
average_discount_per_order: number; // ریال
}
export interface CustomerDiscountUsageResponse {
usages: CustomerDiscountUsage[] | null;
summary: CustomerDiscountUsageSummary;
total: number;
has_more: boolean;
limit: number;
offset: number;
}

View File

@ -1,29 +0,0 @@
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
DiscountUsageFilters,
DiscountUsageResponse,
CustomerDiscountUsageFilters,
CustomerDiscountUsageResponse,
} from "./_models";
export const getDiscountUsageReport = async (
filters: DiscountUsageFilters
): Promise<DiscountUsageResponse> => {
const response = await httpPostRequest<DiscountUsageResponse>(
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT),
filters
);
return response.data;
};
export const getCustomerDiscountUsageReport = async (
filters: CustomerDiscountUsageFilters
): Promise<CustomerDiscountUsageResponse> => {
const response = await httpPostRequest<CustomerDiscountUsageResponse>(
APIUrlGenerator(API_ROUTES.CUSTOMER_DISCOUNT_USAGE_REPORT),
filters
);
return response.data;
};

View File

@ -1,295 +0,0 @@
import React, { useState } from 'react';
import { useCustomerDiscountUsageReport } from '../core/_hooks';
import { CustomerDiscountUsageFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
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 CustomerDiscountUsagePage = () => {
const [filters, setFilters] = useState<CustomerDiscountUsageFilters>({
user_id: 0,
limit: 50,
offset: 0,
});
const { data, isLoading, error } = useCustomerDiscountUsageReport(filters);
const handleFilterChange = (key: keyof CustomerDiscountUsageFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
offset: 0,
}));
};
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
setFilters(prev => ({
...prev,
date_range: {
from,
to,
},
offset: 0,
}));
};
const handleNumericFilterChange = (key: 'discount_id' | 'user_id', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
if (key === 'user_id') {
handleFilterChange('user_id', numeric || 0);
} else {
handleFilterChange(key, numeric);
}
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleClearFilters = () => {
setFilters({
user_id: 0,
limit: 50,
offset: 0,
});
};
const columns: TableColumn[] = [
{
key: 'discount_code',
label: 'کد تخفیف',
align: 'right',
},
{
key: 'discount_name',
label: 'نام کد تخفیف',
align: 'right',
},
{
key: 'order_number',
label: 'شماره سفارش',
align: 'right',
},
{
key: 'amount',
label: 'مبلغ تخفیف',
align: 'right',
},
{
key: 'used_at',
label: 'زمان استفاده',
align: 'right',
},
];
const tableData = (data?.usages || []).map(usage => ({
discount_code: usage.discount_code,
discount_name: usage.discount_name,
order_number: usage.order_number || '-',
amount: formatCurrency(usage.amount),
used_at: formatDateTime(usage.used_at),
}));
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1;
return (
<PageContainer>
<PageTitle>گزارش استفاده کاربر خاص از کدهای تخفیف</PageTitle>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر <span className="text-red-500">*</span>
</label>
<Input
value={filters.user_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
placeholder="مثلاً 456"
numeric
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
کد تخفیف
</label>
<Input
value={filters.discount_code || ''}
onChange={(e) => handleFilterChange('discount_code', e.target.value || undefined)}
placeholder="مثلاً SUMMER2025"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کد تخفیف
</label>
<Input
value={filters.discount_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('discount_id', e.target.value)}
placeholder="مثلاً 123"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ شروع
</label>
<JalaliDateTimePicker
value={filters.date_range?.from}
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
placeholder="انتخاب تاریخ شروع"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ پایان
</label>
<JalaliDateTimePicker
value={filters.date_range?.to}
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
placeholder="انتخاب تاریخ پایان"
/>
</div>
</div>
</div>
{/* Summary Cards */}
{data?.summary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<Hash className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کل استفادهها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.total_usages)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع تخفیف دریافتی</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.summary.total_discount_amount)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<Hash className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کدهای متفاوت</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.unique_codes)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<TrendingUp className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین تخفیف هر سفارش</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.summary.average_discount_per_order)}
</p>
</div>
</div>
</div>
</div>
)}
{/* Table */}
{isLoading ? (
<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>
</div>
) : filters.user_id === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-yellow-600 dark:text-yellow-400">لطفاً شناسه کاربر را وارد کنید</p>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default CustomerDiscountUsagePage;

View File

@ -1,326 +0,0 @@
import React, { useState } from 'react';
import { useDiscountUsageReport } from '../core/_hooks';
import { DiscountUsageFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
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 DiscountUsageReportPage = () => {
const [filters, setFilters] = useState<DiscountUsageFilters>({
limit: 50,
offset: 0,
group_by_code: false,
});
const { data, isLoading, error } = useDiscountUsageReport(filters);
const handleFilterChange = (key: keyof DiscountUsageFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
offset: 0, // Reset pagination when filters change
}));
};
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
setFilters(prev => ({
...prev,
date_range: {
from,
to,
},
offset: 0,
}));
};
const handleNumericFilterChange = (key: 'discount_id' | 'user_id', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
handleFilterChange(key, numeric);
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleClearFilters = () => {
setFilters({
limit: 50,
offset: 0,
group_by_code: false,
});
};
const columns: TableColumn[] = [
{
key: 'discount_code',
label: 'کد تخفیف',
align: 'right',
},
{
key: 'discount_name',
label: 'نام کد تخفیف',
align: 'right',
},
{
key: 'usage_count',
label: 'تعداد استفاده',
align: 'right',
},
{
key: 'total_amount',
label: 'مجموع تخفیف',
align: 'right',
},
{
key: 'unique_users',
label: 'کاربران یونیک',
align: 'right',
},
{
key: 'first_used_at',
label: 'اولین استفاده',
align: 'right',
},
{
key: 'last_used_at',
label: 'آخرین استفاده',
align: 'right',
},
];
const tableData = (data?.usages || []).map(usage => ({
discount_code: usage.discount_code,
discount_name: usage.discount_name,
usage_count: formatWithThousands(usage.usage_count),
total_amount: formatCurrency(usage.total_amount),
unique_users: formatWithThousands(usage.unique_users),
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;
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1;
return (
<PageContainer>
<PageTitle>گزارش جامع استفاده از کدهای تخفیف</PageTitle>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
کد تخفیف
</label>
<Input
value={filters.discount_code || ''}
onChange={(e) => handleFilterChange('discount_code', e.target.value || undefined)}
placeholder="مثلاً SUMMER2025"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کد تخفیف
</label>
<Input
value={filters.discount_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('discount_id', e.target.value)}
placeholder="مثلاً 123"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر
</label>
<Input
value={filters.user_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
placeholder="مثلاً 456"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ شروع
</label>
<JalaliDateTimePicker
value={filters.date_range?.from}
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
placeholder="انتخاب تاریخ شروع"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ پایان
</label>
<JalaliDateTimePicker
value={filters.date_range?.to}
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
placeholder="انتخاب تاریخ پایان"
/>
</div>
<div className="flex items-end">
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={filters.group_by_code || false}
onChange={(e) => handleFilterChange('group_by_code', e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
گروهبندی بر اساس کد
</label>
</div>
</div>
</div>
{/* Summary Cards */}
{data?.summary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<Hash className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کل استفادهها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.total_usages)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع تخفیف داده شده</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.summary.total_discount_given)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کاربران یونیک</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.unique_users)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<Hash className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کدهای یونیک</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.unique_codes)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
<TrendingUp className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">پرکاربردترین کد</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{data.summary.most_used_code || '-'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{formatWithThousands(data.summary.most_used_code_count)} بار استفاده
</p>
</div>
</div>
</div>
</div>
)}
{/* Table */}
{isLoading ? (
<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>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
{data && data.total > 0 && (data.total > filters.limit || data.has_more) && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default DiscountUsageReportPage;

View File

@ -1,23 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import { getPaymentMethodsReport, getPaymentTransactionsReport } from "./_requests";
import { PaymentMethodsFilters, PaymentTransactionsFilters } from "./_models";
export const usePaymentMethodsReport = (filters: PaymentMethodsFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_PAYMENT_METHODS_REPORT, filters],
queryFn: () => getPaymentMethodsReport(filters),
enabled: filters.limit > 0,
});
};
export const usePaymentTransactionsReport = (
filters: PaymentTransactionsFilters,
enabled: boolean = true
) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_PAYMENT_TRANSACTIONS_REPORT, filters],
queryFn: () => getPaymentTransactionsReport(filters),
enabled: enabled && filters.limit > 0,
});
};

View File

@ -1,114 +0,0 @@
export interface DateRange {
from?: string; // ISO 8601
to?: string; // ISO 8601
}
export interface PaymentMethodsFilters {
user_id?: number;
date_range?: DateRange;
payment_type?: string;
status?: 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled';
group_by_user?: boolean;
limit: number;
offset: number;
}
export interface PaymentMethod {
user_id: number;
customer_name: string;
customer_phone: string;
payment_type: string;
successful_count: number;
failed_count: number;
total_attempts: number;
total_amount: number; // ریال
success_rate: number; // درصد (0-100)
first_used_at: string; // ISO 8601
last_used_at: string; // ISO 8601
}
export interface PaymentTypeSummary {
count: number;
success_count: number;
failed_count: number;
total_amount: number; // ریال
percentage: number;
success_rate: number; // درصد
}
export interface PaymentMethodsSummary {
total_transactions: number;
successful_transactions: number;
failed_transactions: number;
total_amount: number; // ریال
by_payment_type: Record<string, PaymentTypeSummary>;
overall_success_rate: number; // درصد
}
export interface PaymentMethodsResponse {
payment_methods: PaymentMethod[];
summary: PaymentMethodsSummary;
total: number;
has_more: boolean;
limit: number;
offset: number;
}
export interface PaymentTransactionsFilters {
user_id?: number;
phone_number?: string;
order_id?: number;
transaction_id?: string;
reference_number?: string;
status?: 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled';
payment_type?: string;
min_amount?: number;
max_amount?: number;
from_date?: string; // RFC3339
to_date?: string; // RFC3339
group_by_status?: boolean;
group_by_type?: boolean;
sort_by?: 'amount' | 'date' | 'status';
sort_order?: 'asc' | 'desc';
limit: number;
offset: number;
}
export interface PaymentTransaction {
payment_id: number;
transaction_id: string;
reference_number: string;
user_id: number;
customer_name: string;
customer_phone: string;
invoice_id: number;
order_number: string;
amount: number; // ریال
payment_type: string;
status: string;
step: string;
created_at: string; // ISO 8601
updated_at: string; // ISO 8601
}
export interface PaymentTransactionsSummary {
total_transactions: number;
successful_count: number;
failed_count: number;
pending_count: number;
cancelled_count: number;
refunded_count: number;
total_success_amount: number; // ریال
total_failed_amount: number; // ریال
success_rate: number; // درصد
average_transaction_amount: number; // ریال
}
export interface PaymentTransactionsResponse {
transactions: PaymentTransaction[];
summary: PaymentTransactionsSummary;
total: number;
has_more: boolean;
limit: number;
offset: number;
}

View File

@ -1,65 +0,0 @@
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
PaymentMethodsFilters,
PaymentMethodsResponse,
PaymentTransactionsFilters,
PaymentTransactionsResponse,
} from "./_models";
export const getPaymentMethodsReport = async (
filters: PaymentMethodsFilters
): Promise<PaymentMethodsResponse> => {
const queryParams: Record<string, string | number | null> = {};
if (filters.user_id) queryParams.user_id = filters.user_id;
if (filters.payment_type) queryParams.payment_type = filters.payment_type;
if (filters.status) queryParams.status = filters.status;
if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
if (typeof filters.group_by_user === "boolean") {
queryParams.group_by_user = filters.group_by_user ? "true" : "false";
}
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
const response = await httpGetRequest<PaymentMethodsResponse>(
APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT, queryParams)
);
return response.data;
};
export const getPaymentTransactionsReport = async (
filters: PaymentTransactionsFilters
): Promise<PaymentTransactionsResponse> => {
const queryParams: Record<string, string | number | null> = {};
if (filters.user_id) queryParams.user_id = filters.user_id;
if (filters.phone_number) queryParams.phone_number = filters.phone_number;
if (filters.order_id) queryParams.order_id = filters.order_id;
if (filters.transaction_id)
queryParams.transaction_id = filters.transaction_id;
if (filters.reference_number)
queryParams.reference_number = filters.reference_number;
if (filters.status) queryParams.status = filters.status;
if (filters.payment_type) queryParams.payment_type = filters.payment_type;
if (filters.min_amount) queryParams.min_amount = filters.min_amount;
if (filters.max_amount) queryParams.max_amount = filters.max_amount;
if (filters.from_date) queryParams.from_date = filters.from_date;
if (filters.to_date) queryParams.to_date = filters.to_date;
if (typeof filters.group_by_status === "boolean") {
queryParams.group_by_status = filters.group_by_status ? "true" : "false";
}
if (typeof filters.group_by_type === "boolean") {
queryParams.group_by_type = filters.group_by_type ? "true" : "false";
}
if (filters.sort_by) queryParams.sort_by = filters.sort_by;
if (filters.sort_order) queryParams.sort_order = filters.sort_order;
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
const response = await httpGetRequest<PaymentTransactionsResponse>(
APIUrlGenerator(API_ROUTES.PAYMENT_TRANSACTIONS_REPORT, queryParams)
);
return response.data;
};

View File

@ -1,433 +0,0 @@
import React, { useState } from 'react';
import { usePaymentMethodsReport } from '../core/_hooks';
import { PaymentMethodsFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
import { PageContainer, PageTitle } from '@/components/ui/Typography';
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';
import { formatCurrency, formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const formatPercentage = (value: number) => {
return formatWithThousands(value.toFixed(2)) + '%';
};
const getPaymentTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
'bank-topup': 'افزایش موجودی کیف پول',
'card-to-card': 'پرداخت به روش کارت به کارت',
'debit-rial-wallet': 'پرداخت از کیف ریالی',
'debit-gold18k-wallet': 'پرداخت از کیف طلا',
};
return labels[type] || type;
};
const PaymentMethodsReportPage = () => {
const [filters, setFilters] = useState<PaymentMethodsFilters>({
limit: 50,
offset: 0,
group_by_user: false,
});
const { data, isLoading, error } = usePaymentMethodsReport(filters);
const handleFilterChange = (key: keyof PaymentMethodsFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
offset: 0,
}));
};
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
setFilters(prev => ({
...prev,
date_range: {
from,
to,
},
offset: 0,
}));
};
const handleNumericFilterChange = (key: 'user_id', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
handleFilterChange(key, numeric);
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleClearFilters = () => {
setFilters({
limit: 50,
offset: 0,
group_by_user: false,
});
};
const columns: TableColumn[] = [
{
key: 'customer_name',
label: 'نام مشتری',
align: 'right',
},
{
key: 'customer_phone',
label: 'شماره تماس',
align: 'right',
},
{
key: 'payment_type',
label: 'نوع پرداخت',
align: 'right',
},
{
key: 'successful_count',
label: 'موفق',
align: 'right',
},
{
key: 'failed_count',
label: 'ناموفق',
align: 'right',
},
{
key: 'total_attempts',
label: 'کل تلاش‌ها',
align: 'right',
},
{
key: 'total_amount',
label: 'مجموع مبلغ',
align: 'right',
},
{
key: 'success_rate',
label: 'نرخ موفقیت',
align: 'right',
},
{
key: 'first_used_at',
label: 'اولین استفاده',
align: 'right',
},
{
key: 'last_used_at',
label: 'آخرین استفاده',
align: 'right',
},
];
const tableData = (data?.payment_methods || []).map(method => ({
customer_name: method.customer_name || '-',
customer_phone: method.customer_phone || '-',
payment_type: getPaymentTypeLabel(method.payment_type),
successful_count: formatWithThousands(method.successful_count),
failed_count: formatWithThousands(method.failed_count),
total_attempts: formatWithThousands(method.total_attempts),
total_amount: formatCurrency(method.total_amount),
success_rate: formatPercentage(method.success_rate),
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;
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1;
return (
<PageContainer>
<PageTitle>گزارش روشهای پرداخت</PageTitle>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<Button
variant="secondary"
size="sm"
onClick={handleClearFilters}
className="flex items-center gap-2"
>
<X className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر
</label>
<Input
value={filters.user_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
placeholder="مثلاً 456"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نوع پرداخت
</label>
<select
value={filters.payment_type || ''}
onChange={(e) => handleFilterChange('payment_type', e.target.value || undefined)}
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="bank-topup">افزایش موجودی کیف پول</option>
<option value="card-to-card">پرداخت به روش کارت به کارت</option>
<option value="debit-rial-wallet">پرداخت از کیف ریالی</option>
<option value="debit-gold18k-wallet">پرداخت از کیف طلا</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={(e) => handleFilterChange('status', e.target.value || undefined)}
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="pending">در انتظار</option>
<option value="paid">پرداخت شده</option>
<option value="failed">ناموفق</option>
<option value="refunded">مرجوع شده</option>
<option value="cancelled">لغو شده</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ شروع
</label>
<JalaliDateTimePicker
value={filters.date_range?.from}
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
placeholder="انتخاب تاریخ شروع"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تاریخ پایان
</label>
<JalaliDateTimePicker
value={filters.date_range?.to}
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
placeholder="انتخاب تاریخ پایان"
/>
</div>
<div className="flex items-end">
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={filters.group_by_user || false}
onChange={(e) => handleFilterChange('group_by_user', e.target.checked)}
className="rounded border-gray-300 dark:border-gray-600"
/>
گروهبندی بر اساس کاربر
</label>
</div>
</div>
</div>
{/* Summary Cards */}
{data?.summary && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<CreditCard className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کل تراکنشها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.total_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">تراکنشهای موفق</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.successful_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">تراکنشهای ناموفق</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.failed_transactions)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<TrendingUp className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">نرخ موفقیت کلی</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatPercentage(data.summary.overall_success_rate)}
</p>
</div>
</div>
</div>
</div>
{/* Payment Type Breakdown */}
{Object.keys(data.summary.by_payment_type).length > 0 && (
<>
{/* Pie Chart and Total Amount */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
{/* Pie Chart */}
<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">
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">
نمودار توزیع روشهای پرداخت
</h3>
<PieChart
data={Object.entries(data.summary.by_payment_type).map(([type, stats]) => ({
name: getPaymentTypeLabel(type),
value: stats.percentage,
}))}
title="درصد استفاده از هر روش پرداخت"
colors={['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6', '#f97316']}
/>
</div>
{/* Total Amount Card */}
<div className="bg-gradient-to-br from-yellow-50 to-yellow-100 dark:from-yellow-900/20 dark:to-yellow-800/20 shadow-sm border-2 border-yellow-200 dark:border-yellow-800 rounded-lg p-6 flex flex-col justify-center">
<div className="text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-yellow-500 dark:bg-yellow-600 rounded-full mb-4 shadow-lg">
<DollarSign className="h-8 w-8 text-white" />
</div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">مجموع مبلغ</p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.summary.total_amount)}
</p>
</div>
</div>
</div>
{/* Payment Type Cards */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">
آمار تفکیکی هر روش پرداخت
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{Object.entries(data.summary.by_payment_type).map(([type, stats]) => (
<div
key={type}
className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50 hover:shadow-md transition-shadow"
>
<h4 className="font-bold text-base text-gray-900 dark:text-gray-100 mb-4 pb-2 border-b border-gray-200 dark:border-gray-600">{getPaymentTypeLabel(type)}</h4>
<div className="space-y-2.5 text-base">
<div className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300 font-medium">کل:</span>
<span className="font-bold text-gray-900 dark:text-gray-100">{formatWithThousands(stats.count)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300 font-medium">موفق:</span>
<span className="font-bold text-green-600 dark:text-green-400">{formatWithThousands(stats.success_count)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300 font-medium">ناموفق:</span>
<span className="font-bold text-red-600 dark:text-red-400">{formatWithThousands(stats.failed_count)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-700 dark:text-gray-300 font-medium">نرخ موفقیت:</span>
<span className="font-bold text-blue-600 dark:text-blue-400">{formatPercentage(stats.success_rate)}</span>
</div>
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-600">
<span className="text-gray-700 dark:text-gray-300 font-medium">درصد از کل:</span>
<span className="font-bold text-purple-600 dark:text-purple-400">{formatPercentage(stats.percentage)}</span>
</div>
</div>
</div>
))}
</div>
</div>
</>
)}
</>
)}
{/* Table */}
{isLoading ? (
<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>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="overflow-x-auto">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default PaymentMethodsReportPage;

View File

@ -1,13 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { QUERY_KEYS } from "@/utils/query-key";
import { getShipmentsByMethodReport } from "./_requests";
import { ShipmentsByMethodFilters } from "./_models";
export const useShipmentsByMethodReport = (filters: ShipmentsByMethodFilters) => {
return useQuery({
queryKey: [QUERY_KEYS.GET_SHIPMENTS_BY_METHOD_REPORT, filters],
queryFn: () => getShipmentsByMethodReport(filters),
enabled: filters.limit > 0,
});
};

View File

@ -1,77 +0,0 @@
export interface DateRange {
from?: string; // ISO 8601
to?: string; // ISO 8601
}
export interface ShipmentsByMethodFilters {
shipping_method_code?: string;
shipping_method_id?: number;
date_range?: DateRange;
customer_name?: string;
user_id?: number;
status?: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
payment_status?: 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled';
min_shipping_cost?: number;
max_shipping_cost?: number;
group_by_method?: boolean;
limit: number;
offset: number;
}
export interface Shipment {
order_id: number;
order_number: string;
user_id: number;
customer_name: string;
customer_phone: string;
shipping_method_id: number;
shipping_method: string;
shipping_method_code: string;
shipping_cost: number; // ریال
delivery_date?: string; // YYYY-MM-DD
delivery_from_hour?: number; // 0-23
delivery_to_hour?: number; // 0-23
status: string;
payment_status: string;
total_weight: number; // گرم
order_amount: number; // ریال
created_at: string; // ISO 8601
shipped_at?: string; // ISO 8601
delivered_at?: string; // ISO 8601
}
export interface MethodSummary {
shipping_method_id: number;
shipping_method: string;
shipping_method_code: string;
shipment_count: number;
total_revenue: number; // ریال
total_shipping_cost: number; // ریال
average_weight: number; // گرم
delivered_count: number;
cancelled_count: number;
}
export interface ShipmentsSummary {
total_shipments: number;
total_shipping_cost: number; // ریال
total_order_amount: number; // ریال
total_weight: number; // گرم
pending_shipments: number;
shipped_count: number;
delivered_count: number;
cancelled_count: number;
average_shipping_cost: number; // ریال
average_delivery_time?: number; // ساعت
}
export interface ShipmentsByMethodResponse {
shipments: Shipment[];
summary: ShipmentsSummary;
method_summaries?: MethodSummary[];
total: number;
has_more: boolean;
limit: number;
offset: number;
}

View File

@ -1,38 +0,0 @@
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
ShipmentsByMethodFilters,
ShipmentsByMethodResponse,
} from "./_models";
export const getShipmentsByMethodReport = async (
filters: ShipmentsByMethodFilters
): Promise<ShipmentsByMethodResponse> => {
const queryParams: Record<string, string | number | null> = {};
if (filters.shipping_method_code)
queryParams.shipping_method_code = filters.shipping_method_code;
if (filters.shipping_method_id)
queryParams.shipping_method_id = filters.shipping_method_id;
if (filters.user_id) queryParams.user_id = filters.user_id;
if (filters.customer_name) queryParams.customer_name = filters.customer_name;
if (filters.status) queryParams.status = filters.status;
if (filters.payment_status) queryParams.payment_status = filters.payment_status;
if (filters.min_shipping_cost)
queryParams.min_shipping_cost = filters.min_shipping_cost;
if (filters.max_shipping_cost)
queryParams.max_shipping_cost = filters.max_shipping_cost;
if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
if (typeof filters.group_by_method === "boolean") {
queryParams.group_by_method = filters.group_by_method ? "true" : "false";
}
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
const response = await httpGetRequest<ShipmentsByMethodResponse>(
APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT, queryParams)
);
return response.data;
};

View File

@ -1,359 +0,0 @@
import React, { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useShipmentsByMethodReport } from '../core/_hooks';
import { ShipmentsByMethodFilters } from '../core/_models';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Table } from '@/components/ui/Table';
import { TableColumn } from '@/types';
import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker';
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';
import { formatCurrency, formatDateTime } from '@/utils/formatters';
import { ReportSkeleton } from '@/components/common/ReportSkeleton';
const formatWeight = (weight: number) => {
return formatWithThousands(weight) + ' گرم';
};
const ShipmentsByMethodReportPage = () => {
const [searchParams] = useSearchParams();
const initialShippingMethodId = useMemo(() => {
const value = searchParams.get('shipping_method_id');
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}, [searchParams]);
const initialShippingMethodCode = useMemo(() => {
const value = searchParams.get('shipping_method_code');
return value || undefined;
}, [searchParams]);
const [filters, setFilters] = useState<ShipmentsByMethodFilters>({
limit: 50,
offset: 0,
group_by_method: false,
...(initialShippingMethodId ? { shipping_method_id: initialShippingMethodId } : {}),
...(initialShippingMethodCode ? { shipping_method_code: initialShippingMethodCode } : {}),
});
const { data, isLoading, error } = useShipmentsByMethodReport(filters);
const handleFilterChange = (key: keyof ShipmentsByMethodFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value,
offset: 0,
}));
};
const handleDateRangeChange = (from: string | undefined, to: string | undefined) => {
setFilters(prev => ({
...prev,
date_range: {
from,
to,
},
offset: 0,
}));
};
const handleNumericFilterChange = (key: 'shipping_method_id' | 'user_id' | 'min_shipping_cost' | 'max_shipping_cost', raw: string) => {
const converted = persianToEnglish(raw).replace(/[^\d]/g, '');
const numeric = converted ? Number(converted) : undefined;
handleFilterChange(key, numeric);
};
const handlePageChange = (page: number) => {
setFilters(prev => ({
...prev,
offset: (page - 1) * prev.limit,
}));
};
const handleClearFilters = () => {
setFilters({
limit: 50,
offset: 0,
group_by_method: false,
});
};
const columns: TableColumn[] = [
{
key: 'order_number',
label: 'شماره سفارش',
align: 'right',
},
{
key: 'customer_name',
label: 'نام مشتری',
align: 'right',
},
{
key: 'customer_phone',
label: 'شماره تماس',
align: 'right',
},
{
key: 'shipping_method',
label: 'روش ارسال',
align: 'right',
},
{
key: 'shipping_cost',
label: 'هزینه ارسال',
align: 'right',
},
{
key: 'order_amount',
label: 'مبلغ سفارش',
align: 'right',
},
{
key: 'total_weight',
label: 'وزن',
align: 'right',
},
{
key: 'status',
label: 'وضعیت',
align: 'right',
},
{
key: 'payment_status',
label: 'وضعیت پرداخت',
align: 'right',
},
{
key: 'created_at',
label: 'زمان ثبت',
align: 'right',
},
];
const tableData = (data?.shipments || []).map(shipment => ({
order_number: shipment.order_number || '-',
customer_name: shipment.customer_name || '-',
customer_phone: shipment.customer_phone || '-',
shipping_method: shipment.shipping_method || '-',
shipping_cost: formatCurrency(shipment.shipping_cost),
order_amount: formatCurrency(shipment.order_amount),
total_weight: formatWeight(shipment.total_weight),
status: shipment.status,
payment_status: shipment.payment_status,
created_at: formatDateTime(shipment.created_at),
})) || [];
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1;
return (
<PageContainer>
<PageTitle>گزارش ارسالها بر اساس روش</PageTitle>
{/* Summary Cards */}
{data?.summary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<Truck className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">کل ارسالها</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.total_shipments)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع هزینه ارسال</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.summary.total_shipping_cost)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
<Package className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع مبلغ سفارشات</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.summary.total_order_amount)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
<Clock className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین هزینه</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatCurrency(data.summary.average_shipping_cost)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
<Clock className="h-5 w-5 text-gray-600 dark:text-gray-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">در انتظار</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.pending_shipments)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
<Truck className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">ارسال شده</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.shipped_count)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
<Package className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">تحویل داده شده</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.delivered_count)}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
<Package className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">لغو شده</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
{formatWithThousands(data.summary.cancelled_count)}
</p>
</div>
</div>
</div>
</div>
)}
{/* Method Summaries */}
{data?.method_summaries && data.method_summaries.length > 0 && (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
آمار هر روش ارسال
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.method_summaries.map((method) => (
<div
key={method.shipping_method_id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
>
<h4 className="font-semibold text-gray-900 dark:text-gray-100 mb-2">
{method.shipping_method || method.shipping_method_code}
</h4>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">تعداد ارسال:</span>
<span className="font-medium">{formatWithThousands(method.shipment_count)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">مجموع درآمد:</span>
<span className="font-medium">{formatCurrency(method.total_revenue)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">مجموع هزینه:</span>
<span className="font-medium">{formatCurrency(method.total_shipping_cost)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">میانگین وزن:</span>
<span className="font-medium">{formatWeight(method.average_weight)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">تحویل شده:</span>
<span className="font-medium text-green-600">{formatWithThousands(method.delivered_count)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">لغو شده:</span>
<span className="font-medium text-red-600">{formatWithThousands(method.cancelled_count)}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Table */}
{isLoading ? (
<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>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default ShipmentsByMethodReportPage;

View File

@ -1,10 +1,10 @@
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText } from 'lucide-react'; import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText } from 'lucide-react';
import { Button } from '../../../components/ui/Button'; import { Button } from '../../../components/ui/Button';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { useRole } from '../core/_hooks'; import { useRole } from '../core/_hooks';
import { PermissionWrapper } from '../../../components/common/PermissionWrapper'; import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography'; import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
import { formatDate } from '../../../utils/formatters';
const RoleDetailPage = () => { const RoleDetailPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -12,55 +12,12 @@ const RoleDetailPage = () => {
const { data: role, isLoading, error } = useRole(id); const { data: role, isLoading, error } = useRole(id);
if (isLoading) { if (isLoading) return <LoadingSpinner />;
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="flex items-center justify-between">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="flex gap-3">
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-6"></div>
<div className="space-y-6">
<div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
</div>
</div>
</div>
<div className="space-y-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div className="space-y-3">
{[...Array(2)].map((_, j) => (
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
</div>
</div>
</PageContainer>
);
}
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات نقش</div>; if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات نقش</div>;
if (!role) return <div>نقش یافت نشد</div>; if (!role) return <div>نقش یافت نشد</div>;
return ( return (
<PageContainer> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -168,7 +125,7 @@ const RoleDetailPage = () => {
</span> </span>
</div> </div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100"> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatDate(role.created_at)} {new Date(role.created_at).toLocaleDateString('fa-IR')}
</p> </p>
</div> </div>
@ -180,7 +137,7 @@ const RoleDetailPage = () => {
</span> </span>
</div> </div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100"> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatDate(role.updated_at)} {new Date(role.updated_at).toLocaleDateString('fa-IR')}
</p> </p>
</div> </div>
</div> </div>
@ -213,7 +170,7 @@ const RoleDetailPage = () => {
</div> </div>
</div> </div>
)} )}
</PageContainer> </div>
); );
}; };

View File

@ -7,6 +7,7 @@ import { useRole, useCreateRole, useUpdateRole } from '../core/_hooks';
import { RoleFormData } from '../core/_models'; import { RoleFormData } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { ArrowRight, Shield } from "lucide-react"; import { ArrowRight, Shield } from "lucide-react";
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography'; import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
@ -69,21 +70,7 @@ const RoleFormPage = () => {
}; };
if (isEdit && roleLoading) { if (isEdit && roleLoading) {
return ( return <LoadingSpinner />;
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
))}
</div>
</div>
</PageContainer>
);
} }
const isLoading = creating || updating; const isLoading = creating || updating;

View File

@ -2,10 +2,10 @@ import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useRole, useRolePermissions, usePermissions, useAssignPermission, useRemovePermission } from "../core/_hooks"; import { useRole, useRolePermissions, usePermissions, useAssignPermission, useRemovePermission } from "../core/_hooks";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { Permission } from "@/types/auth"; import { Permission } from "@/types/auth";
import { ArrowRight, Plus, Trash2, Check, Shield } from "lucide-react"; import { ArrowRight, Plus, Trash2, Check, Shield } from "lucide-react";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { PageContainer } from "@/components/ui/Typography";
const RolePermissionsPage = () => { const RolePermissionsPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -56,33 +56,11 @@ const RolePermissionsPage = () => {
const isLoading = roleLoading || permissionsLoading; const isLoading = roleLoading || permissionsLoading;
if (isLoading) { if (isLoading) return <LoadingSpinner />;
return (
<PageContainer>
<div className="space-y-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64"></div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="card">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
</div>
<div className="p-6 space-y-3">
{[...Array(5)].map((_, j) => (
<div key={j} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
</div>
</PageContainer>
);
}
if (!role) return <div className="text-red-600">نقش یافت نشد</div>; if (!role) return <div className="text-red-600">نقش یافت نشد</div>;
return ( return (
<PageContainer> <div className="p-6">
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -115,10 +93,8 @@ const RolePermissionsPage = () => {
<div className="p-6"> <div className="p-6">
{permissionsLoading ? ( {permissionsLoading ? (
<div className="space-y-3 animate-pulse"> <div className="flex justify-center">
{[...Array(5)].map((_, i) => ( <LoadingSpinner />
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -167,10 +143,8 @@ const RolePermissionsPage = () => {
<div className="p-6"> <div className="p-6">
{allPermissionsLoading ? ( {allPermissionsLoading ? (
<div className="space-y-3 animate-pulse"> <div className="flex justify-center">
{[...Array(5)].map((_, i) => ( <LoadingSpinner />
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -240,7 +214,7 @@ const RolePermissionsPage = () => {
</div> </div>
</Modal> </Modal>
</div> </div>
</PageContainer> </div>
); );
}; };

View File

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

View File

@ -15,7 +15,6 @@ export interface ShippingMethod {
time_note?: string; time_note?: string;
open_hours: ShippingOpenHour[]; open_hours: ShippingOpenHour[];
addresses: string[]; addresses: string[];
needs_address: boolean;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }

View File

@ -4,9 +4,8 @@ import { useCreateShippingMethod, useShippingMethod, useUpdateShippingMethod } f
import { ShippingOpenHour } from '../core/_models'; import { ShippingOpenHour } from '../core/_models';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { TagInput } from '@/components/ui/TagInput'; import { TagInput } from '@/components/ui/TagInput';
import { PageContainer } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader';
import { Truck } from 'lucide-react'; import { Truck } from 'lucide-react';
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils'; import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
@ -35,7 +34,6 @@ const ShippingMethodFormPage = () => {
}, },
], ],
addresses: [] as string[], addresses: [] as string[],
needs_address: false,
}); });
useEffect(() => { useEffect(() => {
@ -62,7 +60,6 @@ const ShippingMethodFormPage = () => {
}, },
], ],
addresses: data.addresses || [], addresses: data.addresses || [],
needs_address: data.needs_address ?? false,
}); });
} }
}, [isEdit, data]); }, [isEdit, data]);
@ -97,7 +94,6 @@ const ShippingMethodFormPage = () => {
!Number.isNaN(item.to_hour) !Number.isNaN(item.to_hour)
), ),
addresses: form.addresses, addresses: form.addresses,
needs_address: form.needs_address,
}; };
if (isEdit && id) { if (isEdit && id) {
update({ id: Number(id), ...payload }, { onSuccess: () => navigate('/shipping-methods') }); update({ id: Number(id), ...payload }, { onSuccess: () => navigate('/shipping-methods') });
@ -108,28 +104,20 @@ const ShippingMethodFormPage = () => {
if (isEdit && isLoading) { if (isEdit && isLoading) {
return ( return (
<PageContainer> <div className="min-h-[200px] flex items-center justify-center">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(5)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
); );
} }
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<PageHeader <div>
title={isEdit ? 'ویرایش روش ارسال' : 'ایجاد روش ارسال'} <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
icon={Truck} <Truck className="h-6 w-6" />
/> {isEdit ? 'ویرایش روش ارسال' : 'ایجاد روش ارسال'}
</h1>
</div>
<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"> <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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -255,15 +243,6 @@ const ShippingMethodFormPage = () => {
فعال فعال
</label> </label>
</div> </div>
<div className="md:col-span-2">
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="needs_address" checked={form.needs_address} onChange={handleChange} className="rounded border-gray-300 dark:border-gray-600" />
نیاز به آدرس اجباری است
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
در صورت فعال بودن، کاربر باید حتماً آدرس تحویل را وارد کند
</p>
</div>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">

View File

@ -2,9 +2,8 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { PageContainer } from '@/components/ui/Typography'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { PageHeader } from '@/components/layout/PageHeader'; import { Plus, Edit3, Trash2, Truck } from 'lucide-react';
import { Plus, Edit3, Trash2, Truck, BarChart3 } from 'lucide-react';
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks'; import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
import { ShippingMethod } from '../core/_models'; import { ShippingMethod } from '../core/_models';
@ -27,70 +26,34 @@ const ShippingMethodsListPage = () => {
deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) }); deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) });
}; };
const handleViewReport = (method: ShippingMethod) => {
navigate(`/shipping-methods/shipments-report?shipping_method_id=${method.id}`);
};
if (isLoading) { if (isLoading) {
return ( return (
<PageContainer> <div className="p-6 flex justify-center">
<div className="space-y-6"> <LoadingSpinner />
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div>
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64 mb-2 animate-pulse"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48 animate-pulse"></div>
</div> </div>
<div className="h-12 w-12 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
</div>
<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 animate-pulse"></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>
</div>
</PageContainer>
); );
} }
if (error) { if (error) {
return ( return (
<PageContainer> <div className="p-6">
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری روشهای ارسال</p> <p className="text-red-600 dark:text-red-400">خطا در بارگذاری روشهای ارسال</p>
</div> </div>
</PageContainer> </div>
); );
} }
return ( return (
<PageContainer> <div className="p-6 space-y-6">
<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">
<PageHeader <div>
title="مدیریت روش‌های ارسال" <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
subtitle="تعریف و مدیریت روش‌های ارسال سفارش" <Truck className="h-6 w-6" />
icon={Truck} مدیریت روشهای ارسال
actions={ </h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">تعریف و مدیریت روشهای ارسال سفارش</p>
</div>
<button <button
onClick={handleCreate} 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" 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"
@ -98,8 +61,7 @@ const ShippingMethodsListPage = () => {
> >
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
</button> </button>
} </div>
/>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="hidden md:block"> <div className="hidden md:block">
@ -129,13 +91,6 @@ const ShippingMethodsListPage = () => {
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={() => handleViewReport(m)}
className="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"
title="گزارش ارسال"
>
<BarChart3 className="h-4 w-4" />
</button>
<button onClick={() => handleEdit(m.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="ویرایش"> <button onClick={() => handleEdit(m.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="ویرایش">
<Edit3 className="h-4 w-4" /> <Edit3 className="h-4 w-4" />
</button> </button>
@ -164,13 +119,6 @@ const ShippingMethodsListPage = () => {
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ساعات پاسخگویی: {formatOpenHours(m.open_hours)}</div> <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ساعات پاسخگویی: {formatOpenHours(m.open_hours)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">اولویت: {m.priority}</div> <div className="text-xs text-gray-500 dark:text-gray-400 mb-3">اولویت: {m.priority}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={() => handleViewReport(m)}
className="flex items-center gap-1 px-2 py-1 text-xs text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"
>
<BarChart3 className="h-3 w-3" />
گزارش
</button>
<button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"> <button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
<Edit3 className="h-3 w-3" /> <Edit3 className="h-3 w-3" />
ویرایش ویرایش
@ -195,7 +143,6 @@ const ShippingMethodsListPage = () => {
</div> </div>
</Modal> </Modal>
</div> </div>
</PageContainer>
); );
}; };

View File

@ -18,8 +18,7 @@ import {
TicketStatus, TicketStatus,
TicketSubject, TicketSubject,
} from "../core/_models"; } from "../core/_models";
import { PageContainer, SectionTitle } from "@/components/ui/Typography"; import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
import { PageHeader } from "@/components/layout/PageHeader";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Table } from "@/components/ui/Table"; import { Table } from "@/components/ui/Table";
@ -352,7 +351,7 @@ const TicketConfigPage = () => {
is_active: e.target.value, is_active: e.target.value,
})) }))
} }
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="true">فعال</option> <option value="true">فعال</option>
<option value="false">غیرفعال</option> <option value="false">غیرفعال</option>
@ -436,7 +435,7 @@ const TicketConfigPage = () => {
is_active: e.target.value, is_active: e.target.value,
})) }))
} }
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="true">فعال</option> <option value="true">فعال</option>
<option value="false">غیرفعال</option> <option value="false">غیرفعال</option>
@ -493,7 +492,7 @@ const TicketConfigPage = () => {
department_id: e.target.value, department_id: e.target.value,
})) }))
} }
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="">انتخاب دپارتمان</option> <option value="">انتخاب دپارتمان</option>
{departments?.map((department) => ( {departments?.map((department) => (
@ -540,7 +539,7 @@ const TicketConfigPage = () => {
is_active: e.target.value, is_active: e.target.value,
})) }))
} }
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="true">فعال</option> <option value="true">فعال</option>
<option value="false">غیرفعال</option> <option value="false">غیرفعال</option>
@ -579,10 +578,12 @@ const TicketConfigPage = () => {
return ( return (
<PageContainer className="space-y-6"> <PageContainer className="space-y-6">
<PageHeader <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
title="تنظیمات تیکت" <PageTitle className="flex items-center gap-2">
icon={Settings} <Settings className="h-6 w-6" />
/> تنظیمات تیکت
</PageTitle>
</div>
<div className="card p-2 flex flex-wrap gap-2"> <div className="card p-2 flex flex-wrap gap-2">
<Button <Button

View File

@ -10,6 +10,7 @@ import {
import { TicketStatus } from "../core/_models"; import { TicketStatus } from "../core/_models";
import { PageContainer, PageTitle, SectionTitle, Label } from "@/components/ui/Typography"; import { PageContainer, PageTitle, SectionTitle, Label } from "@/components/ui/Typography";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
import { FileUploader } from "@/components/ui/FileUploader"; import { FileUploader } from "@/components/ui/FileUploader";
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload"; import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
@ -154,19 +155,9 @@ const TicketDetailPage = () => {
if (isLoading) { if (isLoading) {
return ( return (
<PageContainer> <div className="flex items-center justify-center min-h-screen">
<div className="space-y-6 animate-pulse"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="card p-6 space-y-6">
{[...Array(5)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div> </div>
))}
</div>
</div>
</PageContainer>
); );
} }
@ -227,7 +218,7 @@ const TicketDetailPage = () => {
onChange={(e) => onChange={(e) =>
setStatusId(e.target.value ? Number(e.target.value) : undefined) setStatusId(e.target.value ? Number(e.target.value) : undefined)
} }
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="">انتخاب وضعیت</option> <option value="">انتخاب وضعیت</option>
{statuses?.map((status) => ( {statuses?.map((status) => (

View File

@ -132,9 +132,7 @@ const TicketsListPage = () => {
return ( return (
<PageContainer> <PageContainer>
<div className="space-y-6"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* Header */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<div> <div>
<PageTitle className="flex items-center gap-2"> <PageTitle className="flex items-center gap-2">
<MessageSquare className="h-6 w-6" /> <MessageSquare className="h-6 w-6" />
@ -156,63 +154,15 @@ const TicketsListPage = () => {
</div> </div>
</div> </div>
{/* Filters */} <div className="card p-4 space-y-4">
<div className="card p-4 sm:p-6">
{isLoading ? (
<div className="space-y-4 animate-pulse">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
</div>
<div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
</div>
))}
</div>
</div>
) : (
<>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<Button
variant="secondary"
size="sm"
onClick={() =>
setFilters({
page: 1,
limit: 20,
search: "",
})
}
>
<Filter className="h-4 w-4 ml-2" />
پاک کردن فیلترها
</Button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="relative"> <div className="relative">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <Search className="absolute right-3 top-3 h-4 w-4 text-gray-400" />
جستجو
</label>
<Search className="absolute right-3 top-10 h-4 w-4 text-gray-400" />
<input <input
value={filters.search || ""} value={filters.search || ""}
onChange={(e) => handleFilterChange("search", e.target.value)} onChange={(e) => handleFilterChange("search", e.target.value)}
placeholder="جستجو در عنوان یا شماره تیکت" placeholder="جستجو در عنوان یا شماره تیکت"
className="w-full pr-9 pl-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" className="w-full pr-9 pl-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
/> />
</div> </div>
<Input <Input
@ -228,9 +178,9 @@ const TicketsListPage = () => {
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
وضعیت وضعیت
</label> </label>
<select <select
@ -241,7 +191,7 @@ const TicketsListPage = () => {
e.target.value ? Number(e.target.value) : undefined e.target.value ? Number(e.target.value) : undefined
) )
} }
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="">همه وضعیتها</option> <option value="">همه وضعیتها</option>
{statuses?.map((status) => ( {statuses?.map((status) => (
@ -252,7 +202,7 @@ const TicketsListPage = () => {
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
دپارتمان دپارتمان
</label> </label>
<select <select
@ -263,7 +213,7 @@ const TicketsListPage = () => {
e.target.value ? Number(e.target.value) : undefined e.target.value ? Number(e.target.value) : undefined
) )
} }
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" className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
> >
<option value="">همه دپارتمانها</option> <option value="">همه دپارتمانها</option>
{departments?.map((department) => ( {departments?.map((department) => (
@ -273,13 +223,25 @@ const TicketsListPage = () => {
))} ))}
</select> </select>
</div> </div>
<div className="flex items-end">
<Button
variant="secondary"
className="w-full flex items-center justify-center gap-2"
onClick={() =>
setFilters({
page: 1,
limit: 20,
search: "",
})
}
>
<Filter className="h-4 w-4" />
پاک کردن فیلترها
</Button>
</div> </div>
</div> </div>
</>
)}
</div> </div>
{/* Table */}
{isLoading ? ( {isLoading ? (
<Table columns={columns} data={[]} loading /> <Table columns={columns} data={[]} loading />
) : !data?.tickets || data.tickets.length === 0 ? ( ) : !data?.tickets || data.tickets.length === 0 ? (
@ -289,7 +251,6 @@ const TicketsListPage = () => {
) : ( ) : (
<> <>
<Table columns={columns} data={data.tickets as any[]} /> <Table columns={columns} data={data.tickets as any[]} />
{data.total > 0 && (
<Pagination <Pagination
currentPage={filters.page || 1} currentPage={filters.page || 1}
totalPages={Math.max( totalPages={Math.max(
@ -300,10 +261,8 @@ const TicketsListPage = () => {
itemsPerPage={filters.limit || 20} itemsPerPage={filters.limit || 20}
totalItems={data.total || 0} totalItems={data.total || 0}
/> />
)}
</> </>
)} )}
</div>
</PageContainer> </PageContainer>
); );
}; };

View File

@ -3,10 +3,10 @@ import { useParams, useNavigate } from 'react-router-dom';
import { User, Edit, UserCheck, UserX, Trash2, ArrowLeft, Phone, Mail, CreditCard, Calendar } from 'lucide-react'; import { User, Edit, UserCheck, UserX, Trash2, ArrowLeft, Phone, Mail, CreditCard, Calendar } from 'lucide-react';
import { useUser, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks'; import { useUser, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
import { englishToPersian } from '../../../utils/numberUtils'; import { englishToPersian } from '../../../utils/numberUtils';
import { formatDate } from '../../../utils/formatters';
import { PageContainer } from '../../../components/ui/Typography'; import { PageContainer } from '../../../components/ui/Typography';
import { Button } from '../../../components/ui/Button'; import { Button } from '../../../components/ui/Button';
import { Modal } from '../../../components/ui/Modal'; import { Modal } from '../../../components/ui/Modal';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
const UserAdminDetailPage: React.FC = () => { const UserAdminDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -56,40 +56,8 @@ const UserAdminDetailPage: React.FC = () => {
if (isLoading) { if (isLoading) {
return ( return (
<PageContainer> <PageContainer>
<div className="space-y-6 animate-pulse"> <div className="flex justify-center items-center py-12">
<div className="flex items-center justify-between"> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded w-24"></div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{[...Array(3)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[...Array(4)].map((_, j) => (
<div key={j}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
</div>
))}
</div>
</div>
))}
</div>
<div className="space-y-6">
{[...Array(2)].map((_, i) => (
<div key={i} className="card p-6">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-4"></div>
<div className="space-y-3">
{[...Array(3)].map((_, j) => (
<div key={j} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
</div>
))}
</div>
</div>
</div> </div>
</PageContainer> </PageContainer>
); );
@ -260,7 +228,7 @@ const UserAdminDetailPage: React.FC = () => {
<div> <div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">تاریخ ثبتنام</p> <p className="text-sm font-medium text-gray-500 dark:text-gray-400">تاریخ ثبتنام</p>
<p className="text-gray-900 dark:text-gray-100"> <p className="text-gray-900 dark:text-gray-100">
{formatDate(user.created_at)} {new Date(user.created_at).toLocaleDateString('fa-IR')}
</p> </p>
</div> </div>
</div> </div>
@ -272,7 +240,7 @@ const UserAdminDetailPage: React.FC = () => {
<div> <div>
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">آخرین بهروزرسانی</p> <p className="text-sm font-medium text-gray-500 dark:text-gray-400">آخرین بهروزرسانی</p>
<p className="text-gray-900 dark:text-gray-100"> <p className="text-gray-900 dark:text-gray-100">
{formatDate(user.updated_at)} {new Date(user.updated_at).toLocaleDateString('fa-IR')}
</p> </p>
</div> </div>
</div> </div>

View File

@ -7,9 +7,9 @@ import { User, ArrowLeft, Save, UserPlus } from 'lucide-react';
import { useUser, useCreateUser, useUpdateUser } from '../core/_hooks'; import { useUser, useCreateUser, useUpdateUser } from '../core/_hooks';
import { CreateUserRequest, UpdateUserRequest } from '../core/_models'; import { CreateUserRequest, UpdateUserRequest } from '../core/_models';
import { PageContainer } from '../../../components/ui/Typography'; import { PageContainer } from '../../../components/ui/Typography';
import { PageHeader } from '../../../components/layout/PageHeader';
import { Button } from '../../../components/ui/Button'; import { Button } from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input'; import { Input } from '../../../components/ui/Input';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
// Validation schema // Validation schema
const createUserSchema = yup.object({ const createUserSchema = yup.object({
@ -139,16 +139,8 @@ const UserAdminFormPage: React.FC = () => {
if (isEdit && userLoading) { if (isEdit && userLoading) {
return ( return (
<PageContainer> <PageContainer>
<div className="space-y-6 animate-pulse"> <div className="flex justify-center items-center py-12">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48"></div> <LoadingSpinner />
<div className="card p-6 space-y-6">
{[...Array(6)].map((_, i) => (
<div key={i}>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
))}
</div>
</div> </div>
</PageContainer> </PageContainer>
); );
@ -168,11 +160,15 @@ const UserAdminFormPage: React.FC = () => {
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
بازگشت بازگشت
</Button> </Button>
<PageHeader <div>
title={isEdit ? 'ویرایش کاربر' : 'ایجاد کاربر جدید'} <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
subtitle={isEdit ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید به سیستم'} {isEdit ? <User className="h-6 w-6" /> : <UserPlus className="h-6 w-6" />}
icon={isEdit ? User : UserPlus} {isEdit ? 'ویرایش کاربر' : 'ایجاد کاربر جدید'}
/> </h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{isEdit ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید به سیستم'}
</p>
</div>
</div> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">

View File

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

View File

@ -32,5 +32,3 @@ export const useUpdateWalletStatus = () => {

View File

@ -28,5 +28,3 @@ export const WALLET_LABELS: Record<WalletType, string> = {

View File

@ -23,5 +23,3 @@ export const updateWalletStatus = async (

View File

@ -1,12 +1,55 @@
import React from 'react'; import React from 'react';
import { Wallet, Loader2 } from 'lucide-react'; import { Wallet, Loader2 } from 'lucide-react';
import { PageContainer } from '@/components/ui/Typography'; import { PageContainer, PageTitle } from '@/components/ui/Typography';
import { PageHeader } from '@/components/layout/PageHeader'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { formatDateTime } from '@/utils/formatters';
import { useWalletStatus, useUpdateWalletStatus } from '../core/_hooks'; import { useWalletStatus, useUpdateWalletStatus } from '../core/_hooks';
import { WalletStatus, WALLET_LABELS } from '../core/_models'; 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 WalletListPage = () => {
const { data, isLoading, error } = useWalletStatus(); const { data, isLoading, error } = useWalletStatus();
const { mutate: updateStatus, isPending } = useUpdateWalletStatus(); const { mutate: updateStatus, isPending } = useUpdateWalletStatus();
@ -21,32 +64,8 @@ const WalletListPage = () => {
if (isLoading) { if (isLoading) {
return ( return (
<PageContainer> <PageContainer>
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0"> <div className="flex justify-center items-center h-64">
<div> <LoadingSpinner />
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-2 animate-pulse"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-64 animate-pulse"></div>
</div>
</div>
<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">
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<div
key={i}
className="flex items-center justify-between p-4 border border-gray-200 dark:border-gray-700 rounded-lg animate-pulse"
>
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48 mt-2"></div>
</div>
<div className="h-6 w-11 bg-gray-200 dark:bg-gray-700 rounded-full"></div>
</div>
))}
</div>
</div>
</div> </div>
</PageContainer> </PageContainer>
); );
@ -68,11 +87,17 @@ const WalletListPage = () => {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
title="مدیریت کیف پول" <div>
subtitle="فعال یا غیرفعال کردن کیف‌های پول" <PageTitle className="flex items-center gap-2">
icon={Wallet} <Wallet className="h-6 w-6" />
/> مدیریت کیف پول
</PageTitle>
<p className="text-gray-600 dark:text-gray-400 mt-1">
فعال یا غیرفعال کردن کیفهای پول
</p>
</div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <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"> <div className="p-6">
@ -98,7 +123,7 @@ const WalletListPage = () => {
</span> </span>
</div> </div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
آخرین بهروزرسانی: {formatDateTime(wallet.updated_at)} آخرین بهروزرسانی: {formatDate(wallet.updated_at)}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -126,5 +151,3 @@ export default WalletListPage;

View File

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

Some files were not shown because too many files have changed in this diff Show More