fix
This commit is contained in:
parent
50c6806c3a
commit
ef76defb28
16
src/App.tsx
16
src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,19 +10,19 @@ interface PieChartProps {
|
|||
const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
|
||||
export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => {
|
||||
// Custom legend component for better mobile experience
|
||||
// Custom legend component for left side
|
||||
const CustomLegend = (props: any) => {
|
||||
const { payload } = props;
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-1 text-xs sm:text-sm">
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
className="w-3 h-3 rounded-full flex-shrink-0 border border-white dark:border-gray-800"
|
||||
style={{ backgroundColor: entry.color }}
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
{entry.value}: {entry.payload.value}
|
||||
<span className="text-xs sm:text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<span className="font-medium">{entry.value}</span>: <span className="font-bold">{Math.round(entry.payload.value)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -37,19 +37,32 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
|
|||
{title}
|
||||
</CardTitle>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<div className="w-full flex items-center gap-4">
|
||||
{/* Legend on the left */}
|
||||
<div className="flex-shrink-0">
|
||||
<CustomLegend payload={data.map((item, index) => ({
|
||||
value: item.name,
|
||||
color: colors[index % colors.length],
|
||||
payload: item
|
||||
}))} />
|
||||
</div>
|
||||
|
||||
{/* Chart on the right */}
|
||||
<div className="flex-1">
|
||||
<ResponsiveContainer width="100%" height={280} minHeight={220}>
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="45%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
// Remove the overlapping labels
|
||||
label={false}
|
||||
outerRadius="65%"
|
||||
outerRadius="75%"
|
||||
innerRadius="35%"
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
stroke="#fff"
|
||||
strokeWidth={3}
|
||||
>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
|
||||
|
|
@ -57,24 +70,20 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
|
|||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--toast-bg)',
|
||||
color: 'var(--toast-color)',
|
||||
border: 'none',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
color: '#1f2937',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
formatter={(value, name) => [`${value}`, name]}
|
||||
/>
|
||||
<Legend
|
||||
content={<CustomLegend />}
|
||||
wrapperStyle={{
|
||||
paddingTop: '10px'
|
||||
}}
|
||||
formatter={(value: any, name: any) => [`${Math.round(value)}%`, name]}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import persian from 'react-date-object/calendars/persian';
|
|||
import persian_fa from 'react-date-object/locales/persian_fa';
|
||||
import DateObject from 'react-date-object';
|
||||
import { Label } from './Typography';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface JalaliDateTimePickerProps {
|
||||
label?: string;
|
||||
|
|
@ -46,6 +47,7 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
|
|||
return (
|
||||
<div className="space-y-1">
|
||||
{label && <Label>{label}</Label>}
|
||||
<div className="relative">
|
||||
<DatePicker
|
||||
value={selected}
|
||||
onChange={(val) => onChange(toIsoLike(val as DateObject | null))}
|
||||
|
|
@ -54,7 +56,7 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
|
|||
locale={persian_fa}
|
||||
calendarPosition="bottom-center"
|
||||
disableDayPicker={false}
|
||||
inputClass={`w-full border rounded-lg px-3 py-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`}
|
||||
inputClass={`w-full border rounded-lg px-3 py-3 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`}
|
||||
containerClassName="w-full"
|
||||
placeholder={placeholder || 'تاریخ و ساعت'}
|
||||
editable={false}
|
||||
|
|
@ -63,6 +65,20 @@ export const JalaliDateTimePicker: React.FC<JalaliDateTimePickerProps> = ({ labe
|
|||
disableYearPicker={false}
|
||||
showOtherDays
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(undefined);
|
||||
}}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
title="پاک کردن"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400" role="alert">{error}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -95,13 +95,12 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
|
|||
{/* Selected Items Display */}
|
||||
<div
|
||||
className={`
|
||||
|
||||
w-full min-h-[42px] px-3 py-2 border rounded-md
|
||||
focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
|
||||
cursor-pointer
|
||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
w-full px-3 py-3 text-base border rounded-lg
|
||||
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500
|
||||
cursor-pointer transition-all duration-200
|
||||
${error ? 'border-red-300 focus-within:border-red-500 focus-within:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus-within:border-primary-500'}
|
||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
||||
dark:text-gray-100
|
||||
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400
|
||||
`}
|
||||
onClick={handleToggleDropdown}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -106,12 +106,12 @@ export const SingleSelectAutocomplete: React.FC<SingleSelectAutocompleteProps> =
|
|||
|
||||
<div
|
||||
className={`
|
||||
w-full min-h-[42px] px-3 py-2 border rounded-md
|
||||
focus-within:outline-none focus-within:ring-1 focus-within:ring-primary-500
|
||||
cursor-pointer
|
||||
${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}
|
||||
w-full px-3 py-3 text-base border rounded-lg
|
||||
focus-within:outline-none focus-within:ring-2 focus-within:ring-primary-500
|
||||
cursor-pointer transition-all duration-200
|
||||
${error ? 'border-red-300 focus-within:border-red-500 focus-within:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus-within:border-primary-500'}
|
||||
${disabled ? 'bg-gray-100 cursor-not-allowed' : 'bg-white dark:bg-gray-700'}
|
||||
dark:text-gray-100
|
||||
dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400
|
||||
`}
|
||||
onClick={handleToggleDropdown}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -33,3 +33,4 @@ export const useUpdatePaymentCard = () => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ export interface UpdatePaymentCardResponse {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,3 +24,4 @@ export const updatePaymentCard = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -37,3 +37,4 @@ export const useUpdateIPGStatus = () => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -32,3 +32,4 @@ export const IPG_LABELS: Record<IPGType, string> = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,3 +24,4 @@ export const updateIPGStatus = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -152,3 +152,4 @@ export default IPGListPage;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
getProductComments,
|
||||
updateCommentStatus,
|
||||
deleteComment,
|
||||
} from "./_requests";
|
||||
import {
|
||||
ProductCommentFilters,
|
||||
UpdateCommentStatusRequest,
|
||||
} from "./_models";
|
||||
|
||||
export const useProductComments = (filters: ProductCommentFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS, filters],
|
||||
queryFn: () => getProductComments(filters),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCommentStatus = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
commentId,
|
||||
payload,
|
||||
}: {
|
||||
commentId: string;
|
||||
payload: UpdateCommentStatusRequest;
|
||||
}) => updateCommentStatus(commentId, payload),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS] });
|
||||
toast.success("وضعیت نظر با موفقیت تغییر کرد");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("خطا در تغییر وضعیت نظر");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (commentId: string) => deleteComment(commentId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS] });
|
||||
toast.success("نظر با موفقیت حذف شد");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("خطا در حذف نظر");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
export type CommentStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export interface ProductCommentFilters {
|
||||
status?: CommentStatus;
|
||||
productId?: number;
|
||||
userId?: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
export interface ProductComment {
|
||||
id: number;
|
||||
user_id: number;
|
||||
product_id: number;
|
||||
rating: number;
|
||||
subject: string;
|
||||
comment: string;
|
||||
comment_status: CommentStatus;
|
||||
created_at: string; // ISO 8601
|
||||
updated_at: string; // ISO 8601
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface ProductCommentsResponse {
|
||||
comments: ProductComment[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCommentStatusRequest {
|
||||
status: 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
export interface UpdateCommentStatusResponse extends ProductComment {}
|
||||
|
||||
export interface DeleteCommentResponse {
|
||||
message: string;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
httpGetRequest,
|
||||
httpPutRequest,
|
||||
httpDeleteRequest,
|
||||
APIUrlGenerator,
|
||||
} from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import {
|
||||
ProductCommentFilters,
|
||||
ProductCommentsResponse,
|
||||
UpdateCommentStatusRequest,
|
||||
UpdateCommentStatusResponse,
|
||||
DeleteCommentResponse,
|
||||
} from "./_models";
|
||||
|
||||
export const getProductComments = async (
|
||||
filters: ProductCommentFilters
|
||||
): Promise<ProductCommentsResponse> => {
|
||||
const queryParams: Record<string, string | number> = {};
|
||||
|
||||
if (filters.status) queryParams.status = filters.status;
|
||||
if (filters.productId) queryParams.productId = filters.productId;
|
||||
if (filters.userId) queryParams.userId = filters.userId;
|
||||
queryParams.limit = filters.limit;
|
||||
queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<ProductCommentsResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_PRODUCT_COMMENTS, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const updateCommentStatus = async (
|
||||
commentId: string,
|
||||
payload: UpdateCommentStatusRequest
|
||||
): Promise<UpdateCommentStatusResponse> => {
|
||||
const response = await httpPutRequest<UpdateCommentStatusResponse>(
|
||||
APIUrlGenerator(API_ROUTES.UPDATE_COMMENT_STATUS(commentId)),
|
||||
payload
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteComment = async (
|
||||
commentId: string
|
||||
): Promise<DeleteCommentResponse> => {
|
||||
const response = await httpDeleteRequest<DeleteCommentResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DELETE_COMMENT(commentId))
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import {
|
||||
getDiscountUsageReport,
|
||||
getCustomerDiscountUsageReport,
|
||||
} from "./_requests";
|
||||
import {
|
||||
DiscountUsageFilters,
|
||||
CustomerDiscountUsageFilters,
|
||||
} from "./_models";
|
||||
|
||||
export const useDiscountUsageReport = (filters: DiscountUsageFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_DISCOUNT_USAGE_REPORT, filters],
|
||||
queryFn: () => getDiscountUsageReport(filters),
|
||||
enabled: filters.limit > 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCustomerDiscountUsageReport = (
|
||||
filters: CustomerDiscountUsageFilters
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_CUSTOMER_DISCOUNT_USAGE_REPORT, filters],
|
||||
queryFn: () => getCustomerDiscountUsageReport(filters),
|
||||
enabled: filters.user_id > 0 && filters.limit > 0,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
export interface DateRange {
|
||||
from?: string; // ISO 8601
|
||||
to?: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface DiscountUsageFilters {
|
||||
date_range?: DateRange;
|
||||
discount_code?: string;
|
||||
discount_id?: number;
|
||||
user_id?: number;
|
||||
group_by_code?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface DiscountUsage {
|
||||
discount_id: number;
|
||||
discount_code: string;
|
||||
discount_name: string;
|
||||
usage_count: number;
|
||||
total_amount: number; // ریال
|
||||
unique_users: number;
|
||||
first_used_at: string; // ISO 8601
|
||||
last_used_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface DiscountUsageSummary {
|
||||
total_usages: number;
|
||||
total_discount_given: number; // ریال
|
||||
unique_users: number;
|
||||
unique_codes: number;
|
||||
most_used_code: string;
|
||||
most_used_code_count: number;
|
||||
}
|
||||
|
||||
export interface DiscountUsageResponse {
|
||||
usages: DiscountUsage[] | null;
|
||||
summary: DiscountUsageSummary;
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface CustomerDiscountUsageFilters {
|
||||
user_id: number; // Required
|
||||
date_range?: DateRange;
|
||||
discount_code?: string;
|
||||
discount_id?: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface CustomerDiscountUsage {
|
||||
discount_usage_id: number;
|
||||
user_id: number;
|
||||
customer_name: string;
|
||||
discount_id: number;
|
||||
discount_code: string;
|
||||
discount_name: string;
|
||||
order_id: number;
|
||||
order_number: string;
|
||||
amount: number; // ریال
|
||||
used_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface CustomerDiscountUsageSummary {
|
||||
total_usages: number;
|
||||
total_discount_amount: number; // ریال
|
||||
unique_codes: number;
|
||||
average_discount_per_order: number; // ریال
|
||||
}
|
||||
|
||||
export interface CustomerDiscountUsageResponse {
|
||||
usages: CustomerDiscountUsage[] | null;
|
||||
summary: CustomerDiscountUsageSummary;
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import {
|
||||
DiscountUsageFilters,
|
||||
DiscountUsageResponse,
|
||||
CustomerDiscountUsageFilters,
|
||||
CustomerDiscountUsageResponse,
|
||||
} from "./_models";
|
||||
|
||||
export const getDiscountUsageReport = async (
|
||||
filters: DiscountUsageFilters
|
||||
): Promise<DiscountUsageResponse> => {
|
||||
const response = await httpPostRequest<DiscountUsageResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DISCOUNT_USAGE_REPORT),
|
||||
filters
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getCustomerDiscountUsageReport = async (
|
||||
filters: CustomerDiscountUsageFilters
|
||||
): Promise<CustomerDiscountUsageResponse> => {
|
||||
const response = await httpPostRequest<CustomerDiscountUsageResponse>(
|
||||
APIUrlGenerator(API_ROUTES.CUSTOMER_DISCOUNT_USAGE_REPORT),
|
||||
filters
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import { getShipmentsByMethodReport } from "./_requests";
|
||||
import { ShipmentsByMethodFilters } from "./_models";
|
||||
|
||||
export const useShipmentsByMethodReport = (filters: ShipmentsByMethodFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_SHIPMENTS_BY_METHOD_REPORT, filters],
|
||||
queryFn: () => getShipmentsByMethodReport(filters),
|
||||
enabled: filters.limit > 0,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
export interface DateRange {
|
||||
from?: string; // ISO 8601
|
||||
to?: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface ShipmentsByMethodFilters {
|
||||
shipping_method_code?: string;
|
||||
shipping_method_id?: number;
|
||||
date_range?: DateRange;
|
||||
customer_name?: string;
|
||||
user_id?: number;
|
||||
status?: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
payment_status?: 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled';
|
||||
min_shipping_cost?: number;
|
||||
max_shipping_cost?: number;
|
||||
group_by_method?: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface Shipment {
|
||||
order_id: number;
|
||||
order_number: string;
|
||||
user_id: number;
|
||||
customer_name: string;
|
||||
customer_phone: string;
|
||||
shipping_method_id: number;
|
||||
shipping_method: string;
|
||||
shipping_method_code: string;
|
||||
shipping_cost: number; // ریال
|
||||
delivery_date?: string; // YYYY-MM-DD
|
||||
delivery_from_hour?: number; // 0-23
|
||||
delivery_to_hour?: number; // 0-23
|
||||
status: string;
|
||||
payment_status: string;
|
||||
total_weight: number; // گرم
|
||||
order_amount: number; // ریال
|
||||
created_at: string; // ISO 8601
|
||||
shipped_at?: string; // ISO 8601
|
||||
delivered_at?: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface MethodSummary {
|
||||
shipping_method_id: number;
|
||||
shipping_method: string;
|
||||
shipping_method_code: string;
|
||||
shipment_count: number;
|
||||
total_revenue: number; // ریال
|
||||
total_shipping_cost: number; // ریال
|
||||
average_weight: number; // گرم
|
||||
delivered_count: number;
|
||||
cancelled_count: number;
|
||||
}
|
||||
|
||||
export interface ShipmentsSummary {
|
||||
total_shipments: number;
|
||||
total_shipping_cost: number; // ریال
|
||||
total_order_amount: number; // ریال
|
||||
total_weight: number; // گرم
|
||||
pending_shipments: number;
|
||||
shipped_count: number;
|
||||
delivered_count: number;
|
||||
cancelled_count: number;
|
||||
average_shipping_cost: number; // ریال
|
||||
average_delivery_time?: number; // ساعت
|
||||
}
|
||||
|
||||
export interface ShipmentsByMethodResponse {
|
||||
shipments: Shipment[];
|
||||
summary: ShipmentsSummary;
|
||||
method_summaries?: MethodSummary[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,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;
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -15,6 +15,7 @@ export interface ShippingMethod {
|
|||
time_note?: string;
|
||||
open_hours: ShippingOpenHour[];
|
||||
addresses: string[];
|
||||
needs_address: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -33,3 +33,4 @@ export const useUpdateWalletStatus = () => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -29,3 +29,4 @@ export const WALLET_LABELS: Record<WalletType, string> = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,3 +24,4 @@ export const updateWalletStatus = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -152,3 +152,4 @@ export default WalletListPage;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue