This commit is contained in:
hosseintaromi 2026-01-08 17:10:26 +03:30
parent 50c6806c3a
commit ef76defb28
50 changed files with 2926 additions and 96 deletions

View File

@ -79,6 +79,15 @@ const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormP
// Wallet Page
const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage'));
// 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: any }) => {
const { user, isLoading } = useAuth();
@ -167,6 +176,7 @@ const AppRoutes = () => {
<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 +186,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>
);

View File

@ -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,43 +37,52 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
{title}
</CardTitle>
)}
<div className="w-full">
<ResponsiveContainer width="100%" height={280} minHeight={220}>
<RechartsPieChart>
<Pie
data={data}
cx="50%"
cy="45%"
labelLine={false}
// Remove the overlapping labels
label={false}
outerRadius="65%"
fill="#8884d8"
dataKey="value"
>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: 'var(--toast-bg)',
color: 'var(--toast-color)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
fontSize: '14px',
}}
formatter={(value, name) => [`${value}`, name]}
/>
<Legend
content={<CustomLegend />}
wrapperStyle={{
paddingTop: '10px'
}}
/>
</RechartsPieChart>
</ResponsiveContainer>
<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="50%"
labelLine={false}
label={false}
outerRadius="75%"
innerRadius="35%"
fill="#8884d8"
dataKey="value"
stroke="#fff"
strokeWidth={3}
>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
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: any, name: any) => [`${Math.round(value)}%`, name]}
/>
</RechartsPieChart>
</ResponsiveContainer>
</div>
</div>
</div>
);

View File

@ -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';
@ -91,6 +94,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',
},
]
},
{

View File

@ -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,23 +47,38 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
return (
<div className="space-y-1">
{label && <Label>{label}</Label>}
<DatePicker
value={selected}
onChange={(val) => onChange(toIsoLike(val as DateObject | null))}
format="YYYY/MM/DD HH:mm"
calendar={persian}
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'}`}
containerClassName="w-full"
placeholder={placeholder || 'تاریخ و ساعت'}
editable={false}
plugins={[<TimePicker key="time" position="bottom" />]}
disableMonthPicker={false}
disableYearPicker={false}
showOtherDays
/>
<div className="relative">
<DatePicker
value={selected}
onChange={(val) => onChange(toIsoLike(val as DateObject | null))}
format="YYYY/MM/DD HH:mm"
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-center"
disableDayPicker={false}
inputClass={`w-full border rounded-lg px-3 py-3 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`}
containerClassName="w-full"
placeholder={placeholder || 'تاریخ و ساعت'}
editable={false}
plugins={[<TimePicker key="time" position="bottom" />]}
disableMonthPicker={false}
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>
)}

View File

@ -95,14 +95,13 @@ 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'}
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
dark:text-gray-100
`}
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 placeholder-gray-500 dark:placeholder-gray-400
`}
onClick={handleToggleDropdown}
>
<div className="flex flex-wrap gap-1 items-center">

View File

@ -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}
>

View File

@ -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>

View File

