feat(contact-us): add contact us page and related API endpoints
- Introduced a new Contact Us page with routing and lazy loading. - Added API routes for fetching and deleting contact messages. - Updated sidebar to include a link to the Contact Us page. - Enhanced the shipping methods list page with a report view option for shipments. - Implemented discount reports functionality in the discount codes section. - Improved payment statistics with transaction reporting capabilities. These changes enhance user interaction and reporting features across the application.
This commit is contained in:
parent
5bb506b830
commit
5b62d189f8
11
src/App.tsx
11
src/App.tsx
|
|
@ -6,7 +6,6 @@ import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
import { ErrorBoundary } from './components/common/ErrorBoundary';
|
||||||
import { LoadingSpinner } from './components/ui/LoadingSpinner';
|
|
||||||
import { queryClient } from './lib/queryClient';
|
import { queryClient } from './lib/queryClient';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import { Layout } from './components/layout/Layout';
|
import { Layout } from './components/layout/Layout';
|
||||||
|
|
@ -69,6 +68,7 @@ const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipp
|
||||||
const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage'));
|
const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage'));
|
||||||
const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage'));
|
const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage'));
|
||||||
const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage'));
|
const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage'));
|
||||||
|
const ContactUsListPage = lazy(() => import('./pages/contact-us/contact-us-list/ContactUsListPage'));
|
||||||
|
|
||||||
// Payment IPG Page
|
// Payment IPG Page
|
||||||
const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'));
|
const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage'));
|
||||||
|
|
@ -165,11 +165,14 @@ const AppRoutes = () => {
|
||||||
<Route path="shipping-methods" element={<ShippingMethodsListPage />} />
|
<Route path="shipping-methods" element={<ShippingMethodsListPage />} />
|
||||||
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
|
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
|
||||||
<Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} />
|
<Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} />
|
||||||
|
<Route path="shipping-methods/shipments-report" element={<ShipmentsByMethodReportPage />} />
|
||||||
|
|
||||||
<Route path="tickets" element={<TicketsListPage />} />
|
<Route path="tickets" element={<TicketsListPage />} />
|
||||||
<Route path="tickets/config" element={<TicketConfigPage />} />
|
<Route path="tickets/config" element={<TicketConfigPage />} />
|
||||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||||
|
|
||||||
|
<Route path="contact-us" element={<ContactUsListPage />} />
|
||||||
|
|
||||||
{/* Products Routes */}
|
{/* Products Routes */}
|
||||||
<Route path="products/create" element={<ProductFormPage />} />
|
<Route path="products/create" element={<ProductFormPage />} />
|
||||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||||
|
|
@ -203,11 +206,7 @@ const App = () => {
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<Suspense fallback={
|
<Suspense fallback={null}>
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<LoadingSpinner />
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Router>
|
</Router>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,11 @@ const menuItems: MenuItem[] = [
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'پیامهای تماس با ما',
|
||||||
|
icon: FileText,
|
||||||
|
path: '/contact-us',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'مدیریت محصولات',
|
title: 'مدیریت محصولات',
|
||||||
icon: Package,
|
icon: Package,
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,10 @@ export const API_ROUTES = {
|
||||||
UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
|
UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
|
||||||
DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
|
DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`,
|
||||||
|
|
||||||
|
// Contact Us APIs
|
||||||
|
GET_CONTACT_US_MESSAGES: "contact-us",
|
||||||
|
DELETE_CONTACT_US_MESSAGE: (id: string) => `contact-us/${id}`,
|
||||||
|
|
||||||
// Payment IPG APIs
|
// Payment IPG APIs
|
||||||
GET_IPG_STATUS: "payment/ipg/status",
|
GET_IPG_STATUS: "payment/ipg/status",
|
||||||
UPDATE_IPG_STATUS: "payment/ipg/status",
|
UPDATE_IPG_STATUS: "payment/ipg/status",
|
||||||
|
|
@ -147,9 +151,11 @@ export const API_ROUTES = {
|
||||||
UPDATE_WALLET_STATUS: "wallet/status",
|
UPDATE_WALLET_STATUS: "wallet/status",
|
||||||
|
|
||||||
// Reports APIs
|
// Reports APIs
|
||||||
|
DISCOUNT_REPORTS: "reports/discounts",
|
||||||
DISCOUNT_USAGE_REPORT: "reports/discounts/usage",
|
DISCOUNT_USAGE_REPORT: "reports/discounts/usage",
|
||||||
CUSTOMER_DISCOUNT_USAGE_REPORT: "reports/discounts/customer-usage",
|
CUSTOMER_DISCOUNT_USAGE_REPORT: "reports/discounts/customer-usage",
|
||||||
PAYMENT_METHODS_REPORT: "reports/payments/methods",
|
PAYMENT_METHODS_REPORT: "reports/payments/methods",
|
||||||
|
PAYMENT_TRANSACTIONS_REPORT: "reports/payments/transactions",
|
||||||
SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method",
|
SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method",
|
||||||
|
|
||||||
// Product Comments APIs
|
// Product Comments APIs
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { MessageSquare, Trash2 } from 'lucide-react';
|
||||||
|
import { PageContainer } from '@/components/ui/Typography';
|
||||||
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
|
import { Table } from '@/components/ui/Table';
|
||||||
|
import { TableColumn } from '@/types';
|
||||||
|
import { Pagination } from '@/components/ui/Pagination';
|
||||||
|
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
|
||||||
|
import { englishToPersian } from '@/utils/numberUtils';
|
||||||
|
import { formatDateTime } from '@/utils/formatters';
|
||||||
|
import { useContactUsMessages, useDeleteContactUsMessage } from '../core/_hooks';
|
||||||
|
import { ContactUsFilters, ContactUsMessage } from '../core/_models';
|
||||||
|
|
||||||
|
const ContactUsListPage: React.FC = () => {
|
||||||
|
const [filters, setFilters] = useState<ContactUsFilters>({
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<ContactUsMessage | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useContactUsMessages(filters);
|
||||||
|
const deleteMessageMutation = useDeleteContactUsMessage();
|
||||||
|
|
||||||
|
const messages = data?.messages || [];
|
||||||
|
const total = data?.total ?? messages.length;
|
||||||
|
const limit = filters.limit || 20;
|
||||||
|
const currentPage = Math.floor((filters.offset || 0) / limit) + 1;
|
||||||
|
const totalPages = total > 0 ? Math.ceil(total / limit) : 1;
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
offset: (page - 1) * prev.limit,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteMessageMutation.mutate(deleteTarget.ID, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumn[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
key: 'id',
|
||||||
|
label: 'شناسه',
|
||||||
|
align: 'center',
|
||||||
|
render: (value: number) => englishToPersian(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'نام',
|
||||||
|
align: 'right',
|
||||||
|
render: (value: string) => value || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'phone',
|
||||||
|
label: 'شماره تماس',
|
||||||
|
align: 'left',
|
||||||
|
render: (value: string) => {
|
||||||
|
const display = value ? englishToPersian(value) : '-';
|
||||||
|
return <span dir="ltr">{display}</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'message',
|
||||||
|
label: 'پیام',
|
||||||
|
align: 'right',
|
||||||
|
render: (value: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
return value.length > 120 ? `${value.slice(0, 120)}...` : value;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
label: 'تاریخ',
|
||||||
|
align: 'right',
|
||||||
|
render: (value: string) => formatDateTime(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: 'عملیات',
|
||||||
|
align: 'center',
|
||||||
|
render: (_val, row: any) => (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(row.raw)}
|
||||||
|
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
|
||||||
|
title="حذف پیام"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tableData = messages.map((message) => ({
|
||||||
|
id: message.ID,
|
||||||
|
name: message.Name || '-',
|
||||||
|
phone: message.PhoneNumber || '-',
|
||||||
|
message: message.Message || '-',
|
||||||
|
created_at: message.CreatedAt,
|
||||||
|
raw: message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-600">خطا در دریافت پیامهای تماس با ما</p>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="پیامهای تماس با ما"
|
||||||
|
subtitle="لیست پیامهای ارسالشده توسط کاربران"
|
||||||
|
icon={MessageSquare}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<Table columns={columns} data={[]} loading={true} />
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<MessageSquare className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
پیامی یافت نشد
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
هنوز پیامی برای نمایش وجود ندارد
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table columns={columns} data={tableData} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{messages.length > 0 && totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
itemsPerPage={limit}
|
||||||
|
totalItems={total}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteConfirmModal
|
||||||
|
isOpen={!!deleteTarget}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
title="حذف پیام تماس با ما"
|
||||||
|
message="آیا از حذف این پیام اطمینان دارید؟ این عمل قابل بازگشت نیست."
|
||||||
|
isLoading={deleteMessageMutation.isPending}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ContactUsListPage;
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { QUERY_KEYS } from "@/utils/query-key";
|
||||||
|
import { getContactUsMessages, deleteContactUsMessage } from "./_requests";
|
||||||
|
import { ContactUsFilters } from "./_models";
|
||||||
|
|
||||||
|
export const useContactUsMessages = (filters?: ContactUsFilters) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES, filters],
|
||||||
|
queryFn: () => getContactUsMessages(filters),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeleteContactUsMessage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string | number) => deleteContactUsMessage(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES],
|
||||||
|
});
|
||||||
|
toast.success("پیام تماس با ما حذف شد");
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(error?.message || "خطا در حذف پیام تماس با ما");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
export interface ContactUsMessage {
|
||||||
|
ID: number;
|
||||||
|
UserID: number;
|
||||||
|
Name: string;
|
||||||
|
PhoneNumber: string;
|
||||||
|
Message: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactUsListResponse {
|
||||||
|
messages: ContactUsMessage[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactUsFilters {
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteContactUsResponse {
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import {
|
||||||
|
APIUrlGenerator,
|
||||||
|
httpDeleteRequest,
|
||||||
|
httpGetRequest,
|
||||||
|
} from "@/utils/baseHttpService";
|
||||||
|
import { API_ROUTES } from "@/constant/routes";
|
||||||
|
import {
|
||||||
|
ContactUsFilters,
|
||||||
|
ContactUsListResponse,
|
||||||
|
DeleteContactUsResponse,
|
||||||
|
} from "./_models";
|
||||||
|
|
||||||
|
export const getContactUsMessages = async (filters?: ContactUsFilters) => {
|
||||||
|
const limitValue = filters?.limit ?? 20;
|
||||||
|
const queryParams: Record<string, string | number | null> = {
|
||||||
|
limit: limitValue,
|
||||||
|
offset: filters?.offset ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await httpGetRequest<ContactUsListResponse>(
|
||||||
|
APIUrlGenerator(API_ROUTES.GET_CONTACT_US_MESSAGES, queryParams)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteContactUsMessage = async (id: string | number) => {
|
||||||
|
const response = await httpDeleteRequest<DeleteContactUsResponse>(
|
||||||
|
APIUrlGenerator(API_ROUTES.DELETE_CONTACT_US_MESSAGE(id.toString()))
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
@ -7,11 +7,13 @@ import {
|
||||||
createDiscountCode,
|
createDiscountCode,
|
||||||
updateDiscountCode,
|
updateDiscountCode,
|
||||||
deleteDiscountCode,
|
deleteDiscountCode,
|
||||||
|
getDiscountReports,
|
||||||
} from "./_requests";
|
} from "./_requests";
|
||||||
import {
|
import {
|
||||||
CreateDiscountCodeRequest,
|
CreateDiscountCodeRequest,
|
||||||
UpdateDiscountCodeRequest,
|
UpdateDiscountCodeRequest,
|
||||||
DiscountCodeFilters,
|
DiscountCodeFilters,
|
||||||
|
DiscountReportFilters,
|
||||||
} from "./_models";
|
} from "./_models";
|
||||||
|
|
||||||
export const useDiscountCodes = (filters?: DiscountCodeFilters) => {
|
export const useDiscountCodes = (filters?: DiscountCodeFilters) => {
|
||||||
|
|
@ -84,3 +86,13 @@ export const useDeleteDiscountCode = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useDiscountReports = (
|
||||||
|
filters?: DiscountReportFilters,
|
||||||
|
enabled: boolean = true
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.GET_DISCOUNT_REPORTS, filters],
|
||||||
|
queryFn: () => getDiscountReports(filters),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,17 @@ export type DiscountApplicationLevels =
|
||||||
|
|
||||||
export type DiscountStatus = "active" | "inactive";
|
export type DiscountStatus = "active" | "inactive";
|
||||||
|
|
||||||
|
export type DiscountReportViewMode = "simple" | "detailed";
|
||||||
|
|
||||||
|
export type DiscountReportSortBy =
|
||||||
|
| "usage_count"
|
||||||
|
| "amount"
|
||||||
|
| "date"
|
||||||
|
| "code"
|
||||||
|
| "created_at";
|
||||||
|
|
||||||
|
export type DiscountReportSortOrder = "asc" | "desc";
|
||||||
|
|
||||||
export type UserGroup = "new" | "loyal" | "all";
|
export type UserGroup = "new" | "loyal" | "all";
|
||||||
|
|
||||||
export interface DiscountUserRestrictions {
|
export interface DiscountUserRestrictions {
|
||||||
|
|
@ -73,6 +84,50 @@ export interface DiscountCodeFilters {
|
||||||
active_only?: boolean;
|
active_only?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscountReportFilters {
|
||||||
|
view_mode?: DiscountReportViewMode;
|
||||||
|
discount_code?: string;
|
||||||
|
discount_id?: number;
|
||||||
|
user_id?: number;
|
||||||
|
status?: "active" | "inactive" | "expired";
|
||||||
|
type?: DiscountCodeType;
|
||||||
|
application_level?: DiscountApplicationLevel;
|
||||||
|
from_date?: string;
|
||||||
|
to_date?: string;
|
||||||
|
min_usage_count?: number;
|
||||||
|
include_unused?: boolean;
|
||||||
|
group_by_code?: boolean;
|
||||||
|
sort_by?: DiscountReportSortBy;
|
||||||
|
sort_order?: DiscountReportSortOrder;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountReportUsage {
|
||||||
|
discount_id: number;
|
||||||
|
discount_code: string;
|
||||||
|
discount_name: string;
|
||||||
|
usage_count: number;
|
||||||
|
total_amount: number;
|
||||||
|
unique_users: number;
|
||||||
|
first_used_at: string;
|
||||||
|
last_used_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountReportSummarySimple {
|
||||||
|
total_usages: number;
|
||||||
|
total_discount_given: number;
|
||||||
|
unique_users: number;
|
||||||
|
unique_codes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountReportSimpleResponse {
|
||||||
|
usages: DiscountReportUsage[];
|
||||||
|
summary: DiscountReportSummarySimple;
|
||||||
|
total: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateDiscountCodeRequest {
|
export interface CreateDiscountCodeRequest {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import {
|
||||||
DiscountCode,
|
DiscountCode,
|
||||||
DiscountCodeFilters,
|
DiscountCodeFilters,
|
||||||
PaginatedDiscountCodesResponse,
|
PaginatedDiscountCodesResponse,
|
||||||
|
DiscountReportFilters,
|
||||||
|
DiscountReportSimpleResponse,
|
||||||
} from "./_models";
|
} from "./_models";
|
||||||
|
|
||||||
export const getDiscountCodes = async (filters?: DiscountCodeFilters) => {
|
export const getDiscountCodes = async (filters?: DiscountCodeFilters) => {
|
||||||
|
|
@ -75,3 +77,39 @@ export const deleteDiscountCode = async (id: string) => {
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDiscountReports = async (
|
||||||
|
filters?: DiscountReportFilters
|
||||||
|
) => {
|
||||||
|
const queryParams: Record<string, string | number | null> = {};
|
||||||
|
|
||||||
|
if (filters?.view_mode) queryParams.view_mode = filters.view_mode;
|
||||||
|
if (filters?.discount_code) queryParams.discount_code = filters.discount_code;
|
||||||
|
if (filters?.discount_id) queryParams.discount_id = filters.discount_id;
|
||||||
|
if (filters?.user_id) queryParams.user_id = filters.user_id;
|
||||||
|
if (filters?.status) queryParams.status = filters.status;
|
||||||
|
if (filters?.type) queryParams.type = filters.type;
|
||||||
|
if (filters?.application_level) {
|
||||||
|
queryParams.application_level = filters.application_level;
|
||||||
|
}
|
||||||
|
if (filters?.from_date) queryParams.from_date = filters.from_date;
|
||||||
|
if (filters?.to_date) queryParams.to_date = filters.to_date;
|
||||||
|
if (filters?.min_usage_count)
|
||||||
|
queryParams.min_usage_count = filters.min_usage_count;
|
||||||
|
if (typeof filters?.include_unused === "boolean") {
|
||||||
|
queryParams.include_unused = filters.include_unused ? "true" : "false";
|
||||||
|
}
|
||||||
|
if (typeof filters?.group_by_code === "boolean") {
|
||||||
|
queryParams.group_by_code = filters.group_by_code ? "true" : "false";
|
||||||
|
}
|
||||||
|
if (filters?.sort_by) queryParams.sort_by = filters.sort_by;
|
||||||
|
if (filters?.sort_order) queryParams.sort_order = filters.sort_order;
|
||||||
|
if (typeof filters?.limit === "number") queryParams.limit = filters.limit;
|
||||||
|
if (typeof filters?.offset === "number") queryParams.offset = filters.offset;
|
||||||
|
|
||||||
|
const response = await httpGetRequest<DiscountReportSimpleResponse>(
|
||||||
|
APIUrlGenerator(API_ROUTES.DISCOUNT_REPORTS, queryParams)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks';
|
import { useDiscountCodes, useDeleteDiscountCode, useDiscountReports } from '../core/_hooks';
|
||||||
import { DiscountCode } from '../core/_models';
|
import { DiscountCode } from '../core/_models';
|
||||||
import { Table } from "@/components/ui/Table";
|
import { Table } from "@/components/ui/Table";
|
||||||
import { TableColumn } from "@/types";
|
import { TableColumn } from "@/types";
|
||||||
import { BadgePercent, Plus, Ticket } from 'lucide-react';
|
import { BadgePercent, Plus, Ticket, Hash, DollarSign, Users } from 'lucide-react';
|
||||||
import { PageContainer } from "@/components/ui/Typography";
|
import { PageContainer } from "@/components/ui/Typography";
|
||||||
import { PageHeader } from "@/components/layout/PageHeader";
|
import { PageHeader } from "@/components/layout/PageHeader";
|
||||||
import { FiltersSection } from "@/components/common/FiltersSection";
|
import { FiltersSection } from "@/components/common/FiltersSection";
|
||||||
|
|
@ -12,18 +12,49 @@ import { EmptyState } from "@/components/common/EmptyState";
|
||||||
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal";
|
||||||
import { ActionButtons } from "@/components/common/ActionButtons";
|
import { ActionButtons } from "@/components/common/ActionButtons";
|
||||||
import { StatusBadge } from "@/components/ui/StatusBadge";
|
import { StatusBadge } from "@/components/ui/StatusBadge";
|
||||||
import { formatDate } from "@/utils/formatters";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||||
|
import { formatCurrency, formatDate, formatDateTime } from "@/utils/formatters";
|
||||||
|
import { formatWithThousands } from "@/utils/numberUtils";
|
||||||
|
|
||||||
const DiscountCodesListPage = () => {
|
const DiscountCodesListPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
const [filters, setFilters] = useState({ code: '' });
|
const [filters, setFilters] = useState({ code: '' });
|
||||||
|
const [selectedDiscount, setSelectedDiscount] = useState<DiscountCode | null>(null);
|
||||||
|
const [isUsageModalOpen, setIsUsageModalOpen] = useState(false);
|
||||||
|
|
||||||
const { data: discountCodes, isLoading, error } = useDiscountCodes(filters);
|
const { data: discountCodes, isLoading, error } = useDiscountCodes(filters);
|
||||||
const { mutate: deleteDiscount, isPending: isDeleting } = useDeleteDiscountCode();
|
const { mutate: deleteDiscount, isPending: isDeleting } = useDeleteDiscountCode();
|
||||||
|
const summaryFilters = useMemo(
|
||||||
|
() => ({
|
||||||
|
view_mode: 'simple' as const,
|
||||||
|
limit: 1,
|
||||||
|
offset: 0,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const { data: discountReport, isLoading: isReportLoading, error: reportError } =
|
||||||
|
useDiscountReports(summaryFilters);
|
||||||
|
const usageFilters = useMemo(() => {
|
||||||
|
if (!selectedDiscount) return undefined;
|
||||||
|
return {
|
||||||
|
view_mode: 'simple' as const,
|
||||||
|
discount_id: selectedDiscount.id,
|
||||||
|
include_unused: true,
|
||||||
|
limit: 1,
|
||||||
|
offset: 0,
|
||||||
|
};
|
||||||
|
}, [selectedDiscount]);
|
||||||
|
const {
|
||||||
|
data: usageReport,
|
||||||
|
isLoading: isUsageLoading,
|
||||||
|
error: usageError,
|
||||||
|
} = useDiscountReports(usageFilters, isUsageModalOpen && !!selectedDiscount);
|
||||||
|
const selectedUsage = usageReport?.usages?.[0];
|
||||||
|
|
||||||
const handleCreate = () => navigate('/discount-codes/create');
|
const handleCreate = useCallback(() => navigate('/discount-codes/create'), [navigate]);
|
||||||
const handleEdit = (id: number) => navigate(`/discount-codes/${id}/edit`);
|
const handleEdit = useCallback((id: number) => navigate(`/discount-codes/${id}/edit`), [navigate]);
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = () => {
|
||||||
if (deleteId) {
|
if (deleteId) {
|
||||||
|
|
@ -31,6 +62,16 @@ const DiscountCodesListPage = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenUsageModal = useCallback((discount: DiscountCode) => {
|
||||||
|
setSelectedDiscount(discount);
|
||||||
|
setIsUsageModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseUsageModal = () => {
|
||||||
|
setIsUsageModalOpen(false);
|
||||||
|
setSelectedDiscount(null);
|
||||||
|
};
|
||||||
|
|
||||||
const columns: TableColumn[] = useMemo(() => [
|
const columns: TableColumn[] = useMemo(() => [
|
||||||
{ key: 'code', label: 'کد', sortable: true },
|
{ key: 'code', label: 'کد', sortable: true },
|
||||||
{ key: 'name', label: 'نام', sortable: true },
|
{ key: 'name', label: 'نام', sortable: true },
|
||||||
|
|
@ -63,12 +104,14 @@ const DiscountCodesListPage = () => {
|
||||||
label: 'عملیات',
|
label: 'عملیات',
|
||||||
render: (_val, row: any) => (
|
render: (_val, row: any) => (
|
||||||
<ActionButtons
|
<ActionButtons
|
||||||
|
onView={() => handleOpenUsageModal(row as DiscountCode)}
|
||||||
|
viewTitle="آمار استفاده"
|
||||||
onEdit={() => handleEdit(row.id)}
|
onEdit={() => handleEdit(row.id)}
|
||||||
onDelete={() => setDeleteId(row.id.toString())}
|
onDelete={() => setDeleteId(row.id.toString())}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
], [navigate]);
|
], [handleEdit, handleOpenUsageModal]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -114,6 +157,74 @@ const DiscountCodesListPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</FiltersSection>
|
</FiltersSection>
|
||||||
|
|
||||||
|
{isReportLoading ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<LoadingSpinner size="sm" text="در حال بارگذاری آمار..." />
|
||||||
|
</div>
|
||||||
|
) : reportError ? (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
|
<p className="text-red-600 dark:text-red-400">خطا در دریافت آمار تخفیف</p>
|
||||||
|
</div>
|
||||||
|
) : discountReport?.summary ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||||
|
<Hash className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">کل استفادهها</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(discountReport.summary.total_usages)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||||
|
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع تخفیف داده شده</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatCurrency(discountReport.summary.total_discount_given)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||||
|
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">کاربران یونیک</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(discountReport.summary.unique_users)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||||
|
<Hash className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">کدهای یونیک</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(discountReport.summary.unique_codes)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Table columns={columns} data={Array.isArray(discountCodes) ? (discountCodes as any[]) : []} loading={true} />
|
<Table columns={columns} data={Array.isArray(discountCodes) ? (discountCodes as any[]) : []} loading={true} />
|
||||||
) : !discountCodes || discountCodes.length === 0 ? (
|
) : !discountCodes || discountCodes.length === 0 ? (
|
||||||
|
|
@ -135,6 +246,66 @@ const DiscountCodesListPage = () => {
|
||||||
<Table columns={columns} data={discountCodes as any[]} />
|
<Table columns={columns} data={discountCodes as any[]} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isUsageModalOpen}
|
||||||
|
onClose={handleCloseUsageModal}
|
||||||
|
title={`آمار استفاده - ${selectedDiscount?.code || ''}`}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isUsageLoading ? (
|
||||||
|
<LoadingSpinner size="sm" text="در حال بارگذاری آمار..." />
|
||||||
|
) : usageError ? (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
|
<p className="text-red-600 dark:text-red-400">خطا در دریافت آمار استفاده</p>
|
||||||
|
</div>
|
||||||
|
) : selectedUsage ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">نام کد تخفیف</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{selectedDiscount?.name || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">تعداد استفاده</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(selectedUsage.usage_count)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع تخفیف</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatCurrency(selectedUsage.total_amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">کاربران یونیک</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(selectedUsage.unique_users)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">اولین استفاده</p>
|
||||||
|
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{selectedUsage.first_used_at ? formatDateTime(selectedUsage.first_used_at) : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">آخرین استفاده</p>
|
||||||
|
<p className="text-base font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{selectedUsage.last_used_at ? formatDateTime(selectedUsage.last_used_at) : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/40 border border-gray-200 dark:border-gray-700 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">هیچ استفادهای ثبت نشده است</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<DeleteConfirmModal
|
<DeleteConfirmModal
|
||||||
isOpen={!!deleteId}
|
isOpen={!!deleteId}
|
||||||
onClose={() => setDeleteId(null)}
|
onClose={() => setDeleteId(null)}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,96 @@
|
||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { CreditCard, Loader2 } from 'lucide-react';
|
import { CreditCard, Loader2, TrendingUp, CheckCircle, XCircle, DollarSign } from 'lucide-react';
|
||||||
import { PageContainer } from '@/components/ui/Typography';
|
import { PageContainer } from '@/components/ui/Typography';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Table } from '@/components/ui/Table';
|
||||||
import { formatDateTime } from '@/utils/formatters';
|
import { formatDateTime } from '@/utils/formatters';
|
||||||
|
import { formatCurrency } from '@/utils/formatters';
|
||||||
|
import { formatWithThousands } from '@/utils/numberUtils';
|
||||||
import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks';
|
import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks';
|
||||||
import { IPGStatus, IPG_LABELS } from '../core/_models';
|
import { IPGStatus, IPG_LABELS } from '../core/_models';
|
||||||
|
import { usePaymentMethodsReport, usePaymentTransactionsReport } from '@/pages/reports/payment-statistics/core/_hooks';
|
||||||
|
import { TableColumn } from '@/types';
|
||||||
|
|
||||||
|
const getPaymentTypeLabel = (type: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
'bank-topup': 'افزایش موجودی کیف پول',
|
||||||
|
'card-to-card': 'پرداخت به روش کارت به کارت',
|
||||||
|
'debit-rial-wallet': 'پرداخت از کیف ریالی',
|
||||||
|
'debit-gold18k-wallet': 'پرداخت از کیف طلا',
|
||||||
|
unknown: 'نامشخص',
|
||||||
|
};
|
||||||
|
return labels[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
const IPGListPage = () => {
|
const IPGListPage = () => {
|
||||||
const { data, isLoading, error } = useIPGStatus();
|
const { data, isLoading, error } = useIPGStatus();
|
||||||
const { mutate: updateStatus, isPending } = useUpdateIPGStatus();
|
const { mutate: updateStatus, isPending } = useUpdateIPGStatus();
|
||||||
|
const [selectedPaymentType, setSelectedPaymentType] = useState<string | null>(null);
|
||||||
|
const [isTransactionsModalOpen, setIsTransactionsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const paymentReportFilters = useMemo(
|
||||||
|
() => ({
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
group_by_user: false,
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
data: paymentMethodsReport,
|
||||||
|
isLoading: isPaymentReportLoading,
|
||||||
|
error: paymentReportError,
|
||||||
|
} = usePaymentMethodsReport(paymentReportFilters);
|
||||||
|
|
||||||
|
const transactionFilters = useMemo(() => {
|
||||||
|
if (!selectedPaymentType) {
|
||||||
|
return { limit: 0, offset: 0 } as const;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
payment_type: selectedPaymentType,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
sort_by: 'date' as const,
|
||||||
|
sort_order: 'desc' as const,
|
||||||
|
};
|
||||||
|
}, [selectedPaymentType]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: paymentTransactionsReport,
|
||||||
|
isLoading: isTransactionsLoading,
|
||||||
|
error: paymentTransactionsError,
|
||||||
|
} = usePaymentTransactionsReport(transactionFilters, isTransactionsModalOpen && !!selectedPaymentType);
|
||||||
|
|
||||||
|
const transactionColumns: TableColumn[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{ key: 'order_number', label: 'شماره سفارش', align: 'right' },
|
||||||
|
{ key: 'customer_name', label: 'نام مشتری', align: 'right' },
|
||||||
|
{ key: 'amount', label: 'مبلغ', align: 'right' },
|
||||||
|
{ key: 'status', label: 'وضعیت', align: 'right' },
|
||||||
|
{ key: 'created_at', label: 'تاریخ', align: 'right' },
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const transactionTableData = (paymentTransactionsReport?.transactions || []).map((tx) => ({
|
||||||
|
order_number: tx.order_number || '-',
|
||||||
|
customer_name: tx.customer_name || '-',
|
||||||
|
amount: formatCurrency(tx.amount),
|
||||||
|
status: tx.status,
|
||||||
|
created_at: formatDateTime(tx.created_at),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleOpenTransactionsModal = (paymentType: string) => {
|
||||||
|
setSelectedPaymentType(paymentType);
|
||||||
|
setIsTransactionsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseTransactionsModal = () => {
|
||||||
|
setIsTransactionsModalOpen(false);
|
||||||
|
setSelectedPaymentType(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggle = (ipg: IPGStatus, newStatus: boolean) => {
|
const handleToggle = (ipg: IPGStatus, newStatus: boolean) => {
|
||||||
updateStatus({
|
updateStatus({
|
||||||
|
|
@ -116,6 +197,195 @@ const IPGListPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-6">
|
||||||
|
{isPaymentReportLoading ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">در حال بارگذاری گزارش پرداختها...</div>
|
||||||
|
</div>
|
||||||
|
) : paymentReportError ? (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
|
<p className="text-red-600 dark:text-red-400">خطا در دریافت گزارش پرداختها</p>
|
||||||
|
</div>
|
||||||
|
) : paymentMethodsReport?.summary ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||||
|
<CreditCard className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">کل تراکنشها</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(paymentMethodsReport.summary.total_transactions)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">موفق</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(paymentMethodsReport.summary.successful_transactions)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||||
|
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">ناموفق</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(paymentMethodsReport.summary.failed_transactions)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||||
|
<TrendingUp className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">نرخ موفقیت</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(paymentMethodsReport.summary.overall_success_rate.toFixed(2))}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 md:col-span-2 lg:col-span-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||||
|
<DollarSign className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع مبلغ</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatCurrency(paymentMethodsReport.summary.total_amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{paymentMethodsReport.summary.by_payment_type &&
|
||||||
|
Object.keys(paymentMethodsReport.summary.by_payment_type).length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||||
|
آمار تفکیکی روشهای پرداخت
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{Object.entries(paymentMethodsReport.summary.by_payment_type).map(([type, stats]) => (
|
||||||
|
<div
|
||||||
|
key={type}
|
||||||
|
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{getPaymentTypeLabel(type)}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenTransactionsModal(type)}
|
||||||
|
className="text-xs text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
مشاهده تراکنشها
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">کل:</span>
|
||||||
|
<span className="font-medium">{formatWithThousands(stats.count)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">موفق:</span>
|
||||||
|
<span className="font-medium text-green-600">
|
||||||
|
{formatWithThousands(stats.success_count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">ناموفق:</span>
|
||||||
|
<span className="font-medium text-red-600">
|
||||||
|
{formatWithThousands(stats.failed_count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">نرخ موفقیت:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{formatWithThousands(stats.success_rate.toFixed(2))}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isTransactionsModalOpen}
|
||||||
|
onClose={handleCloseTransactionsModal}
|
||||||
|
title={`تراکنشها - ${selectedPaymentType ? getPaymentTypeLabel(selectedPaymentType) : ''}`}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
{isTransactionsLoading ? (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">در حال بارگذاری تراکنشها...</div>
|
||||||
|
) : paymentTransactionsError ? (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
|
<p className="text-red-600 dark:text-red-400">خطا در دریافت تراکنشها</p>
|
||||||
|
</div>
|
||||||
|
) : paymentTransactionsReport ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">کل تراکنشها</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(paymentTransactionsReport.summary.total_transactions)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">موفق</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(paymentTransactionsReport.summary.successful_count)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">ناموفق</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatWithThousands(paymentTransactionsReport.summary.failed_count)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین مبلغ</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatCurrency(paymentTransactionsReport.summary.average_transaction_amount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||||
|
<Table columns={transactionColumns} data={transactionTableData} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">دادهای یافت نشد</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { QUERY_KEYS } from "@/utils/query-key";
|
import { QUERY_KEYS } from "@/utils/query-key";
|
||||||
import { getPaymentMethodsReport } from "./_requests";
|
import { getPaymentMethodsReport, getPaymentTransactionsReport } from "./_requests";
|
||||||
import { PaymentMethodsFilters } from "./_models";
|
import { PaymentMethodsFilters, PaymentTransactionsFilters } from "./_models";
|
||||||
|
|
||||||
export const usePaymentMethodsReport = (filters: PaymentMethodsFilters) => {
|
export const usePaymentMethodsReport = (filters: PaymentMethodsFilters) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
|
|
@ -11,3 +11,13 @@ export const usePaymentMethodsReport = (filters: PaymentMethodsFilters) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const usePaymentTransactionsReport = (
|
||||||
|
filters: PaymentTransactionsFilters,
|
||||||
|
enabled: boolean = true
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [QUERY_KEYS.GET_PAYMENT_TRANSACTIONS_REPORT, filters],
|
||||||
|
queryFn: () => getPaymentTransactionsReport(filters),
|
||||||
|
enabled: enabled && filters.limit > 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,61 @@ export interface PaymentMethodsResponse {
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PaymentTransactionsFilters {
|
||||||
|
user_id?: number;
|
||||||
|
phone_number?: string;
|
||||||
|
order_id?: number;
|
||||||
|
transaction_id?: string;
|
||||||
|
reference_number?: string;
|
||||||
|
status?: 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled';
|
||||||
|
payment_type?: string;
|
||||||
|
min_amount?: number;
|
||||||
|
max_amount?: number;
|
||||||
|
from_date?: string; // RFC3339
|
||||||
|
to_date?: string; // RFC3339
|
||||||
|
group_by_status?: boolean;
|
||||||
|
group_by_type?: boolean;
|
||||||
|
sort_by?: 'amount' | 'date' | 'status';
|
||||||
|
sort_order?: 'asc' | 'desc';
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentTransaction {
|
||||||
|
payment_id: number;
|
||||||
|
transaction_id: string;
|
||||||
|
reference_number: string;
|
||||||
|
user_id: number;
|
||||||
|
customer_name: string;
|
||||||
|
customer_phone: string;
|
||||||
|
invoice_id: number;
|
||||||
|
order_number: string;
|
||||||
|
amount: number; // ریال
|
||||||
|
payment_type: string;
|
||||||
|
status: string;
|
||||||
|
step: string;
|
||||||
|
created_at: string; // ISO 8601
|
||||||
|
updated_at: string; // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentTransactionsSummary {
|
||||||
|
total_transactions: number;
|
||||||
|
successful_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
cancelled_count: number;
|
||||||
|
refunded_count: number;
|
||||||
|
total_success_amount: number; // ریال
|
||||||
|
total_failed_amount: number; // ریال
|
||||||
|
success_rate: number; // درصد
|
||||||
|
average_transaction_amount: number; // ریال
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentTransactionsResponse {
|
||||||
|
transactions: PaymentTransaction[];
|
||||||
|
summary: PaymentTransactionsSummary;
|
||||||
|
total: number;
|
||||||
|
has_more: boolean;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,65 @@
|
||||||
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||||
import { API_ROUTES } from "@/constant/routes";
|
import { API_ROUTES } from "@/constant/routes";
|
||||||
import {
|
import {
|
||||||
PaymentMethodsFilters,
|
PaymentMethodsFilters,
|
||||||
PaymentMethodsResponse,
|
PaymentMethodsResponse,
|
||||||
|
PaymentTransactionsFilters,
|
||||||
|
PaymentTransactionsResponse,
|
||||||
} from "./_models";
|
} from "./_models";
|
||||||
|
|
||||||
export const getPaymentMethodsReport = async (
|
export const getPaymentMethodsReport = async (
|
||||||
filters: PaymentMethodsFilters
|
filters: PaymentMethodsFilters
|
||||||
): Promise<PaymentMethodsResponse> => {
|
): Promise<PaymentMethodsResponse> => {
|
||||||
const response = await httpPostRequest<PaymentMethodsResponse>(
|
const queryParams: Record<string, string | number | null> = {};
|
||||||
APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT),
|
|
||||||
filters
|
if (filters.user_id) queryParams.user_id = filters.user_id;
|
||||||
|
if (filters.payment_type) queryParams.payment_type = filters.payment_type;
|
||||||
|
if (filters.status) queryParams.status = filters.status;
|
||||||
|
if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
|
||||||
|
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
|
||||||
|
if (typeof filters.group_by_user === "boolean") {
|
||||||
|
queryParams.group_by_user = filters.group_by_user ? "true" : "false";
|
||||||
|
}
|
||||||
|
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
|
||||||
|
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
|
||||||
|
|
||||||
|
const response = await httpGetRequest<PaymentMethodsResponse>(
|
||||||
|
APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT, queryParams)
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPaymentTransactionsReport = async (
|
||||||
|
filters: PaymentTransactionsFilters
|
||||||
|
): Promise<PaymentTransactionsResponse> => {
|
||||||
|
const queryParams: Record<string, string | number | null> = {};
|
||||||
|
|
||||||
|
if (filters.user_id) queryParams.user_id = filters.user_id;
|
||||||
|
if (filters.phone_number) queryParams.phone_number = filters.phone_number;
|
||||||
|
if (filters.order_id) queryParams.order_id = filters.order_id;
|
||||||
|
if (filters.transaction_id)
|
||||||
|
queryParams.transaction_id = filters.transaction_id;
|
||||||
|
if (filters.reference_number)
|
||||||
|
queryParams.reference_number = filters.reference_number;
|
||||||
|
if (filters.status) queryParams.status = filters.status;
|
||||||
|
if (filters.payment_type) queryParams.payment_type = filters.payment_type;
|
||||||
|
if (filters.min_amount) queryParams.min_amount = filters.min_amount;
|
||||||
|
if (filters.max_amount) queryParams.max_amount = filters.max_amount;
|
||||||
|
if (filters.from_date) queryParams.from_date = filters.from_date;
|
||||||
|
if (filters.to_date) queryParams.to_date = filters.to_date;
|
||||||
|
if (typeof filters.group_by_status === "boolean") {
|
||||||
|
queryParams.group_by_status = filters.group_by_status ? "true" : "false";
|
||||||
|
}
|
||||||
|
if (typeof filters.group_by_type === "boolean") {
|
||||||
|
queryParams.group_by_type = filters.group_by_type ? "true" : "false";
|
||||||
|
}
|
||||||
|
if (filters.sort_by) queryParams.sort_by = filters.sort_by;
|
||||||
|
if (filters.sort_order) queryParams.sort_order = filters.sort_order;
|
||||||
|
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
|
||||||
|
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
|
||||||
|
|
||||||
|
const response = await httpGetRequest<PaymentTransactionsResponse>(
|
||||||
|
APIUrlGenerator(API_ROUTES.PAYMENT_TRANSACTIONS_REPORT, queryParams)
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService";
|
||||||
import { API_ROUTES } from "@/constant/routes";
|
import { API_ROUTES } from "@/constant/routes";
|
||||||
import {
|
import {
|
||||||
ShipmentsByMethodFilters,
|
ShipmentsByMethodFilters,
|
||||||
|
|
@ -8,9 +8,30 @@ import {
|
||||||
export const getShipmentsByMethodReport = async (
|
export const getShipmentsByMethodReport = async (
|
||||||
filters: ShipmentsByMethodFilters
|
filters: ShipmentsByMethodFilters
|
||||||
): Promise<ShipmentsByMethodResponse> => {
|
): Promise<ShipmentsByMethodResponse> => {
|
||||||
const response = await httpPostRequest<ShipmentsByMethodResponse>(
|
const queryParams: Record<string, string | number | null> = {};
|
||||||
APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT),
|
|
||||||
filters
|
if (filters.shipping_method_code)
|
||||||
|
queryParams.shipping_method_code = filters.shipping_method_code;
|
||||||
|
if (filters.shipping_method_id)
|
||||||
|
queryParams.shipping_method_id = filters.shipping_method_id;
|
||||||
|
if (filters.user_id) queryParams.user_id = filters.user_id;
|
||||||
|
if (filters.customer_name) queryParams.customer_name = filters.customer_name;
|
||||||
|
if (filters.status) queryParams.status = filters.status;
|
||||||
|
if (filters.payment_status) queryParams.payment_status = filters.payment_status;
|
||||||
|
if (filters.min_shipping_cost)
|
||||||
|
queryParams.min_shipping_cost = filters.min_shipping_cost;
|
||||||
|
if (filters.max_shipping_cost)
|
||||||
|
queryParams.max_shipping_cost = filters.max_shipping_cost;
|
||||||
|
if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
|
||||||
|
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
|
||||||
|
if (typeof filters.group_by_method === "boolean") {
|
||||||
|
queryParams.group_by_method = filters.group_by_method ? "true" : "false";
|
||||||
|
}
|
||||||
|
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
|
||||||
|
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
|
||||||
|
|
||||||
|
const response = await httpGetRequest<ShipmentsByMethodResponse>(
|
||||||
|
APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT, queryParams)
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { useShipmentsByMethodReport } from '../core/_hooks';
|
import { useShipmentsByMethodReport } from '../core/_hooks';
|
||||||
import { ShipmentsByMethodFilters } from '../core/_models';
|
import { ShipmentsByMethodFilters } from '../core/_models';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
@ -19,10 +20,24 @@ const formatWeight = (weight: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const ShipmentsByMethodReportPage = () => {
|
const ShipmentsByMethodReportPage = () => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialShippingMethodId = useMemo(() => {
|
||||||
|
const value = searchParams.get('shipping_method_id');
|
||||||
|
if (!value) return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}, [searchParams]);
|
||||||
|
const initialShippingMethodCode = useMemo(() => {
|
||||||
|
const value = searchParams.get('shipping_method_code');
|
||||||
|
return value || undefined;
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<ShipmentsByMethodFilters>({
|
const [filters, setFilters] = useState<ShipmentsByMethodFilters>({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
group_by_method: false,
|
group_by_method: false,
|
||||||
|
...(initialShippingMethodId ? { shipping_method_id: initialShippingMethodId } : {}),
|
||||||
|
...(initialShippingMethodCode ? { shipping_method_code: initialShippingMethodCode } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading, error } = useShipmentsByMethodReport(filters);
|
const { data, isLoading, error } = useShipmentsByMethodReport(filters);
|
||||||
|
|
@ -140,175 +155,6 @@ const ShipmentsByMethodReportPage = () => {
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageTitle>گزارش ارسالها بر اساس روش</PageTitle>
|
<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 */}
|
{/* Summary Cards */}
|
||||||
{data?.summary && (
|
{data?.summary && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Button } from '@/components/ui/Button';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { PageContainer } from '@/components/ui/Typography';
|
import { PageContainer } from '@/components/ui/Typography';
|
||||||
import { PageHeader } from '@/components/layout/PageHeader';
|
import { PageHeader } from '@/components/layout/PageHeader';
|
||||||
import { Plus, Edit3, Trash2, Truck } from 'lucide-react';
|
import { Plus, Edit3, Trash2, Truck, BarChart3 } from 'lucide-react';
|
||||||
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
|
import { useShippingMethods, useDeleteShippingMethod } from '../core/_hooks';
|
||||||
import { ShippingMethod } from '../core/_models';
|
import { ShippingMethod } from '../core/_models';
|
||||||
|
|
||||||
|
|
@ -27,6 +27,10 @@ const ShippingMethodsListPage = () => {
|
||||||
deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) });
|
deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewReport = (method: ShippingMethod) => {
|
||||||
|
navigate(`/shipping-methods/shipments-report?shipping_method_id=${method.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
|
|
@ -82,100 +86,114 @@ const ShippingMethodsListPage = () => {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="مدیریت روشهای ارسال"
|
title="مدیریت روشهای ارسال"
|
||||||
subtitle="تعریف و مدیریت روشهای ارسال سفارش"
|
subtitle="تعریف و مدیریت روشهای ارسال سفارش"
|
||||||
icon={Truck}
|
icon={Truck}
|
||||||
actions={
|
actions={
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
|
||||||
title="روش ارسال جدید"
|
title="روش ارسال جدید"
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-700">
|
<thead className="bg-gray-50 dark:bg-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">محدوده وزن</th>
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">محدوده وزن</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ساعات پاسخگویی</th>
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ساعات پاسخگویی</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">اولویت</th>
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">اولویت</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{(methods || []).map((m: ShippingMethod) => (
|
|
||||||
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.name}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.code}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.min_weight} - {m.max_weight}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{formatOpenHours(m.open_hours)}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.priority}</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
||||||
<span className={`px-2 py-1 rounded-md text-xs ${m.enabled ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}>{m.enabled ? 'فعال' : 'غیرفعال'}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={() => handleEdit(m.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="ویرایش">
|
|
||||||
<Edit3 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setDeleteId(m.id.toString())} className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300" title="حذف">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
</table>
|
{(methods || []).map((m: ShippingMethod) => (
|
||||||
|
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.name}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.code}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.min_weight} - {m.max_weight}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{formatOpenHours(m.open_hours)}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.priority}</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<span className={`px-2 py-1 rounded-md text-xs ${m.enabled ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}>{m.enabled ? 'فعال' : 'غیرفعال'}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewReport(m)}
|
||||||
|
className="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
|
title="گزارش ارسال"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleEdit(m.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="ویرایش">
|
||||||
|
<Edit3 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setDeleteId(m.id.toString())} className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300" title="حذف">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile */}
|
{/* Mobile */}
|
||||||
<div className="md:hidden p-4 space-y-4">
|
<div className="md:hidden p-4 space-y-4">
|
||||||
{(methods || []).map((m: ShippingMethod) => (
|
{(methods || []).map((m: ShippingMethod) => (
|
||||||
<div key={m.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<div key={m.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
<div className="flex justify-between items-start mb-3">
|
<div className="flex justify-between items-start mb-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{m.name}</h3>
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{m.name}</h3>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">کد: {m.code} • وزن: {m.min_weight}-{m.max_weight}</p>
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">کد: {m.code} • وزن: {m.min_weight}-{m.max_weight}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ساعات پاسخگویی: {formatOpenHours(m.open_hours)}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">اولویت: {m.priority}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleViewReport(m)}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-3 w-3" />
|
||||||
|
گزارش
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
|
||||||
|
<Edit3 className="h-3 w-3" />
|
||||||
|
ویرایش
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setDeleteId(m.id.toString())} className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ساعات پاسخگویی: {formatOpenHours(m.open_hours)}</div>
|
))}
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">اولویت: {m.priority}</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
|
|
||||||
<Edit3 className="h-3 w-3" />
|
|
||||||
ویرایش
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setDeleteId(m.id.toString())} className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
حذف
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف روش ارسال">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">آیا از حذف این روش ارسال اطمینان دارید؟</p>
|
|
||||||
<div className="flex justify-end space-x-2 space-x-reverse">
|
|
||||||
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
|
|
||||||
<Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
|
||||||
|
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف روش ارسال">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">آیا از حذف این روش ارسال اطمینان دارید؟</p>
|
||||||
|
<div className="flex justify-end space-x-2 space-x-reverse">
|
||||||
|
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
|
||||||
|
<Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ export const QUERY_KEYS = {
|
||||||
CREATE_DISCOUNT_CODE: "create_discount_code",
|
CREATE_DISCOUNT_CODE: "create_discount_code",
|
||||||
UPDATE_DISCOUNT_CODE: "update_discount_code",
|
UPDATE_DISCOUNT_CODE: "update_discount_code",
|
||||||
DELETE_DISCOUNT_CODE: "delete_discount_code",
|
DELETE_DISCOUNT_CODE: "delete_discount_code",
|
||||||
|
GET_DISCOUNT_REPORTS: "get_discount_reports",
|
||||||
|
|
||||||
// Orders
|
// Orders
|
||||||
GET_ORDERS: "get_orders",
|
GET_ORDERS: "get_orders",
|
||||||
|
|
@ -111,6 +112,10 @@ export const QUERY_KEYS = {
|
||||||
GET_TICKET_STATUSES: "get_ticket_statuses",
|
GET_TICKET_STATUSES: "get_ticket_statuses",
|
||||||
GET_TICKET_SUBJECTS: "get_ticket_subjects",
|
GET_TICKET_SUBJECTS: "get_ticket_subjects",
|
||||||
|
|
||||||
|
// Contact Us
|
||||||
|
GET_CONTACT_US_MESSAGES: "get_contact_us_messages",
|
||||||
|
DELETE_CONTACT_US_MESSAGE: "delete_contact_us_message",
|
||||||
|
|
||||||
// Payment IPG
|
// Payment IPG
|
||||||
GET_IPG_STATUS: "get_ipg_status",
|
GET_IPG_STATUS: "get_ipg_status",
|
||||||
UPDATE_IPG_STATUS: "update_ipg_status",
|
UPDATE_IPG_STATUS: "update_ipg_status",
|
||||||
|
|
@ -129,6 +134,7 @@ export const QUERY_KEYS = {
|
||||||
|
|
||||||
// Payment Statistics
|
// Payment Statistics
|
||||||
GET_PAYMENT_METHODS_REPORT: "get_payment_methods_report",
|
GET_PAYMENT_METHODS_REPORT: "get_payment_methods_report",
|
||||||
|
GET_PAYMENT_TRANSACTIONS_REPORT: "get_payment_transactions_report",
|
||||||
|
|
||||||
// Shipment Statistics
|
// Shipment Statistics
|
||||||
GET_SHIPMENTS_BY_METHOD_REPORT: "get_shipments_by_method_report",
|
GET_SHIPMENTS_BY_METHOD_REPORT: "get_shipments_by_method_report",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue