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 { 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 = () => {
|
|||
<Route path="shipping-methods" element={<ShippingMethodsListPage />} />
|
||||
<Route path="shipping-methods/create" element={<ShippingMethodFormPage />} />
|
||||
<Route path="shipping-methods/:id/edit" element={<ShippingMethodFormPage />} />
|
||||
<Route path="shipping-methods/shipments-report" element={<ShipmentsByMethodReportPage />} />
|
||||
|
||||
<Route path="tickets" element={<TicketsListPage />} />
|
||||
<Route path="tickets/config" element={<TicketConfigPage />} />
|
||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||
|
||||
<Route path="contact-us" element={<ContactUsListPage />} />
|
||||
|
||||
{/* Products Routes */}
|
||||
<Route path="products/create" element={<ProductFormPage />} />
|
||||
<Route path="products/:id" element={<ProductDetailPage />} />
|
||||
|
|
@ -203,11 +206,7 @@ const App = () => {
|
|||
<ToastProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
}>
|
||||
<Suspense fallback={null}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
</Router>
|
||||
|
|
|
|||
|
|
@ -75,6 +75,11 @@ const menuItems: MenuItem[] = [
|
|||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'پیامهای تماس با ما',
|
||||
icon: FileText,
|
||||
path: '/contact-us',
|
||||
},
|
||||
{
|
||||
title: 'مدیریت محصولات',
|
||||
icon: Package,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { MessageSquare, Trash2 } from 'lucide-react';
|
||||
import { PageContainer } from '@/components/ui/Typography';
|
||||
import { PageHeader } from '@/components/layout/PageHeader';
|
||||
import { Table } from '@/components/ui/Table';
|
||||
import { TableColumn } from '@/types';
|
||||
import { Pagination } from '@/components/ui/Pagination';
|
||||
import { DeleteConfirmModal } from '@/components/common/DeleteConfirmModal';
|
||||
import { englishToPersian } from '@/utils/numberUtils';
|
||||
import { formatDateTime } from '@/utils/formatters';
|
||||
import { useContactUsMessages, useDeleteContactUsMessage } from '../core/_hooks';
|
||||
import { ContactUsFilters, ContactUsMessage } from '../core/_models';
|
||||
|
||||
const ContactUsListPage: React.FC = () => {
|
||||
const [filters, setFilters] = useState<ContactUsFilters>({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
const [deleteTarget, setDeleteTarget] = useState<ContactUsMessage | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const { data, isLoading, error } = useContactUsMessages(filters);
|
||||
const deleteMessageMutation = useDeleteContactUsMessage();
|
||||
|
||||
const messages = data?.messages || [];
|
||||
const total = data?.total ?? messages.length;
|
||||
const limit = filters.limit || 20;
|
||||
const currentPage = Math.floor((filters.offset || 0) / limit) + 1;
|
||||
const totalPages = total > 0 ? Math.ceil(total / limit) : 1;
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
offset: (page - 1) * prev.limit,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteTarget) return;
|
||||
deleteMessageMutation.mutate(deleteTarget.ID, {
|
||||
onSuccess: () => setDeleteTarget(null),
|
||||
});
|
||||
};
|
||||
|
||||
const columns: TableColumn[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'شناسه',
|
||||
align: 'center',
|
||||
render: (value: number) => englishToPersian(value),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'نام',
|
||||
align: 'right',
|
||||
render: (value: string) => value || '-',
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
label: 'شماره تماس',
|
||||
align: 'left',
|
||||
render: (value: string) => {
|
||||
const display = value ? englishToPersian(value) : '-';
|
||||
return <span dir="ltr">{display}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
label: 'پیام',
|
||||
align: 'right',
|
||||
render: (value: string) => {
|
||||
if (!value) return '-';
|
||||
return value.length > 120 ? `${value.slice(0, 120)}...` : value;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'تاریخ',
|
||||
align: 'right',
|
||||
render: (value: string) => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'عملیات',
|
||||
align: 'center',
|
||||
render: (_val, row: any) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(row.raw)}
|
||||
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1"
|
||||
title="حذف پیام"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const tableData = messages.map((message) => ({
|
||||
id: message.ID,
|
||||
name: message.Name || '-',
|
||||
phone: message.PhoneNumber || '-',
|
||||
message: message.Message || '-',
|
||||
created_at: message.CreatedAt,
|
||||
raw: message,
|
||||
}));
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">خطا در دریافت پیامهای تماس با ما</p>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="پیامهای تماس با ما"
|
||||
subtitle="لیست پیامهای ارسالشده توسط کاربران"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{isLoading ? (
|
||||
<Table columns={columns} data={[]} loading={true} />
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<MessageSquare className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
پیامی یافت نشد
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
هنوز پیامی برای نمایش وجود ندارد
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table columns={columns} data={tableData} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{messages.length > 0 && totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
itemsPerPage={limit}
|
||||
totalItems={total}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DeleteConfirmModal
|
||||
isOpen={!!deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="حذف پیام تماس با ما"
|
||||
message="آیا از حذف این پیام اطمینان دارید؟ این عمل قابل بازگشت نیست."
|
||||
isLoading={deleteMessageMutation.isPending}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactUsListPage;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { QUERY_KEYS } from "@/utils/query-key";
|
||||
import { getContactUsMessages, deleteContactUsMessage } from "./_requests";
|
||||
import { ContactUsFilters } from "./_models";
|
||||
|
||||
export const useContactUsMessages = (filters?: ContactUsFilters) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES, filters],
|
||||
queryFn: () => getContactUsMessages(filters),
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteContactUsMessage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string | number) => deleteContactUsMessage(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [QUERY_KEYS.GET_CONTACT_US_MESSAGES],
|
||||
});
|
||||
toast.success("پیام تماس با ما حذف شد");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error?.message || "خطا در حذف پیام تماس با ما");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
export interface ContactUsMessage {
|
||||
ID: number;
|
||||
UserID: number;
|
||||
Name: string;
|
||||
PhoneNumber: string;
|
||||
Message: string;
|
||||
CreatedAt: string;
|
||||
}
|
||||
|
||||
export interface ContactUsListResponse {
|
||||
messages: ContactUsMessage[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface ContactUsFilters {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface DeleteContactUsResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import {
|
||||
APIUrlGenerator,
|
||||
httpDeleteRequest,
|
||||
httpGetRequest,
|
||||
} from "@/utils/baseHttpService";
|
||||
import { API_ROUTES } from "@/constant/routes";
|
||||
import {
|
||||
ContactUsFilters,
|
||||
ContactUsListResponse,
|
||||
DeleteContactUsResponse,
|
||||
} from "./_models";
|
||||
|
||||
export const getContactUsMessages = async (filters?: ContactUsFilters) => {
|
||||
const limitValue = filters?.limit ?? 20;
|
||||
const queryParams: Record<string, string | number | null> = {
|
||||
limit: limitValue,
|
||||
offset: filters?.offset ?? 0,
|
||||
};
|
||||
|
||||
const response = await httpGetRequest<ContactUsListResponse>(
|
||||
APIUrlGenerator(API_ROUTES.GET_CONTACT_US_MESSAGES, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const deleteContactUsMessage = async (id: string | number) => {
|
||||
const response = await httpDeleteRequest<DeleteContactUsResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DELETE_CONTACT_US_MESSAGE(id.toString()))
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
@ -7,11 +7,13 @@ import {
|
|||
createDiscountCode,
|
||||
updateDiscountCode,
|
||||
deleteDiscountCode,
|
||||
getDiscountReports,
|
||||
} from "./_requests";
|
||||
import {
|
||||
CreateDiscountCodeRequest,
|
||||
UpdateDiscountCodeRequest,
|
||||
DiscountCodeFilters,
|
||||
DiscountReportFilters,
|
||||
} from "./_models";
|
||||
|
||||
export const useDiscountCodes = (filters?: DiscountCodeFilters) => {
|
||||
|
|
@ -84,3 +86,13 @@ export const useDeleteDiscountCode = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useDiscountReports = (
|
||||
filters?: DiscountReportFilters,
|
||||
enabled: boolean = true
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEYS.GET_DISCOUNT_REPORTS, filters],
|
||||
queryFn: () => getDiscountReports(filters),
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@ export type DiscountApplicationLevels =
|
|||
|
||||
export type DiscountStatus = "active" | "inactive";
|
||||
|
||||
export type DiscountReportViewMode = "simple" | "detailed";
|
||||
|
||||
export type DiscountReportSortBy =
|
||||
| "usage_count"
|
||||
| "amount"
|
||||
| "date"
|
||||
| "code"
|
||||
| "created_at";
|
||||
|
||||
export type DiscountReportSortOrder = "asc" | "desc";
|
||||
|
||||
export type UserGroup = "new" | "loyal" | "all";
|
||||
|
||||
export interface DiscountUserRestrictions {
|
||||
|
|
@ -73,6 +84,50 @@ export interface DiscountCodeFilters {
|
|||
active_only?: boolean;
|
||||
}
|
||||
|
||||
export interface DiscountReportFilters {
|
||||
view_mode?: DiscountReportViewMode;
|
||||
discount_code?: string;
|
||||
discount_id?: number;
|
||||
user_id?: number;
|
||||
status?: "active" | "inactive" | "expired";
|
||||
type?: DiscountCodeType;
|
||||
application_level?: DiscountApplicationLevel;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
min_usage_count?: number;
|
||||
include_unused?: boolean;
|
||||
group_by_code?: boolean;
|
||||
sort_by?: DiscountReportSortBy;
|
||||
sort_order?: DiscountReportSortOrder;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface DiscountReportUsage {
|
||||
discount_id: number;
|
||||
discount_code: string;
|
||||
discount_name: string;
|
||||
usage_count: number;
|
||||
total_amount: number;
|
||||
unique_users: number;
|
||||
first_used_at: string;
|
||||
last_used_at: string;
|
||||
}
|
||||
|
||||
export interface DiscountReportSummarySimple {
|
||||
total_usages: number;
|
||||
total_discount_given: number;
|
||||
unique_users: number;
|
||||
unique_codes: number;
|
||||
}
|
||||
|
||||
export interface DiscountReportSimpleResponse {
|
||||
usages: DiscountReportUsage[];
|
||||
summary: DiscountReportSummarySimple;
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDiscountCodeRequest {
|
||||
code: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
DiscountCode,
|
||||
DiscountCodeFilters,
|
||||
PaginatedDiscountCodesResponse,
|
||||
DiscountReportFilters,
|
||||
DiscountReportSimpleResponse,
|
||||
} from "./_models";
|
||||
|
||||
export const getDiscountCodes = async (filters?: DiscountCodeFilters) => {
|
||||
|
|
@ -75,3 +77,39 @@ export const deleteDiscountCode = async (id: string) => {
|
|||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getDiscountReports = async (
|
||||
filters?: DiscountReportFilters
|
||||
) => {
|
||||
const queryParams: Record<string, string | number | null> = {};
|
||||
|
||||
if (filters?.view_mode) queryParams.view_mode = filters.view_mode;
|
||||
if (filters?.discount_code) queryParams.discount_code = filters.discount_code;
|
||||
if (filters?.discount_id) queryParams.discount_id = filters.discount_id;
|
||||
if (filters?.user_id) queryParams.user_id = filters.user_id;
|
||||
if (filters?.status) queryParams.status = filters.status;
|
||||
if (filters?.type) queryParams.type = filters.type;
|
||||
if (filters?.application_level) {
|
||||
queryParams.application_level = filters.application_level;
|
||||
}
|
||||
if (filters?.from_date) queryParams.from_date = filters.from_date;
|
||||
if (filters?.to_date) queryParams.to_date = filters.to_date;
|
||||
if (filters?.min_usage_count)
|
||||
queryParams.min_usage_count = filters.min_usage_count;
|
||||
if (typeof filters?.include_unused === "boolean") {
|
||||
queryParams.include_unused = filters.include_unused ? "true" : "false";
|
||||
}
|
||||
if (typeof filters?.group_by_code === "boolean") {
|
||||
queryParams.group_by_code = filters.group_by_code ? "true" : "false";
|
||||
}
|
||||
if (filters?.sort_by) queryParams.sort_by = filters.sort_by;
|
||||
if (filters?.sort_order) queryParams.sort_order = filters.sort_order;
|
||||
if (typeof filters?.limit === "number") queryParams.limit = filters.limit;
|
||||
if (typeof filters?.offset === "number") queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<DiscountReportSimpleResponse>(
|
||||
APIUrlGenerator(API_ROUTES.DISCOUNT_REPORTS, queryParams)
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
const [filters, setFilters] = useState({ code: '' });
|
||||
const [selectedDiscount, setSelectedDiscount] = useState<DiscountCode | null>(null);
|
||||
const [isUsageModalOpen, setIsUsageModalOpen] = useState(false);
|
||||
|
||||
const { data: discountCodes, isLoading, error } = useDiscountCodes(filters);
|
||||
const { mutate: deleteDiscount, isPending: isDeleting } = useDeleteDiscountCode();
|
||||
const summaryFilters = useMemo(
|
||||
() => ({
|
||||
view_mode: 'simple' as const,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const { data: discountReport, isLoading: isReportLoading, error: reportError } =
|
||||
useDiscountReports(summaryFilters);
|
||||
const usageFilters = useMemo(() => {
|
||||
if (!selectedDiscount) return undefined;
|
||||
return {
|
||||
view_mode: 'simple' as const,
|
||||
discount_id: selectedDiscount.id,
|
||||
include_unused: true,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
};
|
||||
}, [selectedDiscount]);
|
||||
const {
|
||||
data: usageReport,
|
||||
isLoading: isUsageLoading,
|
||||
error: usageError,
|
||||
} = useDiscountReports(usageFilters, isUsageModalOpen && !!selectedDiscount);
|
||||
const selectedUsage = usageReport?.usages?.[0];
|
||||
|
||||
const handleCreate = () => navigate('/discount-codes/create');
|
||||
const handleEdit = (id: number) => navigate(`/discount-codes/${id}/edit`);
|
||||
const handleCreate = useCallback(() => navigate('/discount-codes/create'), [navigate]);
|
||||
const handleEdit = useCallback((id: number) => navigate(`/discount-codes/${id}/edit`), [navigate]);
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteId) {
|
||||
|
|
@ -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) => (
|
||||
<ActionButtons
|
||||
onView={() => 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 = () => {
|
|||
</div>
|
||||
</FiltersSection>
|
||||
|
||||
{isReportLoading ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<LoadingSpinner size="sm" text="در حال بارگذاری آمار..." />
|
||||
</div>
|
||||
) : reportError ? (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در دریافت آمار تخفیف</p>
|
||||
</div>
|
||||
) : discountReport?.summary ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<Hash className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">کل استفادهها</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(discountReport.summary.total_usages)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<DollarSign className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع تخفیف داده شده</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(discountReport.summary.total_discount_given)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">کاربران یونیک</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(discountReport.summary.unique_users)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<Hash className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">کدهای یونیک</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(discountReport.summary.unique_codes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Table columns={columns} data={Array.isArray(discountCodes) ? (discountCodes as any[]) : []} loading={true} />
|
||||
) : !discountCodes || discountCodes.length === 0 ? (
|
||||
|
|
@ -135,6 +246,66 @@ const DiscountCodesListPage = () => {
|
|||
<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
|
||||
isOpen={!!deleteId}
|
||||
onClose={() => setDeleteId(null)}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
'bank-topup': 'افزایش موجودی کیف پول',
|
||||
'card-to-card': 'پرداخت به روش کارت به کارت',
|
||||
'debit-rial-wallet': 'پرداخت از کیف ریالی',
|
||||
'debit-gold18k-wallet': 'پرداخت از کیف طلا',
|
||||
unknown: 'نامشخص',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const IPGListPage = () => {
|
||||
const { data, isLoading, error } = useIPGStatus();
|
||||
const { mutate: updateStatus, isPending } = useUpdateIPGStatus();
|
||||
const [selectedPaymentType, setSelectedPaymentType] = useState<string | null>(null);
|
||||
const [isTransactionsModalOpen, setIsTransactionsModalOpen] = useState(false);
|
||||
|
||||
const paymentReportFilters = useMemo(
|
||||
() => ({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
group_by_user: false,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const {
|
||||
data: paymentMethodsReport,
|
||||
isLoading: isPaymentReportLoading,
|
||||
error: paymentReportError,
|
||||
} = usePaymentMethodsReport(paymentReportFilters);
|
||||
|
||||
const transactionFilters = useMemo(() => {
|
||||
if (!selectedPaymentType) {
|
||||
return { limit: 0, offset: 0 } as const;
|
||||
}
|
||||
return {
|
||||
payment_type: selectedPaymentType,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
sort_by: 'date' as const,
|
||||
sort_order: 'desc' as const,
|
||||
};
|
||||
}, [selectedPaymentType]);
|
||||
|
||||
const {
|
||||
data: paymentTransactionsReport,
|
||||
isLoading: isTransactionsLoading,
|
||||
error: paymentTransactionsError,
|
||||
} = usePaymentTransactionsReport(transactionFilters, isTransactionsModalOpen && !!selectedPaymentType);
|
||||
|
||||
const transactionColumns: TableColumn[] = useMemo(
|
||||
() => [
|
||||
{ key: 'order_number', label: 'شماره سفارش', align: 'right' },
|
||||
{ key: 'customer_name', label: 'نام مشتری', align: 'right' },
|
||||
{ key: 'amount', label: 'مبلغ', align: 'right' },
|
||||
{ key: 'status', label: 'وضعیت', align: 'right' },
|
||||
{ key: 'created_at', label: 'تاریخ', align: 'right' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const transactionTableData = (paymentTransactionsReport?.transactions || []).map((tx) => ({
|
||||
order_number: tx.order_number || '-',
|
||||
customer_name: tx.customer_name || '-',
|
||||
amount: formatCurrency(tx.amount),
|
||||
status: tx.status,
|
||||
created_at: formatDateTime(tx.created_at),
|
||||
}));
|
||||
|
||||
const handleOpenTransactionsModal = (paymentType: string) => {
|
||||
setSelectedPaymentType(paymentType);
|
||||
setIsTransactionsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseTransactionsModal = () => {
|
||||
setIsTransactionsModalOpen(false);
|
||||
setSelectedPaymentType(null);
|
||||
};
|
||||
|
||||
const handleToggle = (ipg: IPGStatus, newStatus: boolean) => {
|
||||
updateStatus({
|
||||
|
|
@ -116,6 +197,195 @@ const IPGListPage = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{isPaymentReportLoading ? (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">در حال بارگذاری گزارش پرداختها...</div>
|
||||
</div>
|
||||
) : paymentReportError ? (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در دریافت گزارش پرداختها</p>
|
||||
</div>
|
||||
) : paymentMethodsReport?.summary ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900 rounded-lg">
|
||||
<CreditCard className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">کل تراکنشها</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(paymentMethodsReport.summary.total_transactions)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">موفق</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(paymentMethodsReport.summary.successful_transactions)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-red-100 dark:bg-red-900 rounded-lg">
|
||||
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">ناموفق</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(paymentMethodsReport.summary.failed_transactions)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">نرخ موفقیت</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(paymentMethodsReport.summary.overall_success_rate.toFixed(2))}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 md:col-span-2 lg:col-span-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
|
||||
<DollarSign className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">مجموع مبلغ</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(paymentMethodsReport.summary.total_amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{paymentMethodsReport.summary.by_payment_type &&
|
||||
Object.keys(paymentMethodsReport.summary.by_payment_type).length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
آمار تفکیکی روشهای پرداخت
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Object.entries(paymentMethodsReport.summary.by_payment_type).map(([type, stats]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{getPaymentTypeLabel(type)}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => handleOpenTransactionsModal(type)}
|
||||
className="text-xs text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
مشاهده تراکنشها
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">کل:</span>
|
||||
<span className="font-medium">{formatWithThousands(stats.count)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">موفق:</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatWithThousands(stats.success_count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">ناموفق:</span>
|
||||
<span className="font-medium text-red-600">
|
||||
{formatWithThousands(stats.failed_count)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">نرخ موفقیت:</span>
|
||||
<span className="font-medium">
|
||||
{formatWithThousands(stats.success_rate.toFixed(2))}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={isTransactionsModalOpen}
|
||||
onClose={handleCloseTransactionsModal}
|
||||
title={`تراکنشها - ${selectedPaymentType ? getPaymentTypeLabel(selectedPaymentType) : ''}`}
|
||||
size="xl"
|
||||
>
|
||||
{isTransactionsLoading ? (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">در حال بارگذاری تراکنشها...</div>
|
||||
) : paymentTransactionsError ? (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400">خطا در دریافت تراکنشها</p>
|
||||
</div>
|
||||
) : paymentTransactionsReport ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">کل تراکنشها</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(paymentTransactionsReport.summary.total_transactions)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">موفق</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(paymentTransactionsReport.summary.successful_count)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">ناموفق</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatWithThousands(paymentTransactionsReport.summary.failed_count)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">میانگین مبلغ</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{formatCurrency(paymentTransactionsReport.summary.average_transaction_amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<Table columns={transactionColumns} data={transactionTableData} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">دادهای یافت نشد</div>
|
||||
)}
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PaymentMethodsResponse> => {
|
||||
const response = await httpPostRequest<PaymentMethodsResponse>(
|
||||
APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT),
|
||||
filters
|
||||
const queryParams: Record<string, string | number | null> = {};
|
||||
|
||||
if (filters.user_id) queryParams.user_id = filters.user_id;
|
||||
if (filters.payment_type) queryParams.payment_type = filters.payment_type;
|
||||
if (filters.status) queryParams.status = filters.status;
|
||||
if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
|
||||
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
|
||||
if (typeof filters.group_by_user === "boolean") {
|
||||
queryParams.group_by_user = filters.group_by_user ? "true" : "false";
|
||||
}
|
||||
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
|
||||
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<PaymentMethodsResponse>(
|
||||
APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPaymentTransactionsReport = async (
|
||||
filters: PaymentTransactionsFilters
|
||||
): Promise<PaymentTransactionsResponse> => {
|
||||
const queryParams: Record<string, string | number | null> = {};
|
||||
|
||||
if (filters.user_id) queryParams.user_id = filters.user_id;
|
||||
if (filters.phone_number) queryParams.phone_number = filters.phone_number;
|
||||
if (filters.order_id) queryParams.order_id = filters.order_id;
|
||||
if (filters.transaction_id)
|
||||
queryParams.transaction_id = filters.transaction_id;
|
||||
if (filters.reference_number)
|
||||
queryParams.reference_number = filters.reference_number;
|
||||
if (filters.status) queryParams.status = filters.status;
|
||||
if (filters.payment_type) queryParams.payment_type = filters.payment_type;
|
||||
if (filters.min_amount) queryParams.min_amount = filters.min_amount;
|
||||
if (filters.max_amount) queryParams.max_amount = filters.max_amount;
|
||||
if (filters.from_date) queryParams.from_date = filters.from_date;
|
||||
if (filters.to_date) queryParams.to_date = filters.to_date;
|
||||
if (typeof filters.group_by_status === "boolean") {
|
||||
queryParams.group_by_status = filters.group_by_status ? "true" : "false";
|
||||
}
|
||||
if (typeof filters.group_by_type === "boolean") {
|
||||
queryParams.group_by_type = filters.group_by_type ? "true" : "false";
|
||||
}
|
||||
if (filters.sort_by) queryParams.sort_by = filters.sort_by;
|
||||
if (filters.sort_order) queryParams.sort_order = filters.sort_order;
|
||||
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
|
||||
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<PaymentTransactionsResponse>(
|
||||
APIUrlGenerator(API_ROUTES.PAYMENT_TRANSACTIONS_REPORT, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<ShipmentsByMethodResponse> => {
|
||||
const response = await httpPostRequest<ShipmentsByMethodResponse>(
|
||||
APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT),
|
||||
filters
|
||||
const queryParams: Record<string, string | number | null> = {};
|
||||
|
||||
if (filters.shipping_method_code)
|
||||
queryParams.shipping_method_code = filters.shipping_method_code;
|
||||
if (filters.shipping_method_id)
|
||||
queryParams.shipping_method_id = filters.shipping_method_id;
|
||||
if (filters.user_id) queryParams.user_id = filters.user_id;
|
||||
if (filters.customer_name) queryParams.customer_name = filters.customer_name;
|
||||
if (filters.status) queryParams.status = filters.status;
|
||||
if (filters.payment_status) queryParams.payment_status = filters.payment_status;
|
||||
if (filters.min_shipping_cost)
|
||||
queryParams.min_shipping_cost = filters.min_shipping_cost;
|
||||
if (filters.max_shipping_cost)
|
||||
queryParams.max_shipping_cost = filters.max_shipping_cost;
|
||||
if (filters.date_range?.from) queryParams.from_date = filters.date_range.from;
|
||||
if (filters.date_range?.to) queryParams.to_date = filters.date_range.to;
|
||||
if (typeof filters.group_by_method === "boolean") {
|
||||
queryParams.group_by_method = filters.group_by_method ? "true" : "false";
|
||||
}
|
||||
if (typeof filters.limit === "number") queryParams.limit = filters.limit;
|
||||
if (typeof filters.offset === "number") queryParams.offset = filters.offset;
|
||||
|
||||
const response = await httpGetRequest<ShipmentsByMethodResponse>(
|
||||
APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT, queryParams)
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<ShipmentsByMethodFilters>({
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
group_by_method: false,
|
||||
...(initialShippingMethodId ? { shipping_method_id: initialShippingMethodId } : {}),
|
||||
...(initialShippingMethodCode ? { shipping_method_code: initialShippingMethodCode } : {}),
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useShipmentsByMethodReport(filters);
|
||||
|
|
@ -140,175 +155,6 @@ const ShipmentsByMethodReportPage = () => {
|
|||
<PageContainer>
|
||||
<PageTitle>گزارش ارسالها بر اساس روش</PageTitle>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-gray-500" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">فیلترها</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleClearFilters}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
پاک کردن فیلترها
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
کد روش ارسال
|
||||
</label>
|
||||
<select
|
||||
value={filters.shipping_method_code || ''}
|
||||
onChange={(e) => handleFilterChange('shipping_method_code', e.target.value || undefined)}
|
||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
>
|
||||
<option value="">همه</option>
|
||||
<option value="express">پیک (اکسپرس)</option>
|
||||
<option value="standard">پست (معمولی)</option>
|
||||
<option value="pickup">تحویل حضوری</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
شناسه روش ارسال
|
||||
</label>
|
||||
<Input
|
||||
value={filters.shipping_method_id?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('shipping_method_id', e.target.value)}
|
||||
placeholder="مثلاً 1"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
شناسه کاربر
|
||||
</label>
|
||||
<Input
|
||||
value={filters.user_id?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('user_id', e.target.value)}
|
||||
placeholder="مثلاً 456"
|
||||
numeric
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
نام مشتری
|
||||
</label>
|
||||
<Input
|
||||
value={filters.customer_name || ''}
|
||||
onChange={(e) => handleFilterChange('customer_name', e.target.value || undefined)}
|
||||
placeholder="جستجو در نام"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
وضعیت ارسال
|
||||
</label>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
|
||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
>
|
||||
<option value="">همه</option>
|
||||
<option value="pending">در انتظار</option>
|
||||
<option value="confirmed">تایید شده</option>
|
||||
<option value="processing">در حال پردازش</option>
|
||||
<option value="shipped">ارسال شده</option>
|
||||
<option value="delivered">تحویل داده شده</option>
|
||||
<option value="cancelled">لغو شده</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
وضعیت پرداخت
|
||||
</label>
|
||||
<select
|
||||
value={filters.payment_status || ''}
|
||||
onChange={(e) => handleFilterChange('payment_status', e.target.value || undefined)}
|
||||
className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200"
|
||||
>
|
||||
<option value="">همه</option>
|
||||
<option value="pending">در انتظار</option>
|
||||
<option value="paid">پرداخت شده</option>
|
||||
<option value="failed">ناموفق</option>
|
||||
<option value="refunded">مرجوع شده</option>
|
||||
<option value="cancelled">لغو شده</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداقل هزینه ارسال (ریال)
|
||||
</label>
|
||||
<Input
|
||||
value={filters.min_shipping_cost?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('min_shipping_cost', e.target.value)}
|
||||
placeholder="مثلاً 10000"
|
||||
numeric
|
||||
thousandSeparator
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
حداکثر هزینه ارسال (ریال)
|
||||
</label>
|
||||
<Input
|
||||
value={filters.max_shipping_cost?.toString() || ''}
|
||||
onChange={(e) => handleNumericFilterChange('max_shipping_cost', e.target.value)}
|
||||
placeholder="مثلاً 50000"
|
||||
numeric
|
||||
thousandSeparator
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
تاریخ شروع
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={filters.date_range?.from}
|
||||
onChange={(value) => handleDateRangeChange(value, filters.date_range?.to)}
|
||||
placeholder="انتخاب تاریخ شروع"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
تاریخ پایان
|
||||
</label>
|
||||
<JalaliDateTimePicker
|
||||
value={filters.date_range?.to}
|
||||
onChange={(value) => handleDateRangeChange(filters.date_range?.from, value)}
|
||||
placeholder="انتخاب تاریخ پایان"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.group_by_method || false}
|
||||
onChange={(e) => handleFilterChange('group_by_method', e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
گروهبندی بر اساس روش
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{data?.summary && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<PageContainer>
|
||||
|
|
@ -125,6 +129,13 @@ const ShippingMethodsListPage = () => {
|
|||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleViewReport(m)}
|
||||
className="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
title="گزارش ارسال"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={() => handleEdit(m.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="ویرایش">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
|
|
@ -153,6 +164,13 @@ const ShippingMethodsListPage = () => {
|
|||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ساعات پاسخگویی: {formatOpenHours(m.open_hours)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">اولویت: {m.priority}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleViewReport(m)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
<BarChart3 className="h-3 w-3" />
|
||||
گزارش
|
||||
</button>
|
||||
<button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
|
||||
<Edit3 className="h-3 w-3" />
|
||||
ویرایش
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue