From 5b62d189f8bc7789f9a2e8e7d25a736d02d90f9c Mon Sep 17 00:00:00 2001 From: hosseintaromi Date: Fri, 23 Jan 2026 01:01:38 +0330 Subject: [PATCH] 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. --- src/App.tsx | 11 +- src/components/layout/Sidebar.tsx | 5 + src/constant/routes.ts | 6 + .../contact-us-list/ContactUsListPage.tsx | 173 +++++++++++ src/pages/contact-us/core/_hooks.ts | 28 ++ src/pages/contact-us/core/_models.ts | 25 ++ src/pages/contact-us/core/_requests.ts | 31 ++ src/pages/discount-codes/core/_hooks.ts | 12 + src/pages/discount-codes/core/_models.ts | 55 ++++ src/pages/discount-codes/core/_requests.ts | 38 +++ .../DiscountCodesListPage.tsx | 185 +++++++++++- .../payment-ipg/ipg-list/IPGListPage.tsx | 274 +++++++++++++++++- .../reports/payment-statistics/core/_hooks.ts | 14 +- .../payment-statistics/core/_models.ts | 58 ++++ .../payment-statistics/core/_requests.ts | 56 +++- .../shipment-statistics/core/_requests.ts | 29 +- .../ShipmentsByMethodReportPage.tsx | 186 +----------- .../ShippingMethodsListPage.tsx | 192 ++++++------ src/utils/query-key.ts | 6 + 19 files changed, 1102 insertions(+), 282 deletions(-) create mode 100644 src/pages/contact-us/contact-us-list/ContactUsListPage.tsx create mode 100644 src/pages/contact-us/core/_hooks.ts create mode 100644 src/pages/contact-us/core/_models.ts create mode 100644 src/pages/contact-us/core/_requests.ts diff --git a/src/App.tsx b/src/App.tsx index 8aba761..77e2ada 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,6 @@ import { AuthProvider } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; import { ErrorBoundary } from './components/common/ErrorBoundary'; -import { LoadingSpinner } from './components/ui/LoadingSpinner'; import { queryClient } from './lib/queryClient'; import { useAuth } from './contexts/AuthContext'; import { Layout } from './components/layout/Layout'; @@ -69,6 +68,7 @@ const ShippingMethodFormPage = lazy(() => import('./pages/shipping-methods/shipp const TicketsListPage = lazy(() => import('./pages/tickets/tickets-list/TicketsListPage')); const TicketDetailPage = lazy(() => import('./pages/tickets/ticket-detail/TicketDetailPage')); const TicketConfigPage = lazy(() => import('./pages/tickets/ticket-config/TicketConfigPage')); +const ContactUsListPage = lazy(() => import('./pages/contact-us/contact-us-list/ContactUsListPage')); // Payment IPG Page const IPGListPage = lazy(() => import('./pages/payment-ipg/ipg-list/IPGListPage')); @@ -165,11 +165,14 @@ const AppRoutes = () => { } /> } /> } /> + } /> } /> } /> } /> + } /> + {/* Products Routes */} } /> } /> @@ -203,11 +206,7 @@ const App = () => { - - - - }> + diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 1829ccb..179ee0f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -75,6 +75,11 @@ const menuItems: MenuItem[] = [ }, ] }, + { + title: 'پیام‌های تماس با ما', + icon: FileText, + path: '/contact-us', + }, { title: 'مدیریت محصولات', icon: Package, diff --git a/src/constant/routes.ts b/src/constant/routes.ts index 6fb0cf8..792f6ab 100644 --- a/src/constant/routes.ts +++ b/src/constant/routes.ts @@ -134,6 +134,10 @@ export const API_ROUTES = { UPDATE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`, DELETE_TICKET_SUBJECT: (id: string) => `tickets/config/subjects/${id}`, + // Contact Us APIs + GET_CONTACT_US_MESSAGES: "contact-us", + DELETE_CONTACT_US_MESSAGE: (id: string) => `contact-us/${id}`, + // Payment IPG APIs GET_IPG_STATUS: "payment/ipg/status", UPDATE_IPG_STATUS: "payment/ipg/status", @@ -147,9 +151,11 @@ export const API_ROUTES = { UPDATE_WALLET_STATUS: "wallet/status", // Reports APIs + DISCOUNT_REPORTS: "reports/discounts", DISCOUNT_USAGE_REPORT: "reports/discounts/usage", CUSTOMER_DISCOUNT_USAGE_REPORT: "reports/discounts/customer-usage", PAYMENT_METHODS_REPORT: "reports/payments/methods", + PAYMENT_TRANSACTIONS_REPORT: "reports/payments/transactions", SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method", // Product Comments APIs diff --git a/src/pages/contact-us/contact-us-list/ContactUsListPage.tsx b/src/pages/contact-us/contact-us-list/ContactUsListPage.tsx new file mode 100644 index 0000000..452d496 --- /dev/null +++ b/src/pages/contact-us/contact-us-list/ContactUsListPage.tsx @@ -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({ + limit: 20, + offset: 0, + }); + const [deleteTarget, setDeleteTarget] = useState( + 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 {display}; + }, + }, + { + 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) => ( +
+ +
+ ), + }, + ], + [] + ); + + 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 ( + +
+

خطا در دریافت پیام‌های تماس با ما

+
+
+ ); + } + + return ( + +
+ + +
+ {isLoading ? ( + + ) : messages.length === 0 ? ( +
+ +

+ پیامی یافت نشد +

+

+ هنوز پیامی برای نمایش وجود ندارد +

+
+ ) : ( +
+ )} + + + {messages.length > 0 && totalPages > 1 && ( + + )} + + + setDeleteTarget(null)} + onConfirm={handleDeleteConfirm} + title="حذف پیام تماس با ما" + message="آیا از حذف این پیام اطمینان دارید؟ این عمل قابل بازگشت نیست." + isLoading={deleteMessageMutation.isPending} + /> + + ); +}; + +export default ContactUsListPage; diff --git a/src/pages/contact-us/core/_hooks.ts b/src/pages/contact-us/core/_hooks.ts new file mode 100644 index 0000000..4875621 --- /dev/null +++ b/src/pages/contact-us/core/_hooks.ts @@ -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 || "خطا در حذف پیام تماس با ما"); + }, + }); +}; diff --git a/src/pages/contact-us/core/_models.ts b/src/pages/contact-us/core/_models.ts new file mode 100644 index 0000000..715c7ed --- /dev/null +++ b/src/pages/contact-us/core/_models.ts @@ -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; +} diff --git a/src/pages/contact-us/core/_requests.ts b/src/pages/contact-us/core/_requests.ts new file mode 100644 index 0000000..a922b90 --- /dev/null +++ b/src/pages/contact-us/core/_requests.ts @@ -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 = { + limit: limitValue, + offset: filters?.offset ?? 0, + }; + + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_CONTACT_US_MESSAGES, queryParams) + ); + return response.data; +}; + +export const deleteContactUsMessage = async (id: string | number) => { + const response = await httpDeleteRequest( + APIUrlGenerator(API_ROUTES.DELETE_CONTACT_US_MESSAGE(id.toString())) + ); + return response.data; +}; diff --git a/src/pages/discount-codes/core/_hooks.ts b/src/pages/discount-codes/core/_hooks.ts index 0f3addc..ff61459 100644 --- a/src/pages/discount-codes/core/_hooks.ts +++ b/src/pages/discount-codes/core/_hooks.ts @@ -7,11 +7,13 @@ import { createDiscountCode, updateDiscountCode, deleteDiscountCode, + getDiscountReports, } from "./_requests"; import { CreateDiscountCodeRequest, UpdateDiscountCodeRequest, DiscountCodeFilters, + DiscountReportFilters, } from "./_models"; export const useDiscountCodes = (filters?: DiscountCodeFilters) => { @@ -84,3 +86,13 @@ export const useDeleteDiscountCode = () => { }); }; +export const useDiscountReports = ( + filters?: DiscountReportFilters, + enabled: boolean = true +) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_DISCOUNT_REPORTS, filters], + queryFn: () => getDiscountReports(filters), + enabled, + }); +}; diff --git a/src/pages/discount-codes/core/_models.ts b/src/pages/discount-codes/core/_models.ts index 7eef745..ad8bf34 100644 --- a/src/pages/discount-codes/core/_models.ts +++ b/src/pages/discount-codes/core/_models.ts @@ -13,6 +13,17 @@ export type DiscountApplicationLevels = export type DiscountStatus = "active" | "inactive"; +export type DiscountReportViewMode = "simple" | "detailed"; + +export type DiscountReportSortBy = + | "usage_count" + | "amount" + | "date" + | "code" + | "created_at"; + +export type DiscountReportSortOrder = "asc" | "desc"; + export type UserGroup = "new" | "loyal" | "all"; export interface DiscountUserRestrictions { @@ -73,6 +84,50 @@ export interface DiscountCodeFilters { active_only?: boolean; } +export interface DiscountReportFilters { + view_mode?: DiscountReportViewMode; + discount_code?: string; + discount_id?: number; + user_id?: number; + status?: "active" | "inactive" | "expired"; + type?: DiscountCodeType; + application_level?: DiscountApplicationLevel; + from_date?: string; + to_date?: string; + min_usage_count?: number; + include_unused?: boolean; + group_by_code?: boolean; + sort_by?: DiscountReportSortBy; + sort_order?: DiscountReportSortOrder; + limit?: number; + offset?: number; +} + +export interface DiscountReportUsage { + discount_id: number; + discount_code: string; + discount_name: string; + usage_count: number; + total_amount: number; + unique_users: number; + first_used_at: string; + last_used_at: string; +} + +export interface DiscountReportSummarySimple { + total_usages: number; + total_discount_given: number; + unique_users: number; + unique_codes: number; +} + +export interface DiscountReportSimpleResponse { + usages: DiscountReportUsage[]; + summary: DiscountReportSummarySimple; + total: number; + has_more: boolean; +} + export interface CreateDiscountCodeRequest { code: string; name: string; diff --git a/src/pages/discount-codes/core/_requests.ts b/src/pages/discount-codes/core/_requests.ts index fdcc9a0..78764df 100644 --- a/src/pages/discount-codes/core/_requests.ts +++ b/src/pages/discount-codes/core/_requests.ts @@ -12,6 +12,8 @@ import { DiscountCode, DiscountCodeFilters, PaginatedDiscountCodesResponse, + DiscountReportFilters, + DiscountReportSimpleResponse, } from "./_models"; export const getDiscountCodes = async (filters?: DiscountCodeFilters) => { @@ -75,3 +77,39 @@ export const deleteDiscountCode = async (id: string) => { ); return response.data; }; + +export const getDiscountReports = async ( + filters?: DiscountReportFilters +) => { + const queryParams: Record = {}; + + 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( + APIUrlGenerator(API_ROUTES.DISCOUNT_REPORTS, queryParams) + ); + + return response.data; +}; diff --git a/src/pages/discount-codes/discount-codes-list/DiscountCodesListPage.tsx b/src/pages/discount-codes/discount-codes-list/DiscountCodesListPage.tsx index fa0b2e5..0bb07b7 100644 --- a/src/pages/discount-codes/discount-codes-list/DiscountCodesListPage.tsx +++ b/src/pages/discount-codes/discount-codes-list/DiscountCodesListPage.tsx @@ -1,10 +1,10 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useDiscountCodes, useDeleteDiscountCode } from '../core/_hooks'; +import { useDiscountCodes, useDeleteDiscountCode, useDiscountReports } from '../core/_hooks'; import { DiscountCode } from '../core/_models'; import { Table } from "@/components/ui/Table"; 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 { PageHeader } from "@/components/layout/PageHeader"; import { FiltersSection } from "@/components/common/FiltersSection"; @@ -12,18 +12,49 @@ import { EmptyState } from "@/components/common/EmptyState"; import { DeleteConfirmModal } from "@/components/common/DeleteConfirmModal"; import { ActionButtons } from "@/components/common/ActionButtons"; import { StatusBadge } from "@/components/ui/StatusBadge"; -import { 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 navigate = useNavigate(); const [deleteId, setDeleteId] = useState(null); const [filters, setFilters] = useState({ code: '' }); + const [selectedDiscount, setSelectedDiscount] = useState(null); + const [isUsageModalOpen, setIsUsageModalOpen] = useState(false); const { data: discountCodes, isLoading, error } = useDiscountCodes(filters); const { mutate: deleteDiscount, isPending: isDeleting } = useDeleteDiscountCode(); + const summaryFilters = useMemo( + () => ({ + view_mode: 'simple' as const, + limit: 1, + offset: 0, + }), + [] + ); + const { data: discountReport, isLoading: isReportLoading, error: reportError } = + useDiscountReports(summaryFilters); + const usageFilters = useMemo(() => { + if (!selectedDiscount) return undefined; + return { + view_mode: 'simple' as const, + discount_id: selectedDiscount.id, + include_unused: true, + limit: 1, + offset: 0, + }; + }, [selectedDiscount]); + const { + data: usageReport, + isLoading: isUsageLoading, + error: usageError, + } = useDiscountReports(usageFilters, isUsageModalOpen && !!selectedDiscount); + const selectedUsage = usageReport?.usages?.[0]; - const handleCreate = () => navigate('/discount-codes/create'); - const handleEdit = (id: number) => navigate(`/discount-codes/${id}/edit`); + const handleCreate = useCallback(() => navigate('/discount-codes/create'), [navigate]); + const handleEdit = useCallback((id: number) => navigate(`/discount-codes/${id}/edit`), [navigate]); const handleDeleteConfirm = () => { if (deleteId) { @@ -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(() => [ { key: 'code', label: 'کد', sortable: true }, { key: 'name', label: 'نام', sortable: true }, @@ -63,12 +104,14 @@ const DiscountCodesListPage = () => { label: 'عملیات', render: (_val, row: any) => ( handleOpenUsageModal(row as DiscountCode)} + viewTitle="آمار استفاده" onEdit={() => handleEdit(row.id)} onDelete={() => setDeleteId(row.id.toString())} /> ) } - ], [navigate]); + ], [handleEdit, handleOpenUsageModal]); if (error) { return ( @@ -114,6 +157,74 @@ const DiscountCodesListPage = () => { + {isReportLoading ? ( +
+ +
+ ) : reportError ? ( +
+

خطا در دریافت آمار تخفیف

+
+ ) : discountReport?.summary ? ( +
+
+
+
+ +
+
+

کل استفاده‌ها

+

+ {formatWithThousands(discountReport.summary.total_usages)} +

+
+
+
+ +
+
+
+ +
+
+

مجموع تخفیف داده شده

+

+ {formatCurrency(discountReport.summary.total_discount_given)} +

+
+
+
+ +
+
+
+ +
+
+

کاربران یونیک

+

+ {formatWithThousands(discountReport.summary.unique_users)} +

+
+
+
+ +
+
+
+ +
+
+

کدهای یونیک

+

+ {formatWithThousands(discountReport.summary.unique_codes)} +

+
+
+
+
+ ) : null} + {isLoading ? (
) : !discountCodes || discountCodes.length === 0 ? ( @@ -135,6 +246,66 @@ const DiscountCodesListPage = () => {
)} + + {isUsageLoading ? ( + + ) : usageError ? ( +
+

خطا در دریافت آمار استفاده

+
+ ) : selectedUsage ? ( +
+
+

نام کد تخفیف

+

+ {selectedDiscount?.name || '-'} +

+
+
+
+

تعداد استفاده

+

+ {formatWithThousands(selectedUsage.usage_count)} +

+
+
+

مجموع تخفیف

+

+ {formatCurrency(selectedUsage.total_amount)} +

+
+
+

کاربران یونیک

+

+ {formatWithThousands(selectedUsage.unique_users)} +

+
+
+

اولین استفاده

+

+ {selectedUsage.first_used_at ? formatDateTime(selectedUsage.first_used_at) : '-'} +

+
+
+

آخرین استفاده

+

+ {selectedUsage.last_used_at ? formatDateTime(selectedUsage.last_used_at) : '-'} +

+
+
+
+ ) : ( +
+

هیچ استفاده‌ای ثبت نشده است

+
+ )} +
+ setDeleteId(null)} diff --git a/src/pages/payment-ipg/ipg-list/IPGListPage.tsx b/src/pages/payment-ipg/ipg-list/IPGListPage.tsx index 9768f95..3e82ee4 100644 --- a/src/pages/payment-ipg/ipg-list/IPGListPage.tsx +++ b/src/pages/payment-ipg/ipg-list/IPGListPage.tsx @@ -1,15 +1,96 @@ -import React from 'react'; -import { CreditCard, Loader2 } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { CreditCard, Loader2, TrendingUp, CheckCircle, XCircle, DollarSign } from 'lucide-react'; import { PageContainer } from '@/components/ui/Typography'; import { PageHeader } from '@/components/layout/PageHeader'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { Modal } from '@/components/ui/Modal'; +import { Table } from '@/components/ui/Table'; import { formatDateTime } from '@/utils/formatters'; +import { formatCurrency } from '@/utils/formatters'; +import { formatWithThousands } from '@/utils/numberUtils'; import { useIPGStatus, useUpdateIPGStatus } from '../core/_hooks'; import { IPGStatus, IPG_LABELS } from '../core/_models'; +import { usePaymentMethodsReport, usePaymentTransactionsReport } from '@/pages/reports/payment-statistics/core/_hooks'; +import { TableColumn } from '@/types'; + +const getPaymentTypeLabel = (type: string): string => { + const labels: Record = { + 'bank-topup': 'افزایش موجودی کیف پول', + 'card-to-card': 'پرداخت به روش کارت به کارت', + 'debit-rial-wallet': 'پرداخت از کیف ریالی', + 'debit-gold18k-wallet': 'پرداخت از کیف طلا', + unknown: 'نامشخص', + }; + return labels[type] || type; +}; const IPGListPage = () => { const { data, isLoading, error } = useIPGStatus(); const { mutate: updateStatus, isPending } = useUpdateIPGStatus(); + const [selectedPaymentType, setSelectedPaymentType] = useState(null); + const [isTransactionsModalOpen, setIsTransactionsModalOpen] = useState(false); + + const paymentReportFilters = useMemo( + () => ({ + limit: 50, + offset: 0, + group_by_user: false, + }), + [] + ); + const { + data: paymentMethodsReport, + isLoading: isPaymentReportLoading, + error: paymentReportError, + } = usePaymentMethodsReport(paymentReportFilters); + + const transactionFilters = useMemo(() => { + if (!selectedPaymentType) { + return { limit: 0, offset: 0 } as const; + } + return { + payment_type: selectedPaymentType, + limit: 20, + offset: 0, + sort_by: 'date' as const, + sort_order: 'desc' as const, + }; + }, [selectedPaymentType]); + + const { + data: paymentTransactionsReport, + isLoading: isTransactionsLoading, + error: paymentTransactionsError, + } = usePaymentTransactionsReport(transactionFilters, isTransactionsModalOpen && !!selectedPaymentType); + + const transactionColumns: TableColumn[] = useMemo( + () => [ + { key: 'order_number', label: 'شماره سفارش', align: 'right' }, + { key: 'customer_name', label: 'نام مشتری', align: 'right' }, + { key: 'amount', label: 'مبلغ', align: 'right' }, + { key: 'status', label: 'وضعیت', align: 'right' }, + { key: 'created_at', label: 'تاریخ', align: 'right' }, + ], + [] + ); + + const transactionTableData = (paymentTransactionsReport?.transactions || []).map((tx) => ({ + order_number: tx.order_number || '-', + customer_name: tx.customer_name || '-', + amount: formatCurrency(tx.amount), + status: tx.status, + created_at: formatDateTime(tx.created_at), + })); + + const handleOpenTransactionsModal = (paymentType: string) => { + setSelectedPaymentType(paymentType); + setIsTransactionsModalOpen(true); + }; + + const handleCloseTransactionsModal = () => { + setIsTransactionsModalOpen(false); + setSelectedPaymentType(null); + }; const handleToggle = (ipg: IPGStatus, newStatus: boolean) => { updateStatus({ @@ -116,6 +197,195 @@ const IPGListPage = () => { + +
+ {isPaymentReportLoading ? ( +
+
در حال بارگذاری گزارش پرداخت‌ها...
+
+ ) : paymentReportError ? ( +
+

خطا در دریافت گزارش پرداخت‌ها

+
+ ) : paymentMethodsReport?.summary ? ( + <> +
+
+
+
+ +
+
+

کل تراکنش‌ها

+

+ {formatWithThousands(paymentMethodsReport.summary.total_transactions)} +

+
+
+
+ +
+
+
+ +
+
+

موفق

+

+ {formatWithThousands(paymentMethodsReport.summary.successful_transactions)} +

+
+
+
+ +
+
+
+ +
+
+

ناموفق

+

+ {formatWithThousands(paymentMethodsReport.summary.failed_transactions)} +

+
+
+
+ +
+
+
+ +
+
+

نرخ موفقیت

+

+ {formatWithThousands(paymentMethodsReport.summary.overall_success_rate.toFixed(2))}% +

+
+
+
+ +
+
+
+ +
+
+

مجموع مبلغ

+

+ {formatCurrency(paymentMethodsReport.summary.total_amount)} +

+
+
+
+
+ + {paymentMethodsReport.summary.by_payment_type && + Object.keys(paymentMethodsReport.summary.by_payment_type).length > 0 && ( +
+

+ آمار تفکیکی روش‌های پرداخت +

+
+ {Object.entries(paymentMethodsReport.summary.by_payment_type).map(([type, stats]) => ( +
+
+

+ {getPaymentTypeLabel(type)} +

+ +
+
+
+ کل: + {formatWithThousands(stats.count)} +
+
+ موفق: + + {formatWithThousands(stats.success_count)} + +
+
+ ناموفق: + + {formatWithThousands(stats.failed_count)} + +
+
+ نرخ موفقیت: + + {formatWithThousands(stats.success_rate.toFixed(2))}% + +
+
+
+ ))} +
+
+ )} + + ) : null} +
+ + + {isTransactionsLoading ? ( +
در حال بارگذاری تراکنش‌ها...
+ ) : paymentTransactionsError ? ( +
+

خطا در دریافت تراکنش‌ها

+
+ ) : paymentTransactionsReport ? ( +
+
+
+

کل تراکنش‌ها

+

+ {formatWithThousands(paymentTransactionsReport.summary.total_transactions)} +

+
+
+

موفق

+

+ {formatWithThousands(paymentTransactionsReport.summary.successful_count)} +

+
+
+

ناموفق

+

+ {formatWithThousands(paymentTransactionsReport.summary.failed_count)} +

+
+
+

میانگین مبلغ

+

+ {formatCurrency(paymentTransactionsReport.summary.average_transaction_amount)} +

+
+
+ +
+
+ + + ) : ( +
داده‌ای یافت نشد
+ )} + ); }; diff --git a/src/pages/reports/payment-statistics/core/_hooks.ts b/src/pages/reports/payment-statistics/core/_hooks.ts index 28c3d68..42860e7 100644 --- a/src/pages/reports/payment-statistics/core/_hooks.ts +++ b/src/pages/reports/payment-statistics/core/_hooks.ts @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { QUERY_KEYS } from "@/utils/query-key"; -import { getPaymentMethodsReport } from "./_requests"; -import { PaymentMethodsFilters } from "./_models"; +import { getPaymentMethodsReport, getPaymentTransactionsReport } from "./_requests"; +import { PaymentMethodsFilters, PaymentTransactionsFilters } from "./_models"; export const usePaymentMethodsReport = (filters: PaymentMethodsFilters) => { 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, + }); +}; diff --git a/src/pages/reports/payment-statistics/core/_models.ts b/src/pages/reports/payment-statistics/core/_models.ts index efdb075..dbc2570 100644 --- a/src/pages/reports/payment-statistics/core/_models.ts +++ b/src/pages/reports/payment-statistics/core/_models.ts @@ -54,3 +54,61 @@ export interface PaymentMethodsResponse { 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; +} diff --git a/src/pages/reports/payment-statistics/core/_requests.ts b/src/pages/reports/payment-statistics/core/_requests.ts index ba9d9fb..ce849df 100644 --- a/src/pages/reports/payment-statistics/core/_requests.ts +++ b/src/pages/reports/payment-statistics/core/_requests.ts @@ -1,17 +1,65 @@ -import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService"; +import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService"; import { API_ROUTES } from "@/constant/routes"; import { PaymentMethodsFilters, PaymentMethodsResponse, + PaymentTransactionsFilters, + PaymentTransactionsResponse, } from "./_models"; export const getPaymentMethodsReport = async ( filters: PaymentMethodsFilters ): Promise => { - const response = await httpPostRequest( - APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT), - filters + const queryParams: Record = {}; + + 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( + APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT, queryParams) ); return response.data; }; +export const getPaymentTransactionsReport = async ( + filters: PaymentTransactionsFilters +): Promise => { + const queryParams: Record = {}; + + 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( + APIUrlGenerator(API_ROUTES.PAYMENT_TRANSACTIONS_REPORT, queryParams) + ); + return response.data; +}; diff --git a/src/pages/reports/shipment-statistics/core/_requests.ts b/src/pages/reports/shipment-statistics/core/_requests.ts index 38d0059..2c7eada 100644 --- a/src/pages/reports/shipment-statistics/core/_requests.ts +++ b/src/pages/reports/shipment-statistics/core/_requests.ts @@ -1,4 +1,4 @@ -import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService"; +import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService"; import { API_ROUTES } from "@/constant/routes"; import { ShipmentsByMethodFilters, @@ -8,9 +8,30 @@ import { export const getShipmentsByMethodReport = async ( filters: ShipmentsByMethodFilters ): Promise => { - const response = await httpPostRequest( - APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT), - filters + const queryParams: Record = {}; + + 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( + APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT, queryParams) ); return response.data; }; diff --git a/src/pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage.tsx b/src/pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage.tsx index 3b4a9a1..b91ce69 100644 --- a/src/pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage.tsx +++ b/src/pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage.tsx @@ -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 { ShipmentsByMethodFilters } from '../core/_models'; import { Button } from '@/components/ui/Button'; @@ -19,10 +20,24 @@ const formatWeight = (weight: number) => { }; 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({ limit: 50, offset: 0, group_by_method: false, + ...(initialShippingMethodId ? { shipping_method_id: initialShippingMethodId } : {}), + ...(initialShippingMethodCode ? { shipping_method_code: initialShippingMethodCode } : {}), }); const { data, isLoading, error } = useShipmentsByMethodReport(filters); @@ -140,175 +155,6 @@ const ShipmentsByMethodReportPage = () => { گزارش ارسال‌ها بر اساس روش - {/* Filters */} -
-
-
- -

فیلترها

-
- -
- -
-
- - -
- -
- - handleNumericFilterChange('shipping_method_id', e.target.value)} - placeholder="مثلاً 1" - numeric - /> -
- -
- - handleNumericFilterChange('user_id', e.target.value)} - placeholder="مثلاً 456" - numeric - /> -
- -
- - handleFilterChange('customer_name', e.target.value || undefined)} - placeholder="جستجو در نام" - /> -
- -
- - -
- -
- - -
- -
- - handleNumericFilterChange('min_shipping_cost', e.target.value)} - placeholder="مثلاً 10000" - numeric - thousandSeparator - /> -
- -
- - handleNumericFilterChange('max_shipping_cost', e.target.value)} - placeholder="مثلاً 50000" - numeric - thousandSeparator - /> -
- -
- - handleDateRangeChange(value, filters.date_range?.to)} - placeholder="انتخاب تاریخ شروع" - /> -
- -
- - handleDateRangeChange(filters.date_range?.from, value)} - placeholder="انتخاب تاریخ پایان" - /> -
- -
- -
-
-
- {/* Summary Cards */} {data?.summary && (
diff --git a/src/pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage.tsx b/src/pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage.tsx index ba8be8f..4431ebb 100644 --- a/src/pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage.tsx +++ b/src/pages/shipping-methods/shipping-methods-list/ShippingMethodsListPage.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; import { PageContainer } from '@/components/ui/Typography'; 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 { ShippingMethod } from '../core/_models'; @@ -27,6 +27,10 @@ const ShippingMethodsListPage = () => { deleteMethod(deleteId, { onSuccess: () => setDeleteId(null) }); }; + const handleViewReport = (method: ShippingMethod) => { + navigate(`/shipping-methods/shipments-report?shipping_method_id=${method.id}`); + }; + if (isLoading) { return ( @@ -82,100 +86,114 @@ const ShippingMethodsListPage = () => { return (
- - - - } - /> + + + + } + /> -
-
-
-
- - - - - - - - - - - - - {(methods || []).map((m: ShippingMethod) => ( - - - - - - - - +
+
+
+
نامکدمحدوده وزنساعات پاسخگوییاولویتوضعیتعملیات
{m.name}{m.code}{m.min_weight} - {m.max_weight}{formatOpenHours(m.open_hours)}{m.priority} - {m.enabled ? 'فعال' : 'غیرفعال'} - -
- - -
-
+ + + + + + + + + - ))} - -
نامکدمحدوده وزنساعات پاسخگوییاولویتوضعیتعملیات
+ + + {(methods || []).map((m: ShippingMethod) => ( + + {m.name} + {m.code} + {m.min_weight} - {m.max_weight} + {formatOpenHours(m.open_hours)} + {m.priority} + + {m.enabled ? 'فعال' : 'غیرفعال'} + + +
+ + + +
+ + + ))} + + +
- - {/* Mobile */} -
- {(methods || []).map((m: ShippingMethod) => ( -
-
-
-

{m.name}

-

کد: {m.code} • وزن: {m.min_weight}-{m.max_weight}

+ {/* Mobile */} +
+ {(methods || []).map((m: ShippingMethod) => ( +
+
+
+

{m.name}

+

کد: {m.code} • وزن: {m.min_weight}-{m.max_weight}

+
+
+
ساعات پاسخگویی: {formatOpenHours(m.open_hours)}
+
اولویت: {m.priority}
+
+ + +
-
ساعات پاسخگویی: {formatOpenHours(m.open_hours)}
-
اولویت: {m.priority}
-
- - -
-
- ))} -
-
- - setDeleteId(null)} title="حذف روش ارسال"> -
-

آیا از حذف این روش ارسال اطمینان دارید؟

-
- - + ))}
-
+ + setDeleteId(null)} title="حذف روش ارسال"> +
+

آیا از حذف این روش ارسال اطمینان دارید؟

+
+ + +
+
+
); diff --git a/src/utils/query-key.ts b/src/utils/query-key.ts index 5128811..07c3e1b 100644 --- a/src/utils/query-key.ts +++ b/src/utils/query-key.ts @@ -80,6 +80,7 @@ export const QUERY_KEYS = { CREATE_DISCOUNT_CODE: "create_discount_code", UPDATE_DISCOUNT_CODE: "update_discount_code", DELETE_DISCOUNT_CODE: "delete_discount_code", + GET_DISCOUNT_REPORTS: "get_discount_reports", // Orders GET_ORDERS: "get_orders", @@ -111,6 +112,10 @@ export const QUERY_KEYS = { GET_TICKET_STATUSES: "get_ticket_statuses", 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 GET_IPG_STATUS: "get_ipg_status", UPDATE_IPG_STATUS: "update_ipg_status", @@ -129,6 +134,7 @@ export const QUERY_KEYS = { // Payment Statistics GET_PAYMENT_METHODS_REPORT: "get_payment_methods_report", + GET_PAYMENT_TRANSACTIONS_REPORT: "get_payment_transactions_report", // Shipment Statistics GET_SHIPMENTS_BY_METHOD_REPORT: "get_shipments_by_method_report",