@ -145,4 +145,15 @@ export const API_ROUTES = {
// Wallet APIs
GET_WALLET_STATUS: "wallet/status",
UPDATE_WALLET_STATUS: "wallet/status",
// Reports APIs
DISCOUNT_USAGE_REPORT: "reports/discounts/usage",
CUSTOMER_DISCOUNT_USAGE_REPORT: "reports/discounts/customer-usage",
PAYMENT_METHODS_REPORT: "reports/payments/methods",
SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method",
// Product Comments APIs
GET_PRODUCT_COMMENTS: "products/comments",
UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`,
DELETE_COMMENT: (commentId: string) => `products/comments/${commentId}`,
};

View File

@ -228,7 +228,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>

View File

@ -178,7 +178,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>

View File

@ -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>

View File

@ -74,7 +74,10 @@ 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': 'حواله بانکی',
@ -567,7 +570,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>

View File

@ -331,7 +331,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 +360,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>
@ -500,7 +500,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>

View File

@ -33,3 +33,4 @@ export const useUpdatePaymentCard = () => {

View File

@ -22,3 +22,4 @@ export interface UpdatePaymentCardResponse {

View File

@ -24,3 +24,4 @@ export const updatePaymentCard = async (

View File

@ -37,3 +37,4 @@ export const useUpdateIPGStatus = () => {

View File

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

View File

@ -24,3 +24,4 @@ export const updateIPGStatus = async (

View File

@ -152,3 +152,4 @@ export default IPGListPage;

View File

@ -0,0 +1,358 @@
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';
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const 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) => formatDate(val),
},
{
key: 'actions',
label: 'عملیات',
align: 'center',
render: (_val, row) => (
<div className="flex items-center justify-center gap-2">
{row.comment_status === 'pending' && (
<>
<button
onClick={() => {
setStatusUpdateId(row.id);
setNewStatus('approved');
}}
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300 p-1"
title="تایید"
>
<CheckCircle className="h-4 w-4" />
</button>
<button
onClick={() => {
setStatusUpdateId(row.id);
setNewStatus('rejected');
}}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
title="رد"
>
<XCircle className="h-4 w-4" />
</button>
</>
)}
<button
onClick={() => setDeleteId(row.id)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
),
},
];
const tableData = (data?.comments || []).map(comment => ({
id: comment.id,
user_name: comment.user
? `${comment.user.first_name} ${comment.user.last_name}`.trim() || '-'
: '-',
product_id: formatWithThousands(comment.product_id),
rating: comment.rating,
subject: comment.subject || '-',
comment: comment.comment,
comment_status: comment.comment_status,
created_at: comment.created_at,
})) || [];
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
const totalPages = data ? Math.ceil(data.total / filters.limit) : 1;
return (
<PageContainer>
<PageTitle>مدیریت نظرات محصولات</PageTitle>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
<div className="flex items-center gap-2 mb-4">
<Filter className="h-5 w-5 text-gray-500" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وضعیت
</label>
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
>
<option value="">همه</option>
<option value="pending">در انتظار</option>
<option value="approved">تایید شده</option>
<option value="rejected">رد شده</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه محصول
</label>
<Input
value={filters.productId?.toString() || ''}
onChange={(e) => handleNumericFilterChange('productId', e.target.value)}
placeholder="مثلاً 123"
numeric
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه کاربر
</label>
<Input
value={filters.userId?.toString() || ''}
onChange={(e) => handleNumericFilterChange('userId', e.target.value)}
placeholder="مثلاً 456"
numeric
/>
</div>
</div>
</div>
{/* Table */}
{isLoading ? (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={[]} loading={true} />
</div>
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">خطا در دریافت دادهها</p>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<MessageSquare className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400">نظری یافت نشد</p>
</div>
)}
</>
)}
{/* Status Update Modal */}
<Modal
isOpen={statusUpdateId !== null}
onClose={() => setStatusUpdateId(null)}
title="تغییر وضعیت نظر"
>
<div className="space-y-4">
<p className="text-gray-700 dark:text-gray-300">
آیا میخواهید وضعیت این نظر را به{' '}
<span className="font-semibold">
{newStatus === 'approved' ? 'تایید شده' : 'رد شده'}
</span>{' '}
تغییر دهید؟
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => setStatusUpdateId(null)}
>
انصراف
</Button>
<Button
onClick={handleStatusUpdate}
loading={isUpdating}
variant={newStatus === 'approved' ? 'success' : 'danger'}
>
تایید
</Button>
</div>
</div>
</Modal>
{/* Delete Modal */}
<Modal
isOpen={deleteId !== null}
onClose={() => setDeleteId(null)}
title="حذف نظر"
>
<div className="space-y-4">
<p className="text-gray-700 dark:text-gray-300">
آیا از حذف این نظر اطمینان دارید؟ این عمل قابل بازگشت نیست.
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={() => setDeleteId(null)}
>
انصراف
</Button>
<Button
variant="danger"
onClick={handleDeleteConfirm}
loading={isDeleting}
>
حذف
</Button>
</div>
</div>
</Modal>
</PageContainer>
);
};
export default ProductCommentsListPage;

View File

@ -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("خطا در حذف نظر");
},
});
};

View File

@ -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;
}

View File

@ -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;
};

View File

@ -555,7 +555,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) => (

View File

@ -204,7 +204,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 +221,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>

View File

@ -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,
});
};

View File

@ -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;
}

View File

@ -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;
};

View File

@ -0,0 +1,354 @@
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';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const CustomerDiscountUsageSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-28 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
</div>
</div>
</div>
))}
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{[...Array(7)].map((_, i) => (
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i} className="animate-pulse">
{[...Array(7)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const CustomerDiscountUsagePage = () => {
const [filters, setFilters] = useState<CustomerDiscountUsageFilters>({
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: formatDate(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 ? (
<CustomerDiscountUsageSkeleton />
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">خطا در دریافت دادهها</p>
</div>
) : filters.user_id === 0 ? (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-yellow-600 dark:text-yellow-400">لطفاً شناسه کاربر را وارد کنید</p>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default CustomerDiscountUsagePage;

View File

@ -0,0 +1,385 @@
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';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const DiscountUsageReportSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
</div>
</div>
</div>
))}
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{[...Array(6)].map((_, i) => (
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i} className="animate-pulse">
{[...Array(6)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const DiscountUsageReportPage = () => {
const [filters, setFilters] = useState<DiscountUsageFilters>({
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: formatDate(usage.first_used_at),
last_used_at: formatDate(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 ? (
<DiscountUsageReportSkeleton />
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">خطا در دریافت دادهها</p>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
{data && data.total > 0 && (data.total > filters.limit || data.has_more) && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default DiscountUsageReportPage;

View File

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

View File

@ -0,0 +1,56 @@
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;
}

View File

@ -0,0 +1,17 @@
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
PaymentMethodsFilters,
PaymentMethodsResponse,
} from "./_models";
export const getPaymentMethodsReport = async (
filters: PaymentMethodsFilters
): Promise<PaymentMethodsResponse> => {
const response = await httpPostRequest<PaymentMethodsResponse>(
APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT),
filters
);
return response.data;
};

View File

@ -0,0 +1,525 @@
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';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const 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 PaymentMethodsReportSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
</div>
</div>
))}
</div>
{/* Pie Chart and Total Amount Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<div className="lg:col-span-2 bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 animate-pulse">
<div className="h-16 w-16 bg-gray-200 dark:bg-gray-700 rounded-full mx-auto mb-4"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mx-auto mb-2"></div>
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-40 mx-auto"></div>
</div>
</div>
{/* Payment Type Cards Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-6"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="border-2 border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-700/50">
<div className="h-5 bg-gray-200 dark:bg-gray-600 rounded w-32 mb-4"></div>
<div className="space-y-2.5">
{[...Array(5)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-16"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-12"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{[...Array(10)].map((_, i) => (
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i} className="animate-pulse">
{[...Array(10)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const PaymentMethodsReportPage = () => {
const [filters, setFilters] = useState<PaymentMethodsFilters>({
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: formatDate(method.first_used_at),
last_used_at: formatDate(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 ? (
<PaymentMethodsReportSkeleton />
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">خطا در دریافت دادهها</p>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="overflow-x-auto">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default PaymentMethodsReportPage;

View File

@ -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,
});
};

View File

@ -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;
}

View File

@ -0,0 +1,17 @@
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
import { API_ROUTES } from "@/constant/routes";
import {
ShipmentsByMethodFilters,
ShipmentsByMethodResponse,
} from "./_models";
export const getShipmentsByMethodReport = async (
filters: ShipmentsByMethodFilters
): Promise<ShipmentsByMethodResponse> => {
const response = await httpPostRequest<ShipmentsByMethodResponse>(
APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT),
filters
);
return response.data;
};

View File

@ -0,0 +1,593 @@
import React, { useState } from 'react';
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';
const formatCurrency = (amount: number) => {
return formatWithThousands(amount) + ' تومان';
};
const formatDate = (dateString: string) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatWeight = (weight: number) => {
return formatWithThousands(weight) + ' گرم';
};
const ShipmentsByMethodReportSkeleton = () => (
<>
{/* Summary Cards Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-200 dark:bg-gray-700 rounded-lg w-12 h-12"></div>
<div className="flex-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
</div>
</div>
))}
</div>
{/* Method Summaries Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6 animate-pulse">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-48 mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-32 mb-2"></div>
<div className="space-y-1">
{[...Array(6)].map((_, j) => (
<div key={j} className="flex justify-between">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Table Skeleton */}
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
{[...Array(9)].map((_, i) => (
<th key={i} className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-20"></div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{[...Array(5)].map((_, i) => (
<tr key={i} className="animate-pulse">
{[...Array(9)].map((_, j) => (
<td key={j} className="px-6 py-4 whitespace-nowrap">
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded"></div>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
const ShipmentsByMethodReportPage = () => {
const [filters, setFilters] = useState<ShipmentsByMethodFilters>({
limit: 50,
offset: 0,
group_by_method: false,
});
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: formatDate(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>
{/* 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>
<select
value={filters.shipping_method_code || ''}
onChange={(e) => handleFilterChange('shipping_method_code', 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="express">پیک (اکسپرس)</option>
<option value="standard">پست (معمولی)</option>
<option value="pickup">تحویل حضوری</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
شناسه روش ارسال
</label>
<Input
value={filters.shipping_method_id?.toString() || ''}
onChange={(e) => handleNumericFilterChange('shipping_method_id', e.target.value)}
placeholder="مثلاً 1"
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>
<Input
value={filters.customer_name || ''}
onChange={(e) => handleFilterChange('customer_name', e.target.value || undefined)}
placeholder="جستجو در نام"
/>
</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="confirmed">تایید شده</option>
<option value="processing">در حال پردازش</option>
<option value="shipped">ارسال شده</option>
<option value="delivered">تحویل داده شده</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>
<select
value={filters.payment_status || ''}
onChange={(e) => handleFilterChange('payment_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>
<Input
value={filters.min_shipping_cost?.toString() || ''}
onChange={(e) => handleNumericFilterChange('min_shipping_cost', e.target.value)}
placeholder="مثلاً 10000"
numeric
thousandSeparator
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حداکثر هزینه ارسال (ریال)
</label>
<Input
value={filters.max_shipping_cost?.toString() || ''}
onChange={(e) => handleNumericFilterChange('max_shipping_cost', e.target.value)}
placeholder="مثلاً 50000"
numeric
thousandSeparator
/>
</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_method || false}
onChange={(e) => handleFilterChange('group_by_method', 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">
<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 ? (
<ShipmentsByMethodReportSkeleton />
) : error ? (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p className="text-red-600 dark:text-red-400">خطا در دریافت دادهها</p>
</div>
) : (
<>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<Table columns={columns} data={tableData} loading={isLoading} />
</div>
{data && data.total > 0 && totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
itemsPerPage={filters.limit}
totalItems={data.total}
/>
</div>
)}
{data && data.total === 0 && (
<div className="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-8 text-center">
<p className="text-gray-600 dark:text-gray-400">دادهای یافت نشد</p>
</div>
)}
</>
)}
</PageContainer>
);
};
export default ShipmentsByMethodReportPage;

View File

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

View File

@ -34,6 +34,7 @@ const ShippingMethodFormPage = () => {
},
],
addresses: [] as string[],
needs_address: false,
});
useEffect(() => {
@ -60,6 +61,7 @@ const ShippingMethodFormPage = () => {
},
],
addresses: data.addresses || [],
needs_address: data.needs_address ?? false,
});
}
}, [isEdit, data]);
@ -94,6 +96,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') });
@ -243,6 +246,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">

View File

@ -351,7 +351,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 +435,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 +492,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 +539,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>

View File

@ -218,7 +218,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) => (

View File

@ -191,7 +191,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) => (
@ -213,7 +213,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) => (

View File

@ -286,7 +286,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>

View File

@ -33,3 +33,4 @@ export const useUpdateWalletStatus = () => {

View File

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

View File

@ -24,3 +24,4 @@ export const updateWalletStatus = async (

View File

@ -152,3 +152,4 @@ export default WalletListPage;

View File

@ -122,4 +122,19 @@ export const QUERY_KEYS = {
// Wallet
GET_WALLET_STATUS: "get_wallet_status",
UPDATE_WALLET_STATUS: "update_wallet_status",
// Discount Statistics
GET_DISCOUNT_USAGE_REPORT: "get_discount_usage_report",
GET_CUSTOMER_DISCOUNT_USAGE_REPORT: "get_customer_discount_usage_report",
// Payment Statistics
GET_PAYMENT_METHODS_REPORT: "get_payment_methods_report",
// Shipment Statistics
GET_SHIPMENTS_BY_METHOD_REPORT: "get_shipments_by_method_report",
// Product Comments
GET_PRODUCT_COMMENTS: "get_product_comments",
UPDATE_COMMENT_STATUS: "update_comment_status",
DELETE_COMMENT: "delete_comment",
};