Compare commits
11 Commits
3467c5e459
...
5b62d189f8
| Author | SHA1 | Date |
|---|---|---|
|
|
5b62d189f8 | |
|
|
5bb506b830 | |
|
|
c46fd2ba0e | |
|
|
6dd2429920 | |
|
|
8538d4282e | |
|
|
bfd1ea72a5 | |
|
|
ef76defb28 | |
|
|
50c6806c3a | |
|
|
eac8e9eb08 | |
|
|
62c4544897 | |
|
|
b2823dfb9e |
|
|
@ -1,10 +1,10 @@
|
|||
FROM node:18-alpine as builder
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci
|
||||
RUN npm ci --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
|
|
|
|||
|
|
@ -1,154 +0,0 @@
|
|||
# 📋 مقایسه فیلدها - سیستم محصولات
|
||||
|
||||
## 🔵 فیلدهای محصول (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 شماست! 🚀**
|
||||
33
src/App.tsx
33
src/App.tsx
|
|
@ -6,7 +6,6 @@ import { AuthProvider } from './contexts/AuthContext';
|
|||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { ToastProvider } from './contexts/ToastContext';
|
||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||
import { LoadingSpinner } from './components/ui/LoadingSpinner';
|
||||
import { queryClient } from './lib/queryClient';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import { Layout } from './components/layout/Layout';
|
||||
|
|
@ -69,6 +68,7 @@ const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipp
|
|||
const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage'));
|
||||
const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage'));
|
||||
const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage'));
|
||||
const ContactUsListPage = lazy(() => import('./pages/contact-us/contact-us-list/ContactUsListPage'));
|
||||
|
||||
// Payment IPG Page
|
||||
const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'));
|
||||
|
|
@ -79,14 +79,21 @@ const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormP
|
|||
// Wallet Page
|
||||
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage'));
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: any }) => {
|
||||
// Reports Pages
|
||||
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();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<Layout />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -158,15 +165,19 @@ const AppRoutes = () => {
|
|||
<Route path="shipping-methods" element={<ShippingMethodsListPage />} />
|
||||
<Route path="shipping-methods/create" 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/config" element={<TicketConfigPage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
|
||||
<Route path="contact-us" element={<ContactUsListPage />} />
|
||||
|
||||
{/* Products Routes */}
|
||||
<Route path="products/create" element={<ProductFormPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
<Route path="products/:id/edit" element={<ProductFormPage />} />
|
||||
<Route path="products/comments" element={<ProductCommentsListPage />} />
|
||||
|
||||
{/* Payment IPG Route */}
|
||||
<Route path="payment-ipg" element={<IPGListPage />} />
|
||||
|
|
@ -176,6 +187,12 @@ const AppRoutes = () => {
|
|||
|
||||
{/* Wallet Route */}
|
||||
<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>
|
||||
</Routes>
|
||||
);
|
||||
|
|
@ -189,11 +206,7 @@ const App = () => {
|
|||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}>
|
||||
<Suspense fallback={null}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
</Router>
|
||||
|
|
|
|||
|
|
@ -10,19 +10,19 @@ interface PieChartProps {
|
|||
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => {
|
||||
// Custom legend component for better mobile experience
|
||||
// Custom legend component for left side
|
||||
const CustomLegend = (props: any) => {
|
||||
const { payload } = props;
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-1 text-xs sm:text-sm">
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
className="w-3 h-3 rounded-full flex-shrink-0 border border-white dark:border-gray-800"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{entry.value}: {entry.payload.value}
|
||||
<span className="text-xs sm:text-sm 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>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -37,19 +37,32 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
|
|||
{title}
|
||||
</CardTitle>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{/* 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}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
// Remove the overlapping labels
|
||||
label={false}
|
||||
outerRadius="65%"
|
||||
outerRadius="75%"
|
||||
innerRadius="35%"
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
stroke="#fff"
|
||||
strokeWidth={3}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
|
|
@ -57,24 +70,20 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
|
|||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--toast-bg)',
|
||||
color: 'var(--toast-color)',
|
||||
border: 'none',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
color: '#1f2937',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
formatter={(value, name) => [`${value}`, name]}
|
||||
/>
|
||||
<Legend
|
||||
content={<CustomLegend />}
|
||||
wrapperStyle={{
|
||||
paddingTop: '10px'
|
||||
}}
|
||||
formatter={(value: any, name: any) => [`${Math.round(value)}%`, name]}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import React from 'react';
|
||||
import { Eye, Edit3, Trash2, LucideIcon } from 'lucide-react';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
onView?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
viewTitle?: string;
|
||||
editTitle?: string;
|
||||
deleteTitle?: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showLabels?: boolean;
|
||||
}
|
||||
|
||||
const getSizeClasses = (size: 'sm' | 'md' | 'lg') => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'h-3 w-3';
|
||||
case 'md':
|
||||
return 'h-4 w-4';
|
||||
case 'lg':
|
||||
return 'h-5 w-5';
|
||||
default:
|
||||
return 'h-4 w-4';
|
||||
}
|
||||
};
|
||||
|
||||
const getTextSizeClasses = (size: 'sm' | 'md' | 'lg') => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'text-xs';
|
||||
case 'md':
|
||||
return 'text-xs';
|
||||
case 'lg':
|
||||
return 'text-sm';
|
||||
default:
|
||||
return 'text-xs';
|
||||
}
|
||||
};
|
||||
|
||||
export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
onView,
|
||||
onEdit,
|
||||
onDelete,
|
||||
viewTitle = 'مشاهده',
|
||||
editTitle = 'ویرایش',
|
||||
deleteTitle = 'حذف',
|
||||
className = '',
|
||||
size = 'md',
|
||||
showLabels = false,
|
||||
}) => {
|
||||
const iconSize = getSizeClasses(size);
|
||||
const textSize = getTextSizeClasses(size);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
{onView && (
|
||||
<button
|
||||
onClick={onView}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 flex items-center gap-1"
|
||||
title={viewTitle}
|
||||
>
|
||||
<Eye className={iconSize} />
|
||||
{showLabels && <span className={textSize}>{viewTitle}</span>}
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 flex items-center gap-1"
|
||||
title={editTitle}
|
||||
>
|
||||
<Edit3 className={iconSize} />
|
||||
{showLabels && <span className={textSize}>{editTitle}</span>}
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 flex items-center gap-1"
|
||||
title={deleteTitle}
|
||||
>
|
||||
<Trash2 className={iconSize} />
|
||||
{showLabels && <span className={textSize}>{deleteTitle}</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import React from 'react';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title?: string;
|
||||
message?: string;
|
||||
warningMessage?: string;
|
||||
isLoading?: boolean;
|
||||
itemName?: string;
|
||||
}
|
||||
|
||||
export const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = 'حذف',
|
||||
message,
|
||||
warningMessage,
|
||||
isLoading = false,
|
||||
itemName,
|
||||
}) => {
|
||||
const defaultMessage = itemName
|
||||
? `آیا از حذف "${itemName}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`
|
||||
: 'آیا از حذف این مورد اطمینان دارید؟ این عمل قابل بازگشت نیست.';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{message || defaultMessage}
|
||||
</p>
|
||||
{warningMessage && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{warningMessage}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={onConfirm}
|
||||
loading={isLoading}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: ReactNode;
|
||||
onAction?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`text-center py-12 ${className}`}>
|
||||
{Icon && (
|
||||
<Icon className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
|
||||
)}
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<div className="mt-6">
|
||||
<Button onClick={onAction} className="flex items-center gap-2 mx-auto">
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
|
||||
interface FiltersSectionProps {
|
||||
children: ReactNode;
|
||||
isLoading?: boolean;
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FiltersSection: React.FC<FiltersSectionProps> = ({
|
||||
children,
|
||||
isLoading = false,
|
||||
columns = 4,
|
||||
className = '',
|
||||
}) => {
|
||||
const gridCols = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 ${className}`}>
|
||||
{isLoading ? (
|
||||
<div className={`grid ${gridCols[columns]} gap-4 animate-pulse`}>
|
||||
{[...Array(columns)].map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid ${gridCols[columns]} gap-4`}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import React from 'react';
|
||||
|
||||
interface ReportSkeletonProps {
|
||||
summaryCardCount?: number;
|
||||
tableColumnCount?: number;
|
||||
tableRowCount?: number;
|
||||
showMethodSummaries?: boolean;
|
||||
showChart?: boolean;
|
||||
showPaymentTypeCards?: boolean;
|
||||
}
|
||||
|
||||
export const ReportSkeleton: React.FC<ReportSkeletonProps> = ({
|
||||
summaryCardCount = 4,
|
||||
tableColumnCount = 7,
|
||||
tableRowCount = 5,
|
||||
showMethodSummaries = false,
|
||||
showChart = false,
|
||||
showPaymentTypeCards = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Summary Cards Skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{[...Array(summaryCardCount)].map((_, i) => (
|
||||
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Method Summaries Skeleton */}
|
||||
{showMethodSummaries && (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6 animate-pulse">
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-4"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
|
||||
<div className="space-y-1">
|
||||
{[...Array(6)].map((_, j) => (
|
||||
<div key={j} className="flex justify-between">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pie Chart and Total Amount Skeleton */}
|
||||
{showChart && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
|
||||
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
|
||||
<div className="h-16 w-16 bg-gray-200 dark:bg-gray-700 rounded-full mx-auto mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto mb-2"></div>
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Type Cards Skeleton */}
|
||||
{showPaymentTypeCards && (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6 animate-pulse">
|
||||
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50">
|
||||
<div className="h-5 bg-gray-200 dark:bg-gray-600 rounded w-32 mb-4"></div>
|
||||
<div className="space-y-2.5">
|
||||
{[...Array(5)].map((_, j) => (
|
||||
<div key={j} className="flex justify-between">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-16"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-12"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table Skeleton */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
{[...Array(tableColumnCount)].map((_, i) => (
|
||||
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20"></div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(tableRowCount)].map((_, i) => (
|
||||
<tr key={i} className="animate-pulse">
|
||||
{[...Array(tableColumnCount)].map((_, j) => (
|
||||
<td key={j} className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import React from 'react';
|
||||
|
||||
interface TableSkeletonProps {
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
showMobileCards?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TableSkeleton: React.FC<TableSkeletonProps> = ({
|
||||
columns = 5,
|
||||
rows = 5,
|
||||
showMobileCards = true,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden ${className}`}>
|
||||
{/* Desktop Table Skeleton */}
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
{[...Array(columns)].map((_, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
|
||||
>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-24 animate-pulse"></div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(rows)].map((_, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{[...Array(columns)].map((_, colIndex) => (
|
||||
<td key={colIndex} className="px-6 py-4 whitespace-nowrap">
|
||||
{colIndex === columns - 1 ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse w-32"></div>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards Skeleton */}
|
||||
{showMobileCards && (
|
||||
<div className="md:hidden p-4 space-y-4">
|
||||
{[...Array(Math.min(rows, 3))].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="h-5 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-full"></div>
|
||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -28,10 +28,10 @@ export const StatsCard = ({
|
|||
const isNegative = change && change < 0;
|
||||
|
||||
return (
|
||||
<div className="card p-3 sm:p-4 lg:p-6 animate-fade-in">
|
||||
<div className="card p-4 sm:p-5 lg:p-6 animate-fade-in">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`p-2 sm:p-3 rounded-lg ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue}`}>
|
||||
<div className={`p-3 sm:p-4 rounded-xl ${colorClasses[color as keyof typeof colorClasses] || colorClasses.blue} shadow-sm`}>
|
||||
<Icon className="h-5 w-5 sm:h-6 sm:w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import { Button } from '../ui/Button';
|
||||
|
||||
interface FormActionsProps {
|
||||
onCancel?: () => void;
|
||||
cancelLabel?: string;
|
||||
submitLabel?: string;
|
||||
isLoading?: boolean;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FormActions: React.FC<FormActionsProps> = ({
|
||||
onCancel,
|
||||
cancelLabel = 'انصراف',
|
||||
submitLabel = 'ذخیره',
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600 ${className}`}>
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={isDisabled || isLoading}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { SectionTitle } from '../ui/Typography';
|
||||
|
||||
interface FormSectionProps {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
}
|
||||
|
||||
export const FormSection: React.FC<FormSectionProps> = ({
|
||||
title,
|
||||
children,
|
||||
className = '',
|
||||
titleClassName = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<SectionTitle className={`mb-4 ${titleClassName}`}>
|
||||
{title}
|
||||
</SectionTitle>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -14,8 +14,8 @@ export const Header = ({ onMenuClick }: HeaderProps) => {
|
|||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
return (
|
||||
<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 py-3">
|
||||
<header className="bg-white dark:bg-gray-800 shadow-md 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 space-x-4 space-x-reverse">
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,27 @@
|
|||
import { useState } from 'react';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Sidebar } from './Sidebar';
|
||||
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 = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
|
|
@ -17,8 +36,10 @@ export const Layout = () => {
|
|||
<Header onMenuClick={() => setSidebarOpen(true)} />
|
||||
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-gray-900">
|
||||
<div className="min-h-full">
|
||||
<div className="min-h-full py-6 px-4 sm:px-6 lg:px-8">
|
||||
<Suspense fallback={<ContentSkeleton />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon?: LucideIcon;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
actions,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 ${className}`}>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
{Icon && <Icon className="h-6 w-6" />}
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{actions && (
|
||||
<div className="flex-shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
Settings,
|
||||
|
|
@ -19,7 +19,10 @@ import {
|
|||
X,
|
||||
MessageSquare,
|
||||
CreditCard,
|
||||
Wallet
|
||||
Wallet,
|
||||
BarChart3,
|
||||
FileText,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { PermissionWrapper } from '../common/PermissionWrapper';
|
||||
|
|
@ -72,6 +75,11 @@ const menuItems: MenuItem[] = [
|
|||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'پیامهای تماس با ما',
|
||||
icon: FileText,
|
||||
path: '/contact-us',
|
||||
},
|
||||
{
|
||||
title: 'مدیریت محصولات',
|
||||
icon: Package,
|
||||
|
|
@ -91,6 +99,37 @@ const menuItems: MenuItem[] = [
|
|||
icon: Sliders,
|
||||
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',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -151,14 +190,58 @@ interface SidebarProps {
|
|||
|
||||
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
const { user, logout } = useAuth();
|
||||
const [expandedItems, setExpandedItems] = React.useState<string[]>([]);
|
||||
const location = useLocation();
|
||||
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) => {
|
||||
setExpandedItems(prev =>
|
||||
prev.includes(title)
|
||||
setExpandedItems(prev => {
|
||||
const newItems = prev.includes(title)
|
||||
? prev.filter(item => item !== title)
|
||||
: [...prev, title]
|
||||
);
|
||||
: [...prev, title];
|
||||
return newItems;
|
||||
});
|
||||
};
|
||||
|
||||
const renderMenuItem = (item: MenuItem, depth = 0) => {
|
||||
|
|
@ -171,8 +254,8 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
<div key={item.title} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleExpanded(item.title)}
|
||||
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`}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200
|
||||
text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:shadow-sm`}
|
||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||
>
|
||||
<item.icon className="ml-3 h-5 w-5" />
|
||||
|
|
@ -203,9 +286,9 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
}
|
||||
}}
|
||||
className={({ 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'
|
||||
: 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white'
|
||||
`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${isActive
|
||||
? 'bg-primary-50 dark:bg-primary-900 text-primary-600 dark:text-primary-400 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 hover:shadow-sm'
|
||||
}`
|
||||
}
|
||||
style={{ paddingLeft: `${paddingLeft + 16}px` }}
|
||||
|
|
@ -242,35 +325,35 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
|||
w-64 transform transition-transform duration-300 ease-in-out
|
||||
lg:translate-x-0 lg:block
|
||||
${isOpen ? 'translate-x-0' : 'translate-x-full lg:translate-x-0'}
|
||||
flex flex-col bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700
|
||||
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
|
||||
`}>
|
||||
{/* Mobile close button */}
|
||||
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="lg:hidden flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<SectionTitle>
|
||||
پنل مدیریت
|
||||
</SectionTitle>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logo - desktop only */}
|
||||
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="hidden lg:flex h-16 items-center justify-center border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<SectionTitle>
|
||||
پنل مدیریت
|
||||
</SectionTitle>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto">
|
||||
<nav className="flex-1 space-y-1 px-4 py-6 overflow-y-auto min-h-0">
|
||||
{menuItems.map(item => renderMenuItem(item))}
|
||||
</nav>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 flex-shrink-0">
|
||||
<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">
|
||||
<span className="text-sm font-medium text-white">
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const Button = ({
|
|||
className = '',
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
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 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 variantClasses = {
|
||||
primary: 'bg-primary-600 hover:bg-primary-700 text-white focus:ring-primary-500',
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import persian from 'react-date-object/calendars/persian';
|
|||
import persian_fa from 'react-date-object/locales/persian_fa';
|
||||
import DateObject from 'react-date-object';
|
||||
import { Label } from './Typography';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface JalaliDateTimePickerProps {
|
||||
label?: string;
|
||||
|
|
@ -46,6 +47,7 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
|
|||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <Label>{label}</Label>}
|
||||
<div className="relative">
|
||||
<DatePicker
|
||||
value={selected}
|
||||
onChange={(val) => onChange(toIsoLike(val as DateObject | null))}
|
||||
|
|
@ -54,7 +56,7 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
|
|||
locale={persian_fa}
|
||||
calendarPosition="bottom-center"
|
||||
disableDayPicker={false}
|
||||
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'}`}
|
||||
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'}`}
|
||||
containerClassName="w-full"
|
||||
placeholder={placeholder || 'تاریخ و ساعت'}
|
||||
editable={false}
|
||||
|
|
@ -63,6 +65,20 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
|
|||
disableYearPicker={false}
|
||||
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 && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ export const Modal = ({
|
|||
|
||||
<div className={`
|
||||
relative w-full ${sizeClasses[size]}
|
||||
bg-white dark:bg-gray-800 rounded-lg shadow-xl
|
||||
transform transition-all
|
||||
bg-white dark:bg-gray-800 rounded-2xl shadow-2xl
|
||||
transform transition-all border 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>
|
||||
|
|
|
|||
|
|
@ -95,13 +95,12 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
|
|||
{/* Selected Items Display */}
|
||||
<div
|
||||
className={`
|
||||
|
||||
w-full min-h-[42px] px-3 py-2 border rounded-md
|
||||
focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
|
||||
cursor-pointer
|
||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
w-full px-3 py-3 text-base border rounded-lg
|
||||
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500
|
||||
cursor-pointer transition-all duration-200
|
||||
${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'}
|
||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
||||
dark:text-gray-100
|
||||
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400
|
||||
`}
|
||||
onClick={handleToggleDropdown}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -106,12 +106,12 @@ export const SingleSelectAutocomplete: React.FC<SingleSelectAutocompleteProps> =
|
|||
|
||||
<div
|
||||
className={`
|
||||
w-full min-h-[42px] px-3 py-2 border rounded-md
|
||||
focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
|
||||
cursor-pointer
|
||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
w-full px-3 py-3 text-base border rounded-lg
|
||||
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500
|
||||
cursor-pointer transition-all duration-200
|
||||
${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'}
|
||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
||||
dark:text-gray-100
|
||||
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400
|
||||
`}
|
||||
onClick={handleToggleDropdown}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
import React from 'react';
|
||||
|
||||
export type StatusType = 'product' | 'order' | 'user' | 'discount' | 'comment' | 'generic';
|
||||
|
||||
export type ProductStatus = 'active' | 'inactive' | 'draft';
|
||||
export type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
||||
export type UserStatus = 'verified' | 'unverified' | boolean;
|
||||
export type DiscountStatus = 'active' | 'inactive';
|
||||
export type CommentStatus = 'approved' | 'rejected' | 'pending';
|
||||
|
||||
export type StatusValue = ProductStatus | OrderStatus | UserStatus | DiscountStatus | CommentStatus | string;
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: StatusValue;
|
||||
type?: StatusType;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const getStatusConfig = (status: StatusValue, type?: StatusType) => {
|
||||
// Handle boolean status (for verified/unverified)
|
||||
if (typeof status === 'boolean') {
|
||||
return {
|
||||
color: status
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
text: status ? 'تأیید شده' : 'تأیید نشده',
|
||||
};
|
||||
}
|
||||
|
||||
const statusStr = String(status).toLowerCase();
|
||||
|
||||
switch (type) {
|
||||
case 'product':
|
||||
switch (statusStr) {
|
||||
case 'active':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
text: 'فعال',
|
||||
};
|
||||
case 'inactive':
|
||||
return {
|
||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
text: 'غیرفعال',
|
||||
};
|
||||
case 'draft':
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
text: 'پیشنویس',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
text: statusStr,
|
||||
};
|
||||
}
|
||||
|
||||
case 'order':
|
||||
switch (statusStr) {
|
||||
case 'pending':
|
||||
return {
|
||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
text: 'در انتظار',
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
text: 'در حال پردازش',
|
||||
};
|
||||
case 'shipped':
|
||||
return {
|
||||
color: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
text: 'ارسال شده',
|
||||
};
|
||||
case 'delivered':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
text: 'تحویل شده',
|
||||
};
|
||||
case 'cancelled':
|
||||
return {
|
||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
text: 'لغو شده',
|
||||
};
|
||||
case 'refunded':
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
text: 'مرجوع شده',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
text: statusStr,
|
||||
};
|
||||
}
|
||||
|
||||
case 'user':
|
||||
switch (statusStr) {
|
||||
case 'verified':
|
||||
case 'true':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
text: 'تأیید شده',
|
||||
};
|
||||
case 'unverified':
|
||||
case 'false':
|
||||
return {
|
||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
text: 'تأیید نشده',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
text: statusStr,
|
||||
};
|
||||
}
|
||||
|
||||
case 'discount':
|
||||
switch (statusStr) {
|
||||
case 'active':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
text: 'فعال',
|
||||
};
|
||||
case 'inactive':
|
||||
return {
|
||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
text: 'غیرفعال',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
text: statusStr,
|
||||
};
|
||||
}
|
||||
|
||||
case 'comment':
|
||||
switch (statusStr) {
|
||||
case 'approved':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
text: 'تأیید شده',
|
||||
};
|
||||
case 'rejected':
|
||||
return {
|
||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
text: 'رد شده',
|
||||
};
|
||||
case 'pending':
|
||||
return {
|
||||
color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
text: 'در انتظار',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
text: statusStr,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
// Generic status handling
|
||||
switch (statusStr) {
|
||||
case 'active':
|
||||
case 'true':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
text: 'فعال',
|
||||
};
|
||||
case 'inactive':
|
||||
case 'false':
|
||||
return {
|
||||
color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
text: 'غیرفعال',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
text: statusStr,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getSizeClasses = (size: 'sm' | 'md' | 'lg') => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'px-2 py-0.5 text-xs';
|
||||
case 'md':
|
||||
return 'px-2.5 py-0.5 text-xs';
|
||||
case 'lg':
|
||||
return 'px-3 py-1 text-sm';
|
||||
default:
|
||||
return 'px-2.5 py-0.5 text-xs';
|
||||
}
|
||||
};
|
||||
|
||||
export const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
status,
|
||||
type = 'generic',
|
||||
className = '',
|
||||
size = 'md',
|
||||
}) => {
|
||||
const config = getStatusConfig(status, type);
|
||||
const sizeClasses = getSizeClasses(size);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full font-medium ${config.color} ${sizeClasses} ${className}`}
|
||||
>
|
||||
{config.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -75,7 +75,7 @@ export const Table = ({ columns, data, loading = false }: TableProps) => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="hidden md:block card overflow-hidden">
|
||||
<div className="hidden md:block card 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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
|
||||
interface ToggleSwitchProps {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<label className={`flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
checked
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ interface LabelProps extends TypographyProps {
|
|||
|
||||
// Page Headers
|
||||
export const PageTitle = ({ children, className = '' }: TypographyProps) => (
|
||||
<h1 className={`text-xl sm:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-gray-100 ${className}`}>
|
||||
<h1 className={`text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-6 ${className}`}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
|
|
@ -109,7 +109,7 @@ export const FormHeader = ({ title, subtitle, backButton, actions, className = '
|
|||
|
||||
// Page Container with consistent mobile spacing
|
||||
export const PageContainer = ({ children, className = '' }: TypographyProps) => (
|
||||
<div className={`p-4 sm:p-6 lg:p-8 space-y-4 sm:space-y-6 max-w-none ${className}`}>
|
||||
<div className={`space-y-6 max-w-none ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
enabled: true,
|
||||
fee_percentage: 0,
|
||||
profit_percentage: 0,
|
||||
tax_percentage: 0,
|
||||
stock_limit: 0,
|
||||
stock_managed: true,
|
||||
stock_number: 0,
|
||||
|
|
@ -99,6 +100,7 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
const [weightDisplay, setWeightDisplay] = useState(variant?.weight?.toString() || '');
|
||||
const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || '');
|
||||
const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || '');
|
||||
const [taxPercentageDisplay, setTaxPercentageDisplay] = useState(variant?.tax_percentage?.toString() || '');
|
||||
|
||||
const { mutateAsync: uploadFile } = useFileUpload();
|
||||
const { mutate: deleteFile } = useFileDelete();
|
||||
|
|
@ -119,11 +121,14 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
if (variant?.profit_percentage !== undefined) {
|
||||
setProfitPercentageDisplay(variant.profit_percentage.toString());
|
||||
}
|
||||
if (variant?.tax_percentage !== undefined) {
|
||||
setTaxPercentageDisplay(variant.tax_percentage.toString());
|
||||
}
|
||||
// Load variant attribute value if exists
|
||||
if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) {
|
||||
setVariantAttributeValue(variant.attributes[variantAttributeName].toString());
|
||||
}
|
||||
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.attributes, variantAttributeName]);
|
||||
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.tax_percentage, variant?.attributes, variantAttributeName]);
|
||||
|
||||
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
|
||||
if (typeof value === 'string') {
|
||||
|
|
@ -261,6 +266,28 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
/>
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
وزن (گرم)
|
||||
|
|
@ -580,6 +607,9 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
|
|||
<div>
|
||||
<strong>درصد سود:</strong> {variant.profit_percentage}%
|
||||
</div>
|
||||
<div>
|
||||
<strong>درصد مالیات:</strong> {variant.tax_percentage}%
|
||||
</div>
|
||||
<div>
|
||||
<strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -134,6 +134,10 @@ export const API_ROUTES = {
|
|||
UPDATE_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
|
||||
GET_IPG_STATUS: "payment/ipg/status",
|
||||
UPDATE_IPG_STATUS: "payment/ipg/status",
|
||||
|
|
@ -145,4 +149,17 @@ export const API_ROUTES = {
|
|||
// Wallet APIs
|
||||
GET_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}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,13 +12,14 @@ export const ThemeProvider = ({ children }: { children: any }) => {
|
|||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('admin_theme') as 'light' | 'dark' | null;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||
const initialTheme = savedTheme || 'light';
|
||||
setMode(initialTheme);
|
||||
|
||||
if (initialTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@
|
|||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700;
|
||||
@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;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { yupResolver } from '@hookform/resolvers/yup';
|
|||
import { Settings as SettingsIcon, Save, Globe, Mail } from 'lucide-react';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { PageHeader } from '../components/layout/PageHeader';
|
||||
import { settingsSchema, SettingsFormData } from '../utils/validationSchemas';
|
||||
|
||||
export const Settings = () => {
|
||||
|
|
@ -43,15 +44,11 @@ export const Settings = () => {
|
|||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center">
|
||||
<SettingsIcon className="h-6 w-6 ml-3" />
|
||||
تنظیمات سیستم
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
تنظیمات کلی سیستم را اینجا مدیریت کنید
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="تنظیمات سیستم"
|
||||
subtitle="تنظیمات کلی سیستم را اینجا مدیریت کنید"
|
||||
icon={SettingsIcon}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { Modal } from '../components/ui/Modal';
|
|||
import { Pagination } from '../components/ui/Pagination';
|
||||
import { UserForm } from '../components/forms/UserForm';
|
||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
||||
import { LoadingSpinner } from '../components/ui/LoadingSpinner';
|
||||
import { TableColumn } from '../types';
|
||||
import { UserFormData } from '../utils/validationSchemas';
|
||||
import { formatDate } from '../utils/formatters';
|
||||
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '../hooks/useUsers';
|
||||
|
||||
import { useFilters } from '../stores/useAppStore';
|
||||
|
|
@ -59,7 +59,7 @@ const Users = () => {
|
|||
key: 'createdAt',
|
||||
label: 'تاریخ عضویت',
|
||||
sortable: true,
|
||||
render: (value) => new Date(value).toLocaleDateString('fa-IR')
|
||||
render: (value) => formatDate(value)
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
|
|
@ -211,7 +211,9 @@ const Users = () => {
|
|||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<LoadingSpinner />
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText, User } from 'lucide-react';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { useAdminUser } from '../core/_hooks';
|
||||
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
||||
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
|
||||
const AdminUserDetailPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -12,14 +12,50 @@ const AdminUserDetailPage = () => {
|
|||
|
||||
const { data: user, isLoading, error } = useAdminUser(id);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (isLoading) {
|
||||
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 (!user) return <div>کاربر یافت نشد</div>;
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('fa-IR');
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const isActive = status === 'active';
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { usePermissions } from '../../permissions/core/_hooks';
|
|||
import { useRoles } from '../../roles/core/_hooks';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
|
@ -134,9 +133,19 @@ const AdminUserFormPage = () => {
|
|||
|
||||
if (isEdit && isLoadingUser) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -228,7 +237,7 @@ const AdminUserFormPage = () => {
|
|||
</label>
|
||||
<select
|
||||
{...register('status')}
|
||||
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-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="active">فعال</option>
|
||||
<option value="deactive">غیرفعال</option>
|
||||
|
|
|
|||
|
|
@ -4,84 +4,14 @@ import { useAdminUsers, useDeleteAdminUser } from '../core/_hooks';
|
|||
import { AdminUserInfo } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
import { Trash2, Edit3, Plus, Eye, Users, UserPlus } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer, PageTitle, SectionSubtitle } from '../../../components/ui/Typography';
|
||||
|
||||
// Skeleton Loading Component
|
||||
const AdminUserTableSkeleton = () => (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Desktop Table Skeleton */}
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
نام و نام خانوادگی
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
نام کاربری
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
وضعیت
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
تاریخ ایجاد
|
||||
</th>
|
||||
<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>
|
||||
);
|
||||
import { Users, UserPlus, Plus } from "lucide-react";
|
||||
import { PageContainer, SectionSubtitle } from '../../../components/ui/Typography';
|
||||
import { TableSkeleton } from '@/components/common/TableSkeleton';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { EmptyState } from '@/components/common/EmptyState';
|
||||
import { ActionButtons } from '@/components/common/ActionButtons';
|
||||
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
|
||||
import { formatDate } from '@/utils/formatters';
|
||||
|
||||
const AdminUsersListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -136,16 +66,11 @@ const AdminUsersListPage = () => {
|
|||
|
||||
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">
|
||||
<Users className="h-6 w-6" />
|
||||
<PageTitle>مدیریت کاربران ادمین</PageTitle>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">مدیریت کاربران دسترسی به پنل ادمین</p>
|
||||
</div>
|
||||
|
||||
<PageHeader
|
||||
title="مدیریت کاربران ادمین"
|
||||
subtitle="مدیریت کاربران دسترسی به پنل ادمین"
|
||||
icon={Users}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||
|
|
@ -153,7 +78,8 @@ const AdminUsersListPage = () => {
|
|||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<SectionSubtitle>فیلترها</SectionSubtitle>
|
||||
|
|
@ -178,7 +104,7 @@ const AdminUsersListPage = () => {
|
|||
<select
|
||||
value={filters.status}
|
||||
onChange={handleStatusChange}
|
||||
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-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
>
|
||||
<option value="">همه</option>
|
||||
<option value="active">فعال</option>
|
||||
|
|
@ -190,25 +116,24 @@ const AdminUsersListPage = () => {
|
|||
|
||||
{/* Users Table */}
|
||||
{isLoading ? (
|
||||
<AdminUserTableSkeleton />
|
||||
<TableSkeleton columns={5} rows={5} />
|
||||
) : (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="text-center py-12">
|
||||
<Users className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
هیچ کاربر ادمین یافت نشد
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{filters.search || filters.status
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="هیچ کاربر ادمین یافت نشد"
|
||||
description={filters.search || filters.status
|
||||
? "نتیجهای برای جستجوی شما یافت نشد"
|
||||
: "شما هنوز هیچ کاربر ادمین ایجاد نکردهاید"
|
||||
}
|
||||
</p>
|
||||
<Button onClick={handleCreate}>
|
||||
actionLabel={
|
||||
<>
|
||||
<UserPlus className="h-4 w-4 ml-2" />
|
||||
اولین کاربر ادمین را ایجاد کنید
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
onAction={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
|
|
@ -253,32 +178,14 @@ const AdminUsersListPage = () => {
|
|||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{new Date(user.created_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleView(user.id)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title="مشاهده"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(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>
|
||||
<ActionButtons
|
||||
onView={() => handleView(user.id)}
|
||||
onEdit={() => handleEdit(user.id)}
|
||||
onDelete={() => setDeleteUserId(user.id.toString())}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -308,65 +215,27 @@ const AdminUsersListPage = () => {
|
|||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
تاریخ ایجاد: {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>
|
||||
تاریخ ایجاد: {formatDate(user.created_at)}
|
||||
</div>
|
||||
<ActionButtons
|
||||
onView={() => handleView(user.id)}
|
||||
onEdit={() => handleEdit(user.id)}
|
||||
onDelete={() => setDeleteUserId(user.id.toString())}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
<DeleteConfirmModal
|
||||
isOpen={!!deleteUserId}
|
||||
onClose={() => setDeleteUserId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف کاربر ادمین"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
آیا از حذف این کاربر ادمین اطمینان دارید؟ این عمل قابل بازگشت نیست.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeleteUserId(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
loading={isDeleting}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
message="آیا از حذف این کاربر ادمین اطمینان دارید؟ این عمل قابل بازگشت نیست."
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,58 +3,15 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { useCategories, useDeleteCategory } from '../core/_hooks';
|
||||
import { Category } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
import { Trash2, Edit3, Plus, FolderOpen, Folder } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer, PageTitle, SectionSubtitle } from "../../../components/ui/Typography";
|
||||
|
||||
const CategoriesTableSkeleton = () => (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
نام دستهبندی
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
توضیحات
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
تاریخ ایجاد
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
عملیات
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import { Plus, FolderOpen, Folder } from "lucide-react";
|
||||
import { PageContainer } from "../../../components/ui/Typography";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { FiltersSection } from "@/components/common/FiltersSection";
|
||||
import { TableSkeleton } from "@/components/common/TableSkeleton";
|
||||
import { EmptyState } from "@/components/common/EmptyState";
|
||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||
import { ActionButtons } from "@/components/common/ActionButtons";
|
||||
import { formatDate } from "@/utils/formatters";
|
||||
|
||||
const CategoriesListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -98,18 +55,7 @@ const CategoriesListPage = () => {
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FolderOpen className="h-6 w-6" />
|
||||
<PageTitle>مدیریت دستهبندیها</PageTitle>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">مدیریت دستهبندیهای محصولات</p>
|
||||
</div>
|
||||
|
||||
const createButton = (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||
|
|
@ -117,11 +63,18 @@ const CategoriesListPage = () => {
|
|||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="مدیریت دستهبندیها"
|
||||
subtitle="مدیریت دستهبندیهای محصولات"
|
||||
icon={FolderOpen}
|
||||
actions={createButton}
|
||||
/>
|
||||
|
||||
<FiltersSection isLoading={isLoading} columns={2}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
جستجو
|
||||
|
|
@ -134,12 +87,25 @@ const CategoriesListPage = () => {
|
|||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FiltersSection>
|
||||
|
||||
{/* Categories Table */}
|
||||
{isLoading ? (
|
||||
<CategoriesTableSkeleton />
|
||||
<TableSkeleton columns={4} rows={5} />
|
||||
) : (!categories || categories.length === 0) ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<EmptyState
|
||||
icon={FolderOpen}
|
||||
title="دستهبندیای موجود نیست"
|
||||
description="برای شروع، اولین دستهبندی محصولات خود را ایجاد کنید."
|
||||
actionLabel={
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
ایجاد دستهبندی جدید
|
||||
</>
|
||||
}
|
||||
onAction={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Desktop Table */}
|
||||
|
|
@ -177,25 +143,13 @@ const CategoriesListPage = () => {
|
|||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{new Date(category.created_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(category.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(category.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteCategoryId(category.id.toString())}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ActionButtons
|
||||
onEdit={() => handleEdit(category.id)}
|
||||
onDelete={() => setDeleteCategoryId(category.id.toString())}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -220,77 +174,28 @@ const CategoriesListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
تاریخ ایجاد: {new Date(category.created_at).toLocaleDateString('fa-IR')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(category.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteCategoryId(category.id.toString())}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
حذف
|
||||
</button>
|
||||
تاریخ ایجاد: {formatDate(category.created_at)}
|
||||
</div>
|
||||
<ActionButtons
|
||||
onEdit={() => handleEdit(category.id)}
|
||||
onDelete={() => setDeleteCategoryId(category.id.toString())}
|
||||
showLabels={true}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{(!categories || categories.length === 0) && !isLoading && (
|
||||
<div className="text-center py-12">
|
||||
<FolderOpen className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
دستهبندیای موجود نیست
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
برای شروع، اولین دستهبندی محصولات خود را ایجاد کنید.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2 mx-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
ایجاد دستهبندی جدید
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
<DeleteConfirmModal
|
||||
isOpen={!!deleteCategoryId}
|
||||
onClose={() => setDeleteCategoryId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف دستهبندی"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
آیا از حذف این دستهبندی اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که در این دستهبندی قرار دارند تأثیر بگذارد.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeleteCategoryId(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
loading={isDeleting}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
message="آیا از حذف این دستهبندی اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که در این دستهبندی قرار دارند تأثیر بگذارد."
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||
import { ArrowRight, FolderOpen } from 'lucide-react';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Input } from '../../../components/ui/Input';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { FileUploader } from '../../../components/ui/FileUploader';
|
||||
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
|
|
@ -116,9 +115,19 @@ const CategoryFormPage = () => {
|
|||
|
||||
if (isEdit && isLoadingCategory) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
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;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
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 || "خطا در حذف پیام تماس با ما");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
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;
|
||||
};
|
||||
|
|
@ -7,11 +7,13 @@ import {
|
|||
createDiscountCode,
|
||||
updateDiscountCode,
|
||||
deleteDiscountCode,
|
||||
getDiscountReports,
|
||||
} from "./_requests";
|
||||
import {
|
||||
CreateDiscountCodeRequest,
|
||||
UpdateDiscountCodeRequest,
|
||||
DiscountCodeFilters,
|
||||
DiscountReportFilters,
|
||||
} from "./_models";
|
||||
|
||||
export const useDiscountCodes = (filters?: DiscountCodeFilters) => {
|
||||
|
|
@ -84,3 +86,13 @@ export const useDeleteDiscountCode = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useDiscountReports = (
|
||||
filters?: DiscountReportFilters,
|
||||
enabled: boolean = true
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_DISCOUNT_REPORTS, filters],
|
||||
queryFn: () => getDiscountReports(filters),
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@ export type DiscountApplicationLevels =
|
|||
|
||||
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 interface DiscountUserRestrictions {
|
||||
|
|
@ -73,6 +84,50 @@ export interface DiscountCodeFilters {
|
|||
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 {
|
||||
code: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
DiscountCode,
|
||||
DiscountCodeFilters,
|
||||
PaginatedDiscountCodesResponse,
|
||||
DiscountReportFilters,
|
||||
DiscountReportSimpleResponse,
|
||||
} from "./_models";
|
||||
|
||||
export const getDiscountCodes = async (filters?: DiscountCodeFilters) => {
|
||||
|
|
@ -75,3 +77,39 @@ export const deleteDiscountCode = async (id: string) => {
|
|||
);
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { useDiscountCode, useCreateDiscountCode, useUpdateDiscountCode } from '.
|
|||
import { CreateDiscountCodeRequest } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete";
|
||||
import { SingleSelectAutocomplete } from "@/components/ui/SingleSelectAutocomplete";
|
||||
import { JalaliDateTimePicker } from "@/components/ui/JalaliDateTimePicker";
|
||||
|
|
@ -17,6 +16,7 @@ import { ArrowRight, BadgePercent, Calendar, Settings, Users, Tag, Info } from '
|
|||
import { useUsers, useSearchUsers } from '../../users-admin/core/_hooks';
|
||||
import { useSearchProducts } from '../../products/core/_hooks';
|
||||
import { useSearchCategories } from '../../categories/core/_hooks';
|
||||
import { formatDateTimeLocal } from '../../../utils/formatters';
|
||||
|
||||
const schema = yup.object({
|
||||
code: yup.string().min(3, 'کد باید حداقل ۳ کاراکتر باشد').max(50, 'کد نباید بیشتر از ۵۰ کاراکتر باشد').required('کد الزامی است'),
|
||||
|
|
@ -51,22 +51,6 @@ const schema = yup.object({
|
|||
valid_to: yup.string().nullable(),
|
||||
});
|
||||
|
||||
const formatDateTimeLocal = (dateString?: string): string => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Convert input value (YYYY-MM-DDTHH:mm) to API format (YYYY-MM-DDTHH:mm:00Z)
|
||||
const toApiDateTime = (value?: string): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
|
|
@ -258,7 +242,23 @@ const DiscountCodeFormPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (isEdit && dcLoading) return <LoadingSpinner />;
|
||||
if (isEdit && dcLoading) {
|
||||
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;
|
||||
|
||||
return (
|
||||
|
|
@ -339,7 +339,7 @@ const DiscountCodeFormPage = () => {
|
|||
<div className="space-y-2">
|
||||
<Label>نوع تخفیف</Label>
|
||||
<select
|
||||
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"
|
||||
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"
|
||||
{...register('type')}
|
||||
data-testid="discount-type-select"
|
||||
>
|
||||
|
|
@ -363,7 +363,7 @@ const DiscountCodeFormPage = () => {
|
|||
<div className="space-y-2">
|
||||
<Label>وضعیت</Label>
|
||||
<select
|
||||
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"
|
||||
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"
|
||||
{...register('status')}
|
||||
data-testid="discount-status-select"
|
||||
required
|
||||
|
|
@ -696,7 +696,7 @@ const DiscountCodeFormPage = () => {
|
|||
<div className="space-y-2">
|
||||
<Label>گروه کاربری</Label>
|
||||
<select
|
||||
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"
|
||||
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"
|
||||
{...register('user_restrictions.user_group')}
|
||||
>
|
||||
<option value="loyal">وفادار (loyal)</option>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,60 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks';
|
||||
import { useDiscountCodes, useDeleteDiscountCode, useDiscountReports } from '../core/_hooks';
|
||||
import { DiscountCode } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Table } from "@/components/ui/Table";
|
||||
import { TableColumn } from "@/types";
|
||||
import { Percent, BadgePercent, Trash2, Edit3, Plus, Ticket } from 'lucide-react';
|
||||
import { BadgePercent, Plus, Ticket, Hash, DollarSign, Users } 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 navigate = useNavigate();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
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 { 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 = () => navigate('/discount-codes/create');
|
||||
const handleEdit = (id: number) => navigate(`/discount-codes/${id}/edit`);
|
||||
const handleCreate = useCallback(() => navigate('/discount-codes/create'), [navigate]);
|
||||
const handleEdit = useCallback((id: number) => navigate(`/discount-codes/${id}/edit`), [navigate]);
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteId) {
|
||||
|
|
@ -25,6 +62,16 @@ const DiscountCodesListPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleOpenUsageModal = useCallback((discount: DiscountCode) => {
|
||||
setSelectedDiscount(discount);
|
||||
setIsUsageModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseUsageModal = () => {
|
||||
setIsUsageModalOpen(false);
|
||||
setSelectedDiscount(null);
|
||||
};
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'code', label: 'کد', sortable: true },
|
||||
{ key: 'name', label: 'نام', sortable: true },
|
||||
|
|
@ -41,18 +88,14 @@ const DiscountCodesListPage = () => {
|
|||
{
|
||||
key: 'status',
|
||||
label: 'وضعیت',
|
||||
render: (val: string) => (
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${val === 'active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
|
||||
{val === 'active' ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
)
|
||||
render: (val: string) => <StatusBadge status={val} type="discount" />
|
||||
},
|
||||
{
|
||||
key: 'period',
|
||||
label: 'بازه زمانی',
|
||||
render: (_val, row: any) => (
|
||||
<span>
|
||||
{row.valid_from ? new Date(row.valid_from).toLocaleDateString('fa-IR') : '-'} تا {row.valid_to ? new Date(row.valid_to).toLocaleDateString('fa-IR') : '-'}
|
||||
{row.valid_from ? formatDate(row.valid_from) : '-'} تا {row.valid_to ? formatDate(row.valid_to) : '-'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
|
|
@ -60,25 +103,15 @@ const DiscountCodesListPage = () => {
|
|||
key: 'actions',
|
||||
label: 'عملیات',
|
||||
render: (_val, row: any) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(row.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteId(row.id.toString())}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ActionButtons
|
||||
onView={() => handleOpenUsageModal(row as DiscountCode)}
|
||||
viewTitle="آمار استفاده"
|
||||
onEdit={() => handleEdit(row.id)}
|
||||
onDelete={() => setDeleteId(row.id.toString())}
|
||||
/>
|
||||
)
|
||||
}
|
||||
], [navigate]);
|
||||
], [handleEdit, handleOpenUsageModal]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -90,16 +123,7 @@ const DiscountCodesListPage = () => {
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<BadgePercent className="h-6 w-6" />
|
||||
مدیریت کدهای تخفیف
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">ایجاد و مدیریت کدهای تخفیف</p>
|
||||
</div>
|
||||
const createButton = (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||
|
|
@ -108,10 +132,19 @@ const DiscountCodesListPage = () => {
|
|||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="مدیریت کدهای تخفیف"
|
||||
subtitle="ایجاد و مدیریت کدهای تخفیف"
|
||||
icon={BadgePercent}
|
||||
actions={createButton}
|
||||
/>
|
||||
|
||||
<FiltersSection isLoading={isLoading} columns={3}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">کد</label>
|
||||
<input
|
||||
|
|
@ -122,37 +155,167 @@ const DiscountCodesListPage = () => {
|
|||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</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 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 ? (
|
||||
<Table columns={columns} data={Array.isArray(discountCodes) ? (discountCodes as any[]) : []} loading={true} />
|
||||
) : !discountCodes || discountCodes.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="text-center py-12">
|
||||
<Ticket className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">هیچ کد تخفیفی یافت نشد</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">برای شروع یک کد تخفیف ایجاد کنید</p>
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||
<EmptyState
|
||||
icon={Ticket}
|
||||
title="هیچ کد تخفیفی یافت نشد"
|
||||
description="برای شروع یک کد تخفیف ایجاد کنید"
|
||||
actionLabel={
|
||||
<>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
ایجاد کد تخفیف
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
onAction={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={discountCodes as any[]} />
|
||||
)}
|
||||
|
||||
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف کد تخفیف">
|
||||
<Modal
|
||||
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">
|
||||
<p className="text-gray-600 dark:text-gray-400">آیا از حذف این کد تخفیف اطمینان دارید؟ این عمل قابل بازگشت نیست.</p>
|
||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
|
||||
<Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
|
||||
<div className="bg-gray-50 dark:bg-gray-900/40 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-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{selectedDiscount?.name || '-'}
|
||||
</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 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>
|
||||
|
||||
<DeleteConfirmModal
|
||||
isOpen={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف کد تخفیف"
|
||||
message="آیا از حذف این کد تخفیف اطمینان دارید؟ این عمل قابل بازگشت نیست."
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import { FileUploader } from "@/components/ui/FileUploader";
|
|||
import { Button } from "@/components/ui/Button";
|
||||
import { useLandingHero, useUpdateLandingHero } from "./core/_hooks";
|
||||
import { LandingHeroData, HeroImage } from "./core/_models";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { PlusCircle, Trash2, Save } from "lucide-react";
|
||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer } from "@/components/ui/Typography";
|
||||
|
||||
const heroImageSchema = yup.object({
|
||||
alt_text: yup.string().required("متن ALT الزامی است"),
|
||||
|
|
@ -92,9 +92,19 @@ export const HeroSliderPage = () => {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useOrder, useUpdateOrderStatus } from '../core/_hooks';
|
|||
import { OrderStatus } from '../core/_models';
|
||||
import { useShippingMethods } from '@/pages/shipping-methods/core/_hooks';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
|
||||
import {
|
||||
|
|
@ -22,6 +21,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { englishToPersian } from '@/utils/numberUtils';
|
||||
import { API_GATE_WAY } from '@/constant/routes';
|
||||
import { formatCurrency, formatDateTime } from '@/utils/formatters';
|
||||
|
||||
const resolveImageUrl = (imageUrl?: string): string => {
|
||||
if (!imageUrl) return '';
|
||||
|
|
@ -56,25 +56,15 @@ const getStatusText = (status: OrderStatus) => {
|
|||
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) => {
|
||||
if (!type) return '';
|
||||
const key = type.toLowerCase().replace(/\s+/g, '-').replace(/_/g, '-');
|
||||
const mapping: Record<string, string> = {
|
||||
'card-to-card': 'کارت به کارت',
|
||||
'bank-topup': 'افزایش موجودی کیف پول',
|
||||
'card-to-card': 'پرداخت به روش کارت به کارت',
|
||||
'debit-rial-wallet': 'پرداخت از کیف ریالی',
|
||||
'debit-gold18k-wallet': 'پرداخت از کیف طلا',
|
||||
'credit-card': 'پرداخت بانکی',
|
||||
'debit-card': 'کارت بانکی',
|
||||
'bank-transfer': 'حواله بانکی',
|
||||
|
|
@ -114,7 +104,41 @@ const OrderDetailPage = () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (isLoading) {
|
||||
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) {
|
||||
return (
|
||||
<PageContainer>
|
||||
|
|
@ -135,7 +159,7 @@ const OrderDetailPage = () => {
|
|||
<div>
|
||||
<PageTitle>سفارش #{order?.order_number || 'نامشخص'}</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
تاریخ ثبت: {order?.created_at ? formatDate(order.created_at) : 'نامشخص'}
|
||||
تاریخ ثبت: {order?.created_at ? formatDateTime(order.created_at) : 'نامشخص'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
|
|
@ -331,7 +355,7 @@ const OrderDetailPage = () => {
|
|||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ ثبت</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{order?.created_at ? formatDate(order.created_at) : 'نامشخص'}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{order?.created_at ? formatDateTime(order.created_at) : 'نامشخص'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">شناسه فاکتور</h4>
|
||||
|
|
@ -343,7 +367,7 @@ const OrderDetailPage = () => {
|
|||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ آخرین بروزرسانی</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{order?.updated_at ? formatDate(order.updated_at) : 'نامشخص'}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{order?.updated_at ? formatDateTime(order.updated_at) : 'نامشخص'}</p>
|
||||
</div>
|
||||
{/* روش حمل و نقل در دادههای فعلی وجود ندارد */}
|
||||
{order?.tracking_number && (
|
||||
|
|
@ -355,7 +379,7 @@ const OrderDetailPage = () => {
|
|||
{order?.estimated_delivery && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-2">تاریخ تحویل تخمینی</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{formatDate(order.estimated_delivery)}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{formatDateTime(order.estimated_delivery)}</p>
|
||||
</div>
|
||||
)}
|
||||
{order?.shipping_method_id !== undefined && order?.shipping_method_id !== null && (
|
||||
|
|
@ -567,7 +591,7 @@ const OrderDetailPage = () => {
|
|||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value as OrderStatus)}
|
||||
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"
|
||||
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="pending">در انتظار</option>
|
||||
<option value="processing">در حال پردازش</option>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { OrderFilters, OrderStatus } from '../core/_models';
|
|||
import { Button } from "@/components/ui/Button";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { PageContainer, PageTitle } from "@/components/ui/Typography";
|
||||
import { PageContainer } from "@/components/ui/Typography";
|
||||
import { Table } from "@/components/ui/Table";
|
||||
import { TableColumn } from "@/types";
|
||||
import { StatsCard } from '@/components/dashboard/StatsCard';
|
||||
|
|
@ -20,46 +20,16 @@ import {
|
|||
Clock,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Edit3,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { FiltersSection } from '@/components/common/FiltersSection';
|
||||
import { EmptyState } from '@/components/common/EmptyState';
|
||||
import { ActionButtons } from '@/components/common/ActionButtons';
|
||||
import { StatusBadge } from '@/components/ui/StatusBadge';
|
||||
import { formatCurrency, formatDate } from '@/utils/formatters';
|
||||
|
||||
const getStatusColor = (status: OrderStatus) => {
|
||||
const colors = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
processing: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
shipped: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||
delivered: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||
cancelled: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||
refunded: 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200',
|
||||
};
|
||||
return colors[status] || colors.pending;
|
||||
};
|
||||
|
||||
const getStatusText = (status: OrderStatus) => {
|
||||
const text = {
|
||||
pending: 'در انتظار',
|
||||
processing: 'در حال پردازش',
|
||||
shipped: 'ارسال شده',
|
||||
delivered: 'تحویل شده',
|
||||
cancelled: 'لغو شده',
|
||||
refunded: 'مرجوع شده',
|
||||
};
|
||||
return text[status] || status;
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('fa-IR').format(amount) + ' تومان';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('fa-IR');
|
||||
};
|
||||
|
||||
const ListSkeleton = () => (
|
||||
<Table columns={[]} data={[]} loading={true} />
|
||||
);
|
||||
|
||||
const getDefaultFilters = (): OrderFilters => ({
|
||||
page: 1,
|
||||
|
|
@ -219,7 +189,7 @@ const OrdersListPage = () => {
|
|||
)
|
||||
},
|
||||
{ key: 'final_total', label: 'مبلغ نهایی', sortable: true, align: 'right', render: (v: number, row: any) => formatCurrency(row.final_total || row.total_amount || 0) },
|
||||
{ key: 'status', label: 'وضعیت', align: 'right', render: (v: OrderStatus) => (<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(v)}`}>{getStatusText(v)}</span>) },
|
||||
{ key: 'status', label: 'وضعیت', align: 'right', render: (v: OrderStatus) => <StatusBadge status={v} type="order" /> },
|
||||
{ key: 'created_at', label: 'تاریخ', sortable: true, align: 'right', render: (v: string) => formatDate(v) },
|
||||
{
|
||||
key: 'actions',
|
||||
|
|
@ -227,13 +197,9 @@ const OrdersListPage = () => {
|
|||
align: 'right',
|
||||
render: (_val, row: any) => (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => handleViewOrder(row.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
title="مشاهده جزئیات"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<ActionButtons
|
||||
onView={() => handleViewOrder(row.id)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(row.id, row.status)}
|
||||
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||
|
|
@ -280,17 +246,11 @@ const OrdersListPage = () => {
|
|||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-6 w-6" />
|
||||
مدیریت سفارشات
|
||||
</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{ordersData?.total || 0} سفارش یافت شد
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="مدیریت سفارشات"
|
||||
subtitle={`${ordersData?.total || 0} سفارش یافت شد`}
|
||||
icon={ShoppingCart}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 lg:gap-6">
|
||||
{statsLoading ? (
|
||||
|
|
@ -331,7 +291,7 @@ const OrdersListPage = () => {
|
|||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value as OrderStatus || undefined, page: 1 }))}
|
||||
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"
|
||||
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>
|
||||
|
|
@ -360,7 +320,7 @@ const OrdersListPage = () => {
|
|||
<select
|
||||
value={filters.payment_status || ''}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, payment_status: e.target.value as any || undefined, page: 1 }))}
|
||||
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"
|
||||
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>
|
||||
|
|
@ -468,14 +428,14 @@ const OrdersListPage = () => {
|
|||
|
||||
{/* جدول سفارشات */}
|
||||
{isLoading ? (
|
||||
<ListSkeleton />
|
||||
<Table columns={columns} data={[]} loading={true} />
|
||||
) : !ordersData?.orders || ordersData.orders.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="text-center py-12">
|
||||
<ShoppingCart className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">هیچ سفارشی یافت نشد</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">با تغییر فیلترها جستجو کنید</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={ShoppingCart}
|
||||
title="هیچ سفارشی یافت نشد"
|
||||
description="با تغییر فیلترها جستجو کنید"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -500,7 +460,7 @@ const OrdersListPage = () => {
|
|||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value as OrderStatus)}
|
||||
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"
|
||||
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="pending">در انتظار</option>
|
||||
<option value="processing">در حال پردازش</option>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import { useForm, Controller } from 'react-hook-form';
|
|||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import { CreditCard } from 'lucide-react';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { PageContainer } from '@/components/ui/Typography';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { formatDateTime } from '@/utils/formatters';
|
||||
import { usePaymentCard, useUpdatePaymentCard } from '../core/_hooks';
|
||||
import { persianToEnglish } from '@/utils/numberUtils';
|
||||
|
||||
|
|
@ -35,41 +37,6 @@ const formatCardNumber = (value: string): string => {
|
|||
return groups ? groups.join(' ') : cleaned;
|
||||
};
|
||||
|
||||
const ToggleSwitch = ({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
checked
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const CardFormPage = () => {
|
||||
const { data, isLoading, error } = usePaymentCard();
|
||||
const { mutate: updateCard, isPending } = useUpdatePaymentCard();
|
||||
|
|
@ -127,23 +94,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) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -162,22 +129,16 @@ const CardFormPage = () => {
|
|||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-6 w-6" />
|
||||
پرداخت کارت به کارت
|
||||
</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
مدیریت اطلاعات کارت و فعال/غیرفعال کردن روش پرداخت
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="پرداخت کارت به کارت"
|
||||
subtitle="مدیریت اطلاعات کارت و فعال/غیرفعال کردن روش پرداخت"
|
||||
icon={CreditCard}
|
||||
/>
|
||||
|
||||
{data?.updated_at && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
آخرین بهروزرسانی: {formatDate(data.updated_at)}
|
||||
آخرین بهروزرسانی: {formatDateTime(data.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -32,3 +32,5 @@ export const useUpdatePaymentCard = () => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,3 +21,5 @@ export interface UpdatePaymentCardResponse {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,3 +23,5 @@ export const updatePaymentCard = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -36,3 +36,5 @@ export const useUpdateIPGStatus = () => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,3 +31,5 @@ export const IPG_LABELS: Record<IPGType, string> = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,3 +23,5 @@ export const updateIPGStatus = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,58 +1,96 @@
|
|||
import React from 'react';
|
||||
import { CreditCard, Loader2 } from 'lucide-react';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { CreditCard, Loader2, TrendingUp, CheckCircle, XCircle, DollarSign } from 'lucide-react';
|
||||
import { PageContainer } from '@/components/ui/Typography';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
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 { IPGStatus, IPG_LABELS } from '../core/_models';
|
||||
import { usePaymentMethodsReport, usePaymentTransactionsReport } from '@/pages/reports/payment-statistics/core/_hooks';
|
||||
import { TableColumn } from '@/types';
|
||||
|
||||
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 getPaymentTypeLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
'bank-topup': 'افزایش موجودی کیف پول',
|
||||
'card-to-card': 'پرداخت به روش کارت به کارت',
|
||||
'debit-rial-wallet': 'پرداخت از کیف ریالی',
|
||||
'debit-gold18k-wallet': 'پرداخت از کیف طلا',
|
||||
unknown: 'نامشخص',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const IPGListPage = () => {
|
||||
const { data, isLoading, error } = useIPGStatus();
|
||||
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) => {
|
||||
updateStatus({
|
||||
|
|
@ -64,8 +102,32 @@ const IPGListPage = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<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-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>
|
||||
</PageContainer>
|
||||
);
|
||||
|
|
@ -87,17 +149,11 @@ const IPGListPage = () => {
|
|||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<CreditCard className="h-6 w-6" />
|
||||
مدیریت درگاههای پرداخت
|
||||
</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
فعال یا غیرفعال کردن درگاههای پرداخت
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="مدیریت درگاههای پرداخت"
|
||||
subtitle="فعال یا غیرفعال کردن درگاههای پرداخت"
|
||||
icon={CreditCard}
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="p-6">
|
||||
|
|
@ -123,7 +179,7 @@ const IPGListPage = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
آخرین بهروزرسانی: {formatDate(ipg.updated_at)}
|
||||
آخرین بهروزرسانی: {formatDateTime(ipg.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -141,6 +197,195 @@ const IPGListPage = () => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
@ -151,3 +396,5 @@ export default IPGListPage;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { usePermission, useCreatePermission, useUpdatePermission } from '../core
|
|||
import { PermissionFormData } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
||||
|
|
@ -81,9 +80,19 @@ const PermissionFormPage = () => {
|
|||
|
||||
if (isEdit && isLoadingPermission) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,72 +1,12 @@
|
|||
import React, { useState } from 'react';
|
||||
import { usePermissions } from '../core/_hooks';
|
||||
import { Permission } from '../core/_models';
|
||||
|
||||
import { Shield, Plus } from "lucide-react";
|
||||
|
||||
// Skeleton Loading Component
|
||||
const PermissionsTableSkeleton = () => (
|
||||
<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>
|
||||
);
|
||||
import { Shield } from "lucide-react";
|
||||
import { TableSkeleton } from '@/components/common/TableSkeleton';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { EmptyState } from '@/components/common/EmptyState';
|
||||
import { FiltersSection } from '@/components/common/FiltersSection';
|
||||
import { formatDate } from '@/utils/formatters';
|
||||
|
||||
const PermissionsListPage = () => {
|
||||
const [filters, setFilters] = useState({
|
||||
|
|
@ -91,22 +31,13 @@ const PermissionsListPage = () => {
|
|||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<PageHeader
|
||||
title="لیست دسترسیها"
|
||||
subtitle="نمایش دسترسیهای سیستم"
|
||||
icon={Shield}
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<FiltersSection>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
جستجو
|
||||
|
|
@ -119,26 +50,21 @@ 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FiltersSection>
|
||||
|
||||
{/* Permissions Table */}
|
||||
{isLoading ? (
|
||||
<PermissionsTableSkeleton />
|
||||
<TableSkeleton columns={4} rows={5} />
|
||||
) : (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="text-center py-12">
|
||||
<Shield className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
هیچ دسترسی یافت نشد
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{filters.search
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title="هیچ دسترسی یافت نشد"
|
||||
description={filters.search
|
||||
? "نتیجهای برای جستجوی شما یافت نشد"
|
||||
: "دسترسیهای سیستم در اینجا نمایش داده میشوند"
|
||||
}
|
||||
</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">
|
||||
|
|
@ -172,7 +98,7 @@ const PermissionsListPage = () => {
|
|||
{permission.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{new Date(permission.created_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(permission.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -196,7 +122,7 @@ const PermissionsListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
تاریخ ایجاد: {new Date(permission.created_at).toLocaleDateString('fa-IR')}
|
||||
تاریخ ایجاد: {formatDate(permission.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { useProductOption, useCreateProductOption, useUpdateProductOption } from
|
|||
import { ProductOptionFormData } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { FileUploader } from "@/components/ui/FileUploader";
|
||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||
import { ArrowRight, Plus, Trash2 } from "lucide-react";
|
||||
|
|
@ -142,9 +141,19 @@ const ProductOptionFormPage = () => {
|
|||
|
||||
if (isLoadingOption) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,58 +2,14 @@ import React, { useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { useProductOptions, useDeleteProductOption } from '../core/_hooks';
|
||||
import { ProductOption } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
import { Trash2, Edit3, Plus, Settings, Tag } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
const 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="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
نام گزینه
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
مقادیر
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
تاریخ ایجاد
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
عملیات
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import { Settings, Tag, Plus } from "lucide-react";
|
||||
import { TableSkeleton } from '@/components/common/TableSkeleton';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { EmptyState } from '@/components/common/EmptyState';
|
||||
import { ActionButtons } from '@/components/common/ActionButtons';
|
||||
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
|
||||
import { FiltersSection } from '@/components/common/FiltersSection';
|
||||
import { formatDate } from '@/utils/formatters';
|
||||
|
||||
const ProductOptionsListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -99,17 +55,11 @@ const ProductOptionsListPage = () => {
|
|||
|
||||
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">
|
||||
<Settings className="h-6 w-6" />
|
||||
مدیریت گزینههای محصول
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
تنظیمات گزینههای قابل انتخاب برای محصولات
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="مدیریت گزینههای محصول"
|
||||
subtitle="تنظیمات گزینههای قابل انتخاب برای محصولات"
|
||||
icon={Settings}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||
|
|
@ -117,11 +67,10 @@ const ProductOptionsListPage = () => {
|
|||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FiltersSection>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
جستجو
|
||||
|
|
@ -134,12 +83,11 @@ 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FiltersSection>
|
||||
|
||||
{/* Product Options Table */}
|
||||
{isLoading ? (
|
||||
<ProductOptionsTableSkeleton />
|
||||
<TableSkeleton columns={4} rows={5} />
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Desktop Table */}
|
||||
|
|
@ -187,25 +135,13 @@ const ProductOptionsListPage = () => {
|
|||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{new Date(option.created_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(option.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(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>
|
||||
<ActionButtons
|
||||
onEdit={() => handleEdit(option.id)}
|
||||
onDelete={() => setDeleteOptionId(option.id.toString())}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -242,77 +178,26 @@ const ProductOptionsListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
تاریخ ایجاد: {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>
|
||||
تاریخ ایجاد: {formatDate(option.created_at)}
|
||||
</div>
|
||||
<ActionButtons
|
||||
onEdit={() => handleEdit(option.id)}
|
||||
onDelete={() => setDeleteOptionId(option.id.toString())}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
<DeleteConfirmModal
|
||||
isOpen={!!deleteOptionId}
|
||||
onClose={() => setDeleteOptionId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف گزینه محصول"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
آیا از حذف این گزینه محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که از این گزینه استفاده میکنند تأثیر بگذارد.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeleteOptionId(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
loading={isDeleting}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
message="آیا از حذف این گزینه محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و ممکن است بر محصولاتی که از این گزینه استفاده میکنند تأثیر بگذارد."
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
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;
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
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("خطا در حذف نظر");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
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;
|
||||
};
|
||||
|
||||
|
|
@ -17,6 +17,7 @@ export interface ProductVariant {
|
|||
enabled: boolean;
|
||||
fee_percentage: number;
|
||||
profit_percentage: number;
|
||||
tax_percentage: number;
|
||||
stock_limit: number;
|
||||
stock_managed: boolean;
|
||||
stock_number: number;
|
||||
|
|
@ -77,6 +78,7 @@ export interface ProductVariantFormData {
|
|||
enabled: boolean;
|
||||
fee_percentage: number;
|
||||
profit_percentage: number;
|
||||
tax_percentage: number;
|
||||
stock_limit: number;
|
||||
stock_managed: boolean;
|
||||
stock_number: number;
|
||||
|
|
@ -133,6 +135,7 @@ export interface CreateVariantRequest {
|
|||
enabled: boolean;
|
||||
fee_percentage: number;
|
||||
profit_percentage: number;
|
||||
tax_percentage: number;
|
||||
stock_limit: number;
|
||||
stock_managed: boolean;
|
||||
stock_number: number;
|
||||
|
|
@ -147,6 +150,7 @@ export interface UpdateVariantRequest {
|
|||
enabled: boolean;
|
||||
fee_percentage: number;
|
||||
profit_percentage: number;
|
||||
tax_percentage: number;
|
||||
stock_limit: number;
|
||||
stock_managed: boolean;
|
||||
stock_number: number;
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { useState } from 'react';
|
|||
import { Modal } from '../../../components/ui/Modal';
|
||||
import { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign, Hash, Layers, Settings } from 'lucide-react';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { useProduct } from '../core/_hooks';
|
||||
import { PRODUCT_TYPE_LABELS } from '../core/_models';
|
||||
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
|
||||
import { formatPrice, formatDate } from '../../../utils/formatters';
|
||||
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
|
||||
|
||||
type NormalizedMedia = {
|
||||
|
|
@ -25,14 +25,47 @@ const ProductDetailPage = () => {
|
|||
|
||||
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: 'image' | 'video' } | null>(null);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (isLoading) {
|
||||
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 (!product) return <div>محصول یافت نشد</div>;
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
|
||||
};
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return new Intl.NumberFormat('fa-IR').format(num);
|
||||
};
|
||||
|
|
@ -400,6 +433,12 @@ const ProductDetailPage = () => {
|
|||
{formatNumber(variant.profit_percentage)}%
|
||||
</span>
|
||||
</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">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 block">وزن</span>
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
|
|
@ -644,7 +683,7 @@ const ProductDetailPage = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{new Date(product.created_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(product.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -656,7 +695,7 @@ const ProductDetailPage = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{new Date(product.updated_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(product.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import { ProductFormData, ProductImage, ProductVariantFormData, PRODUCT_TYPES, P
|
|||
import { MultiSelectAutocomplete } from "@/components/ui/MultiSelectAutocomplete";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { FileUploader } from "@/components/ui/FileUploader";
|
||||
import { VariantManager } from "@/components/ui/VariantManager";
|
||||
import { ArrowRight, X } from "lucide-react";
|
||||
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
import { FormSection } from '@/components/forms/FormSection';
|
||||
import { FormActions } from '@/components/forms/FormActions';
|
||||
import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils';
|
||||
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
|
||||
import { toast } from "react-hot-toast";
|
||||
|
|
@ -178,6 +179,7 @@ const ProductFormPage = () => {
|
|||
enabled: variant.enabled,
|
||||
fee_percentage: variant.fee_percentage,
|
||||
profit_percentage: variant.profit_percentage,
|
||||
tax_percentage: variant.tax_percentage || 0,
|
||||
stock_limit: variant.stock_limit,
|
||||
stock_managed: variant.stock_managed,
|
||||
stock_number: variant.stock_number,
|
||||
|
|
@ -351,6 +353,7 @@ const ProductFormPage = () => {
|
|||
enabled: variant.enabled,
|
||||
fee_percentage: variant.fee_percentage,
|
||||
profit_percentage: variant.profit_percentage,
|
||||
tax_percentage: variant.tax_percentage || 0,
|
||||
stock_limit: variant.stock_limit,
|
||||
stock_managed: variant.stock_managed,
|
||||
stock_number: variant.stock_number,
|
||||
|
|
@ -380,6 +383,7 @@ const ProductFormPage = () => {
|
|||
enabled: variant.enabled,
|
||||
fee_percentage: variant.fee_percentage,
|
||||
profit_percentage: variant.profit_percentage,
|
||||
tax_percentage: variant.tax_percentage || 0,
|
||||
stock_limit: variant.stock_limit,
|
||||
stock_managed: variant.stock_managed,
|
||||
stock_number: variant.stock_number,
|
||||
|
|
@ -417,9 +421,19 @@ const ProductFormPage = () => {
|
|||
|
||||
if (isEdit && isLoadingProduct) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -459,11 +473,7 @@ const ProductFormPage = () => {
|
|||
{/* Form */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* Basic Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
اطلاعات پایه
|
||||
</h3>
|
||||
<FormSection title="اطلاعات پایه">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
|
|
@ -524,13 +534,9 @@ const ProductFormPage = () => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Categories and Product Options */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
دستهبندی و گزینهها
|
||||
</h3>
|
||||
<FormSection title="دستهبندی و گزینهها">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<MultiSelectAutocomplete
|
||||
label="دستهبندیها"
|
||||
|
|
@ -552,7 +558,7 @@ const ProductFormPage = () => {
|
|||
) : (
|
||||
<select
|
||||
{...register('product_option_id')}
|
||||
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-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>
|
||||
{productOptionOptions.map((option) => (
|
||||
|
|
@ -570,13 +576,9 @@ const ProductFormPage = () => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Images */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
تصاویر محصول
|
||||
</h3>
|
||||
<FormSection title="تصاویر محصول">
|
||||
|
||||
<FileUploader
|
||||
onUpload={handleFileUpload}
|
||||
|
|
@ -629,12 +631,9 @@ const ProductFormPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
فایلهای Explorer
|
||||
</h3>
|
||||
<FormSection title="فایلهای Explorer">
|
||||
<FileUploader
|
||||
onUpload={handleExplorerUpload}
|
||||
onRemove={handleExplorerRemove}
|
||||
|
|
@ -697,19 +696,16 @@ const ProductFormPage = () => {
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Variants Management */}
|
||||
<div>
|
||||
<FormSection title="مدیریت Variants">
|
||||
<VariantManager
|
||||
variants={watch('variants') || []}
|
||||
onChange={(variants) => setValue('variants', variants, { shouldValidate: true, shouldDirty: true })}
|
||||
productOptions={productOptionOptions}
|
||||
variantAttributeName={watch('variant_attribute_name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</FormSection>
|
||||
|
||||
{/* Preview */}
|
||||
{formValues.name && (
|
||||
|
|
@ -785,24 +781,13 @@ const ProductFormPage = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex justify-end space-x-4 space-x-reverse pt-6 border-t border-gray-200 dark:border-gray-600">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
disabled={isLoading}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={!isValid || isLoading || isUploading || isExplorerUploading}
|
||||
>
|
||||
{isEdit ? 'بهروزرسانی' : 'ایجاد محصول'}
|
||||
</Button>
|
||||
</div>
|
||||
<FormActions
|
||||
onCancel={handleBack}
|
||||
cancelLabel="انصراف"
|
||||
submitLabel={isEdit ? 'بهروزرسانی' : 'ایجاد محصول'}
|
||||
isLoading={isLoading}
|
||||
isDisabled={!isValid || isLoading || isUploading || isExplorerUploading}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,65 +4,18 @@ import { useProducts, useDeleteProduct } from '../core/_hooks';
|
|||
import { useCategories } from '../../categories/core/_hooks';
|
||||
import { Product } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer } from "@/components/ui/Typography";
|
||||
import { Plus, Package, Image } from "lucide-react";
|
||||
import { persianToEnglish } from '../../../utils/numberUtils';
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
|
||||
const ProductsTableSkeleton = () => (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
محصول
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
قیمت
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
دستهبندی
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
وضعیت
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
عملیات
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
<div className="h-8 w-8 bg-gray-200 dark:bg-gray-600 rounded animate-pulse"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { FiltersSection } from "@/components/common/FiltersSection";
|
||||
import { TableSkeleton } from "@/components/common/TableSkeleton";
|
||||
import { EmptyState } from "@/components/common/EmptyState";
|
||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||
import { ActionButtons } from "@/components/common/ActionButtons";
|
||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||
import { formatPrice } from "@/utils/formatters";
|
||||
|
||||
const ProductsListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -122,29 +75,12 @@ const ProductsListPage = () => {
|
|||
setFilters(prev => ({ ...prev, status: e.target.value, page: 1 }));
|
||||
};
|
||||
|
||||
const formatPrice = (price: number) => {
|
||||
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
|
||||
};
|
||||
|
||||
const getFirstImageUrl = (p: any): string | null => {
|
||||
if (p.file_ids && p.file_ids.length > 0) return p.file_ids[0].url;
|
||||
if (p.files && p.files.length > 0) return p.files[0].url;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">فعال</span>;
|
||||
case 'inactive':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">غیرفعال</span>;
|
||||
case 'draft':
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">پیشنویس</span>;
|
||||
default:
|
||||
return <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">{status}</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const total = productsData?.total || 0;
|
||||
const currentPage = productsData?.page || filters.page;
|
||||
const perPage = productsData?.per_page || filters.limit;
|
||||
|
|
@ -152,27 +88,15 @@ const ProductsListPage = () => {
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری محصولات</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
const createButton = (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||
|
|
@ -180,11 +104,19 @@ const ProductsListPage = () => {
|
|||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="مدیریت محصولات"
|
||||
subtitle="مدیریت محصولات، قیمتها و موجودی"
|
||||
icon={Package}
|
||||
actions={createButton}
|
||||
/>
|
||||
|
||||
<FiltersSection isLoading={isLoading} columns={4}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
جستجو
|
||||
|
|
@ -194,7 +126,7 @@ const ProductsListPage = () => {
|
|||
placeholder="جستجو در نام محصول..."
|
||||
value={filters.search}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -204,7 +136,7 @@ const ProductsListPage = () => {
|
|||
<select
|
||||
value={filters.category_id}
|
||||
onChange={handleCategoryChange}
|
||||
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-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
>
|
||||
<option value="">همه دستهبندیها</option>
|
||||
{(categories || []).map((category) => (
|
||||
|
|
@ -221,7 +153,7 @@ const ProductsListPage = () => {
|
|||
<select
|
||||
value={filters.status}
|
||||
onChange={handleStatusChange}
|
||||
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-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
>
|
||||
<option value="">همه وضعیتها</option>
|
||||
<option value="active">فعال</option>
|
||||
|
|
@ -243,7 +175,7 @@ const ProductsListPage = () => {
|
|||
const converted = persianToEnglish(e.target.value);
|
||||
setFilters(prev => ({ ...prev, min_price: converted, page: 1 }));
|
||||
}}
|
||||
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-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -254,16 +186,29 @@ const ProductsListPage = () => {
|
|||
const converted = persianToEnglish(e.target.value);
|
||||
setFilters(prev => ({ ...prev, max_price: converted, page: 1 }));
|
||||
}}
|
||||
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-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FiltersSection>
|
||||
|
||||
{/* Products Table */}
|
||||
{isLoading ? (
|
||||
<ProductsTableSkeleton />
|
||||
<TableSkeleton columns={5} rows={5} />
|
||||
) : products.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<EmptyState
|
||||
icon={Package}
|
||||
title="محصولی موجود نیست"
|
||||
description="برای شروع، اولین محصول خود را ایجاد کنید."
|
||||
actionLabel={
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
ایجاد محصول جدید
|
||||
</>
|
||||
}
|
||||
onAction={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Desktop Table */}
|
||||
|
|
@ -326,32 +271,14 @@ const ProductsListPage = () => {
|
|||
{product.category?.name || 'بدون دستهبندی'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(product.status || '')}
|
||||
<StatusBadge status={product.status || ''} type="product" />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleView(product.id)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title="مشاهده"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(product.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteProductId(product.id.toString())}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ActionButtons
|
||||
onView={() => handleView(product.id)}
|
||||
onEdit={() => handleEdit(product.id)}
|
||||
onDelete={() => setDeleteProductId(product.id.toString())}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -386,7 +313,7 @@ const ProductsListPage = () => {
|
|||
{formatPrice(product.price || 0)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{getStatusBadge(product.status || '')}
|
||||
<StatusBadge status={product.status || ''} type="product" size="sm" />
|
||||
{product.category && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{product.category.name}
|
||||
|
|
@ -395,51 +322,16 @@ const ProductsListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleView(product.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
مشاهده
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(product.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteProductId(product.id.toString())}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
<ActionButtons
|
||||
onView={() => handleView(product.id)}
|
||||
onEdit={() => handleEdit(product.id)}
|
||||
onDelete={() => setDeleteProductId(product.id.toString())}
|
||||
showLabels={true}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{(!products || products.length === 0) && !isLoading && (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
محصولی موجود نیست
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
برای شروع، اولین محصول خود را ایجاد کنید.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2 mx-auto">
|
||||
<Plus className="h-4 w-4" />
|
||||
ایجاد محصول جدید
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -452,35 +344,16 @@ const ProductsListPage = () => {
|
|||
onPageChange={(page) => setFilters(prev => ({ ...prev, page }))}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
<DeleteConfirmModal
|
||||
isOpen={!!deleteProductId}
|
||||
onClose={() => setDeleteProductId(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف محصول"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
آیا از حذف این محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام اطلاعات مربوط به محصول از جمله نسخهها و تصاویر حذف خواهد شد.
|
||||
</p>
|
||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeleteProductId(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
loading={isDeleting}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
message="آیا از حذف این محصول اطمینان دارید؟ این عمل قابل بازگشت نیست و تمام اطلاعات مربوط به محصول از جمله نسخهها و تصاویر حذف خواهد شد."
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
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;
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,295 @@
|
|||
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;
|
||||
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
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;
|
||||
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
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,
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
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;
|
||||
};
|
||||
|
|
@ -0,0 +1,433 @@
|
|||
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;
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
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;
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,359 @@
|
|||
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;
|
||||
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, Shield, Users, Key, Edit, Calendar, FileText } from 'lucide-react';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { useRole } from '../core/_hooks';
|
||||
import { PermissionWrapper } from '../../../components/common/PermissionWrapper';
|
||||
import { PageContainer, PageTitle, SectionTitle, SectionSubtitle, BodyText } from '../../../components/ui/Typography';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
|
||||
const RoleDetailPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -12,12 +12,55 @@ const RoleDetailPage = () => {
|
|||
|
||||
const { data: role, isLoading, error } = useRole(id);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (isLoading) {
|
||||
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 (!role) return <div>نقش یافت نشد</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageContainer>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -125,7 +168,7 @@ const RoleDetailPage = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{new Date(role.created_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(role.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -137,7 +180,7 @@ const RoleDetailPage = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{new Date(role.updated_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(role.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -170,7 +213,7 @@ const RoleDetailPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { useRole, useCreateRole, useUpdateRole } from '../core/_hooks';
|
|||
import { RoleFormData } from '../core/_models';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { ArrowRight, Shield } from "lucide-react";
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
||||
|
|
@ -70,7 +69,21 @@ const RoleFormPage = () => {
|
|||
};
|
||||
|
||||
if (isEdit && roleLoading) {
|
||||
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(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;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import { useState } from "react";
|
|||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useRole, useRolePermissions, usePermissions, useAssignPermission, useRemovePermission } from "../core/_hooks";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { Permission } from "@/types/auth";
|
||||
import { ArrowRight, Plus, Trash2, Check, Shield } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer } from "@/components/ui/Typography";
|
||||
|
||||
const RolePermissionsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -56,11 +56,33 @@ const RolePermissionsPage = () => {
|
|||
|
||||
const isLoading = roleLoading || permissionsLoading;
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
if (isLoading) {
|
||||
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>;
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -93,8 +115,10 @@ const RolePermissionsPage = () => {
|
|||
|
||||
<div className="p-6">
|
||||
{permissionsLoading ? (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner />
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -143,8 +167,10 @@ const RolePermissionsPage = () => {
|
|||
|
||||
<div className="p-6">
|
||||
{allPermissionsLoading ? (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner />
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
|
@ -214,7 +240,7 @@ const RolePermissionsPage = () => {
|
|||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,79 +3,15 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { useRoles, useDeleteRole } from '../core/_hooks';
|
||||
import { Role } from '@/types/auth';
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
import { Trash2, Edit3, Plus, UserCog, Shield, Eye, Settings } from "lucide-react";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { PageContainer, PageTitle, SectionSubtitle } from '../../../components/ui/Typography';
|
||||
|
||||
// Skeleton Loading Component
|
||||
const RolesTableSkeleton = () => (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Desktop Table Skeleton */}
|
||||
<div className="hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
نام نقش
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
توضیحات
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
تاریخ ایجاد
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
عملیات
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<tr key={index} className="animate-pulse">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-32"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-48"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-20"></div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards Skeleton */}
|
||||
<div className="md:hidden p-4 space-y-4">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div key={index} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
|
||||
<div className="space-y-3">
|
||||
<div className="h-5 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-full"></div>
|
||||
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-1/3"></div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||
<div className="h-8 w-8 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
import { Plus, Shield, Eye, Settings } from "lucide-react";
|
||||
import { PageContainer } from '../../../components/ui/Typography';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { FiltersSection } from '@/components/common/FiltersSection';
|
||||
import { TableSkeleton } from '@/components/common/TableSkeleton';
|
||||
import { EmptyState } from '@/components/common/EmptyState';
|
||||
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
|
||||
import { ActionButtons } from '@/components/common/ActionButtons';
|
||||
import { formatDate } from '@/utils/formatters';
|
||||
|
||||
const RolesListPage = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -135,18 +71,7 @@ const RolesListPage = () => {
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<UserCog className="h-6 w-6" />
|
||||
<PageTitle>مدیریت نقشها</PageTitle>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">مدیریت نقشها و دسترسیهای سیستم</p>
|
||||
</div>
|
||||
|
||||
const createButton = (
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||
|
|
@ -154,11 +79,18 @@ const RolesListPage = () => {
|
|||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="مدیریت نقشها"
|
||||
subtitle="مدیریت نقشها و دسترسیهای سیستم"
|
||||
icon={Shield}
|
||||
actions={createButton}
|
||||
/>
|
||||
|
||||
<FiltersSection isLoading={isLoading} columns={2}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
جستجو
|
||||
|
|
@ -171,30 +103,26 @@ const RolesListPage = () => {
|
|||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FiltersSection>
|
||||
|
||||
{/* Roles Table */}
|
||||
{isLoading ? (
|
||||
<RolesTableSkeleton />
|
||||
<TableSkeleton columns={4} rows={5} />
|
||||
) : (roles || []).length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div className="text-center py-12">
|
||||
<UserCog className="h-12 w-12 text-gray-400 dark:text-gray-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
هیچ نقش یافت نشد
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{filters.search
|
||||
<EmptyState
|
||||
icon={Shield}
|
||||
title="هیچ نقش یافت نشد"
|
||||
description={filters.search
|
||||
? "نتیجهای برای جستجوی شما یافت نشد"
|
||||
: "شما هنوز هیچ نقش ایجاد نکردهاید"
|
||||
}
|
||||
</p>
|
||||
<Button onClick={handleCreate} className="flex items-center gap-2">
|
||||
: "شما هنوز هیچ نقش ایجاد نکردهاید"}
|
||||
actionLabel={
|
||||
<>
|
||||
<Plus className="h-4 w-4 ml-2" />
|
||||
اولین نقش را ایجاد کنید
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
onAction={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
|
|
@ -228,24 +156,15 @@ const RolesListPage = () => {
|
|||
{role.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{new Date(role.created_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(role.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleView(role.id)}
|
||||
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
title="مشاهده"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(role.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<ActionButtons
|
||||
onView={() => handleView(role.id)}
|
||||
onEdit={() => handleEdit(role.id)}
|
||||
onDelete={() => setDeleteRoleId(role.id.toString())}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handlePermissions(role.id)}
|
||||
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||
|
|
@ -253,13 +172,6 @@ const RolesListPage = () => {
|
|||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteRoleId(role.id.toString())}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -284,23 +196,16 @@ const RolesListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
تاریخ ایجاد: {new Date(role.created_at).toLocaleDateString('fa-IR')}
|
||||
تاریخ ایجاد: {formatDate(role.created_at)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleView(role.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
مشاهده
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(role.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
ویرایش
|
||||
</button>
|
||||
<ActionButtons
|
||||
onView={() => handleView(role.id)}
|
||||
onEdit={() => handleEdit(role.id)}
|
||||
onDelete={() => setDeleteRoleId(role.id.toString())}
|
||||
showLabels={true}
|
||||
size="sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handlePermissions(role.id)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"
|
||||
|
|
@ -308,13 +213,6 @@ const RolesListPage = () => {
|
|||
<Settings className="h-3 w-3" />
|
||||
دسترسیها
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteRoleId(role.id.toString())}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -322,31 +220,14 @@ const RolesListPage = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
<DeleteConfirmModal
|
||||
isOpen={!!deleteRoleId}
|
||||
onClose={cancelDelete}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="تأیید حذف"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
آیا از حذف این نقش اطمینان دارید؟ این عمل قابل بازگشت نیست.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={cancelDelete}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
loading={isDeleting}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
message="آیا از حذف این نقش اطمینان دارید؟ این عمل قابل بازگشت نیست."
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface ShippingMethod {
|
|||
time_note?: string;
|
||||
open_hours: ShippingOpenHour[];
|
||||
addresses: string[];
|
||||
needs_address: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import { useCreateShippingMethod, useShippingMethod, useUpdateShippingMethod } f
|
|||
import { ShippingOpenHour } from '../core/_models';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { TagInput } from '@/components/ui/TagInput';
|
||||
import { PageContainer } from '@/components/ui/Typography';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Truck } from 'lucide-react';
|
||||
import { formatWithThousands, parseFormattedNumber } from '@/utils/numberUtils';
|
||||
|
||||
|
|
@ -34,6 +35,7 @@ const ShippingMethodFormPage = () => {
|
|||
},
|
||||
],
|
||||
addresses: [] as string[],
|
||||
needs_address: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -60,6 +62,7 @@ const ShippingMethodFormPage = () => {
|
|||
},
|
||||
],
|
||||
addresses: data.addresses || [],
|
||||
needs_address: data.needs_address ?? false,
|
||||
});
|
||||
}
|
||||
}, [isEdit, data]);
|
||||
|
|
@ -94,6 +97,7 @@ const ShippingMethodFormPage = () => {
|
|||
!Number.isNaN(item.to_hour)
|
||||
),
|
||||
addresses: form.addresses,
|
||||
needs_address: form.needs_address,
|
||||
};
|
||||
if (isEdit && id) {
|
||||
update({ id: Number(id), ...payload }, { onSuccess: () => navigate('/shipping-methods') });
|
||||
|
|
@ -104,20 +108,28 @@ const ShippingMethodFormPage = () => {
|
|||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<div className="min-h-[200px] flex items-center justify-center">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Truck className="h-6 w-6" />
|
||||
{isEdit ? 'ویرایش روش ارسال' : 'ایجاد روش ارسال'}
|
||||
</h1>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={isEdit ? 'ویرایش روش ارسال' : 'ایجاد روش ارسال'}
|
||||
icon={Truck}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
|
@ -243,6 +255,15 @@ const ShippingMethodFormPage = () => {
|
|||
فعال
|
||||
</label>
|
||||
</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 className="flex justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import React, { useState } from 'react';
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { Plus, Edit3, Trash2, Truck } from 'lucide-react';
|
||||
import { PageContainer } from '@/components/ui/Typography';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Plus, Edit3, Trash2, Truck, BarChart3 } from 'lucide-react';
|
||||
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
|
||||
import { ShippingMethod } from '../core/_models';
|
||||
|
||||
|
|
@ -26,34 +27,70 @@ const ShippingMethodsListPage = () => {
|
|||
deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) });
|
||||
};
|
||||
|
||||
const handleViewReport = (method: ShippingMethod) => {
|
||||
navigate(`/shipping-methods/shipments-report?shipping_method_id=${method.id}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 flex justify-center">
|
||||
<LoadingSpinner />
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<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 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) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در بارگذاری روشهای ارسال</p>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<Truck className="h-6 w-6" />
|
||||
مدیریت روشهای ارسال
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">تعریف و مدیریت روشهای ارسال سفارش</p>
|
||||
</div>
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="مدیریت روشهای ارسال"
|
||||
subtitle="تعریف و مدیریت روشهای ارسال سفارش"
|
||||
icon={Truck}
|
||||
actions={
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||
|
|
@ -61,7 +98,8 @@ const ShippingMethodsListPage = () => {
|
|||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="hidden md:block">
|
||||
|
|
@ -91,6 +129,13 @@ const ShippingMethodsListPage = () => {
|
|||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<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="ویرایش">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -119,6 +164,13 @@ 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-3">اولویت: {m.priority}</div>
|
||||
<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">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
ویرایش
|
||||
|
|
@ -143,6 +195,7 @@ const ShippingMethodsListPage = () => {
|
|||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import {
|
|||
TicketStatus,
|
||||
TicketSubject,
|
||||
} from "../core/_models";
|
||||
import { PageContainer, PageTitle, SectionTitle } from "@/components/ui/Typography";
|
||||
import { PageContainer, SectionTitle } from "@/components/ui/Typography";
|
||||
import { PageHeader } from "@/components/layout/PageHeader";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Table } from "@/components/ui/Table";
|
||||
|
|
@ -351,7 +352,7 @@ const TicketConfigPage = () => {
|
|||
is_active: e.target.value,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
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="true">فعال</option>
|
||||
<option value="false">غیرفعال</option>
|
||||
|
|
@ -435,7 +436,7 @@ const TicketConfigPage = () => {
|
|||
is_active: e.target.value,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
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="true">فعال</option>
|
||||
<option value="false">غیرفعال</option>
|
||||
|
|
@ -492,7 +493,7 @@ const TicketConfigPage = () => {
|
|||
department_id: e.target.value,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
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>
|
||||
{departments?.map((department) => (
|
||||
|
|
@ -539,7 +540,7 @@ const TicketConfigPage = () => {
|
|||
is_active: e.target.value,
|
||||
}))
|
||||
}
|
||||
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"
|
||||
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="true">فعال</option>
|
||||
<option value="false">غیرفعال</option>
|
||||
|
|
@ -578,12 +579,10 @@ const TicketConfigPage = () => {
|
|||
|
||||
return (
|
||||
<PageContainer className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<Settings className="h-6 w-6" />
|
||||
تنظیمات تیکت
|
||||
</PageTitle>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="تنظیمات تیکت"
|
||||
icon={Settings}
|
||||
/>
|
||||
|
||||
<div className="card p-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
import { TicketStatus } from "../core/_models";
|
||||
import { PageContainer, PageTitle, SectionTitle, Label } from "@/components/ui/Typography";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { FileUploader } from "@/components/ui/FileUploader";
|
||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
|
|
@ -155,9 +154,19 @@ const TicketDetailPage = () => {
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<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(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>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -218,7 +227,7 @@ const TicketDetailPage = () => {
|
|||
onChange={(e) =>
|
||||
setStatusId(e.target.value ? Number(e.target.value) : undefined)
|
||||
}
|
||||
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"
|
||||
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>
|
||||
{statuses?.map((status) => (
|
||||
|
|
|
|||
|
|
@ -132,7 +132,9 @@ const TicketsListPage = () => {
|
|||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-6 w-6" />
|
||||
|
|
@ -154,15 +156,63 @@ const TicketsListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 space-y-4">
|
||||
{/* Filters */}
|
||||
<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="relative">
|
||||
<Search className="absolute right-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
جستجو
|
||||
</label>
|
||||
<Search className="absolute right-3 top-10 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
value={filters.search || ""}
|
||||
onChange={(e) => handleFilterChange("search", e.target.value)}
|
||||
placeholder="جستجو در عنوان یا شماره تیکت"
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
|
|
@ -178,9 +228,9 @@ const TicketsListPage = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
وضعیت
|
||||
</label>
|
||||
<select
|
||||
|
|
@ -191,7 +241,7 @@ const TicketsListPage = () => {
|
|||
e.target.value ? Number(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
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"
|
||||
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>
|
||||
{statuses?.map((status) => (
|
||||
|
|
@ -202,7 +252,7 @@ const TicketsListPage = () => {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
دپارتمان
|
||||
</label>
|
||||
<select
|
||||
|
|
@ -213,7 +263,7 @@ const TicketsListPage = () => {
|
|||
e.target.value ? Number(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
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"
|
||||
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>
|
||||
{departments?.map((department) => (
|
||||
|
|
@ -223,25 +273,13 @@ const TicketsListPage = () => {
|
|||
))}
|
||||
</select>
|
||||
</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>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<Table columns={columns} data={[]} loading />
|
||||
) : !data?.tickets || data.tickets.length === 0 ? (
|
||||
|
|
@ -251,6 +289,7 @@ const TicketsListPage = () => {
|
|||
) : (
|
||||
<>
|
||||
<Table columns={columns} data={data.tickets as any[]} />
|
||||
{data.total > 0 && (
|
||||
<Pagination
|
||||
currentPage={filters.page || 1}
|
||||
totalPages={Math.max(
|
||||
|
|
@ -261,8 +300,10 @@ const TicketsListPage = () => {
|
|||
itemsPerPage={filters.limit || 20}
|
||||
totalItems={data.total || 0}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 { useUser, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
|
||||
import { englishToPersian } from '../../../utils/numberUtils';
|
||||
import { formatDate } from '../../../utils/formatters';
|
||||
import { PageContainer } from '../../../components/ui/Typography';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Modal } from '../../../components/ui/Modal';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
|
||||
const UserAdminDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
|
@ -56,8 +56,40 @@ const UserAdminDetailPage: React.FC = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<LoadingSpinner />
|
||||
<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>
|
||||
);
|
||||
|
|
@ -228,7 +260,7 @@ const UserAdminDetailPage: React.FC = () => {
|
|||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">تاریخ ثبتنام</p>
|
||||
<p className="text-gray-900 dark:text-gray-100">
|
||||
{new Date(user.created_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(user.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -240,7 +272,7 @@ const UserAdminDetailPage: React.FC = () => {
|
|||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">آخرین بهروزرسانی</p>
|
||||
<p className="text-gray-900 dark:text-gray-100">
|
||||
{new Date(user.updated_at).toLocaleDateString('fa-IR')}
|
||||
{formatDate(user.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import { User, ArrowLeft, Save, UserPlus } from 'lucide-react';
|
|||
import { useUser, useCreateUser, useUpdateUser } from '../core/_hooks';
|
||||
import { CreateUserRequest, UpdateUserRequest } from '../core/_models';
|
||||
import { PageContainer } from '../../../components/ui/Typography';
|
||||
import { PageHeader } from '../../../components/layout/PageHeader';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Input } from '../../../components/ui/Input';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
|
||||
// Validation schema
|
||||
const createUserSchema = yup.object({
|
||||
|
|
@ -139,8 +139,16 @@ const UserAdminFormPage: React.FC = () => {
|
|||
if (isEdit && userLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<LoadingSpinner />
|
||||
<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-32 mb-2"></div>
|
||||
<div className="h-10 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
|
|
@ -160,15 +168,11 @@ const UserAdminFormPage: React.FC = () => {
|
|||
<ArrowLeft className="h-4 w-4" />
|
||||
بازگشت
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
{isEdit ? <User className="h-6 w-6" /> : <UserPlus className="h-6 w-6" />}
|
||||
{isEdit ? 'ویرایش کاربر' : 'ایجاد کاربر جدید'}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{isEdit ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید به سیستم'}
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={isEdit ? 'ویرایش کاربر' : 'ایجاد کاربر جدید'}
|
||||
subtitle={isEdit ? 'ویرایش اطلاعات کاربر' : 'افزودن کاربر جدید به سیستم'}
|
||||
icon={isEdit ? User : UserPlus}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Users, Plus, Search, Filter, UserCheck, UserX, Edit, Trash2, Eye, User as UserIcon } from 'lucide-react';
|
||||
import { Users, Plus, Search, Filter, UserCheck, UserX, Eye, Edit, Trash2, User as UserIcon } from 'lucide-react';
|
||||
import { useSearchUsers, useUserStats, useVerifyUser, useUnverifyUser, useDeleteUser } from '../core/_hooks';
|
||||
import { User, UserFilters } from '../core/_models';
|
||||
import { PageContainer } from '../../../components/ui/Typography';
|
||||
|
|
@ -12,6 +12,10 @@ import { StatsCard } from '../../../components/dashboard/StatsCard';
|
|||
import { Table } from '../../../components/ui/Table';
|
||||
import { TableColumn } from '../../../types';
|
||||
import { englishToPersian, persianToEnglish } from '../../../utils/numberUtils';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { FiltersSection } from '@/components/common/FiltersSection';
|
||||
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
|
||||
import { StatusBadge } from '@/components/ui/StatusBadge';
|
||||
|
||||
const UsersAdminListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -163,14 +167,7 @@ const UsersAdminListPage: React.FC = () => {
|
|||
key: 'verified',
|
||||
label: 'وضعیت',
|
||||
align: 'center',
|
||||
render: (v: boolean) => (
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${v
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}>
|
||||
{v ? 'تأیید شده' : 'تأیید نشده'}
|
||||
</span>
|
||||
)
|
||||
render: (v: boolean) => <StatusBadge status={v} type="user" />
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
|
|
@ -231,17 +228,11 @@ const UsersAdminListPage: React.FC = () => {
|
|||
return (
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Users className="h-6 w-6" />
|
||||
مدیریت کاربران
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">مشاهده و مدیریت کاربران سیستم</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<PageHeader
|
||||
title="مدیریت کاربران"
|
||||
subtitle="مشاهده و مدیریت کاربران سیستم"
|
||||
icon={Users}
|
||||
/>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
|
|
@ -273,9 +264,7 @@ const UsersAdminListPage: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FiltersSection isLoading={false} columns={3}>
|
||||
<Input
|
||||
placeholder="جستجو بر اساس نام، شماره تلفن یا ایمیل..."
|
||||
value={searchTerm}
|
||||
|
|
@ -286,7 +275,7 @@ const UsersAdminListPage: React.FC = () => {
|
|||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value as any)}
|
||||
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"
|
||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
data-testid="status-filter-select"
|
||||
>
|
||||
<option value="all">همه کاربران</option>
|
||||
|
|
@ -312,8 +301,7 @@ const UsersAdminListPage: React.FC = () => {
|
|||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FiltersSection>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
|
|
@ -346,36 +334,15 @@ const UsersAdminListPage: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
<DeleteConfirmModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, user: null })}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف کاربر"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
آیا از حذف کاربر "{deleteModal.user?.first_name} {deleteModal.user?.last_name}" اطمینان دارید؟
|
||||
</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
این عمل غیرقابل بازگشت است.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setDeleteModal({ isOpen: false, user: null })}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
loading={deleteUserMutation.isPending}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
message={`آیا از حذف کاربر "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}" اطمینان دارید؟`}
|
||||
warningMessage="این عمل غیرقابل بازگشت است."
|
||||
isLoading={deleteUserMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Verify/Unverify Confirmation Modal */}
|
||||
<Modal
|
||||
|
|
|
|||
|
|
@ -32,3 +32,5 @@ export const useUpdateWalletStatus = () => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -28,3 +28,5 @@ export const WALLET_LABELS: Record<WalletType, string> = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,3 +23,5 @@ export const updateWalletStatus = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,55 +1,12 @@
|
|||
import React from 'react';
|
||||
import { Wallet, Loader2 } from 'lucide-react';
|
||||
import { PageContainer, PageTitle } from '@/components/ui/Typography';
|
||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||
import { PageContainer } from '@/components/ui/Typography';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||
import { formatDateTime } from '@/utils/formatters';
|
||||
import { useWalletStatus, useUpdateWalletStatus } from '../core/_hooks';
|
||||
import { WalletStatus, WALLET_LABELS } from '../core/_models';
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const ToggleSwitch = ({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<label className="flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
checked
|
||||
? 'bg-primary-600'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 left-0.5 bg-white rounded-full h-5 w-5 transition-transform ${
|
||||
checked ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const WalletListPage = () => {
|
||||
const { data, isLoading, error } = useWalletStatus();
|
||||
const { mutate: updateStatus, isPending } = useUpdateWalletStatus();
|
||||
|
|
@ -64,8 +21,32 @@ const WalletListPage = () => {
|
|||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<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-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>
|
||||
</PageContainer>
|
||||
);
|
||||
|
|
@ -87,17 +68,11 @@ const WalletListPage = () => {
|
|||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div>
|
||||
<PageTitle className="flex items-center gap-2">
|
||||
<Wallet className="h-6 w-6" />
|
||||
مدیریت کیف پول
|
||||
</PageTitle>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
فعال یا غیرفعال کردن کیفهای پول
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="مدیریت کیف پول"
|
||||
subtitle="فعال یا غیرفعال کردن کیفهای پول"
|
||||
icon={Wallet}
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="p-6">
|
||||
|
|
@ -123,7 +98,7 @@ const WalletListPage = () => {
|
|||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
آخرین بهروزرسانی: {formatDate(wallet.updated_at)}
|
||||
آخرین بهروزرسانی: {formatDateTime(wallet.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
|
@ -151,3 +126,5 @@ export default WalletListPage;
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* 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
Loading…
Reference in New Issue