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:
hosseintaromi 2026-01-23 01:01:38 +03:30
parent 5bb506b830
commit 5b62d189f8
19 changed files with 1102 additions and 282 deletions

View File

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

View File

@ -75,6 +75,11 @@ const menuItems: MenuItem[] = [
},
]
},
{
title: 'پیام‌های تماس با ما',
icon: FileText,
path: '/contact-us',
},
{
title: 'مدیریت محصولات',
icon: Package,

View File

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

View File

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

View File

@ -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 || "خطا در حذف پیام تماس با ما");
},
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
@ -82,100 +86,114 @@ const ShippingMethodsListPage = () => {
return (
<PageContainer>
<div className="space-y-6">
<PageHeader
title="مدیریت روش‌های ارسال"
subtitle="تعریف و مدیریت روش‌های ارسال سفارش"
icon={Truck}
actions={
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
title="روش ارسال جدید"
>
<Plus className="h-5 w-5" />
</button>
}
/>
<PageHeader
title="مدیریت روش‌های ارسال"
subtitle="تعریف و مدیریت روش‌های ارسال سفارش"
icon={Truck}
actions={
<button
onClick={handleCreate}
className="flex items-center justify-center w-12 h-12 bg-primary-600 hover:bg-primary-700 rounded-full transition-colors duration-200 text-white shadow-lg hover:shadow-xl"
title="روش ارسال جدید"
>
<Plus className="h-5 w-5" />
</button>
}
/>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">محدوده وزن</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ساعات پاسخگویی</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">اولویت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{(methods || []).map((m: ShippingMethod) => (
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.min_weight} - {m.max_weight}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{formatOpenHours(m.open_hours)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.priority}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-md text-xs ${m.enabled ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}>{m.enabled ? 'فعال' : 'غیرفعال'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button onClick={() => handleEdit(m.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="ویرایش">
<Edit3 className="h-4 w-4" />
</button>
<button onClick={() => setDeleteId(m.id.toString())} className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300" title="حذف">
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<div className="hidden md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">نام</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">کد</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">محدوده وزن</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">ساعات پاسخگویی</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">اولویت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">وضعیت</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">عملیات</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{(methods || []).map((m: ShippingMethod) => (
<tr key={m.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.name}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.code}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.min_weight} - {m.max_weight}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{formatOpenHours(m.open_hours)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{m.priority}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<span className={`px-2 py-1 rounded-md text-xs ${m.enabled ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'}`}>{m.enabled ? 'فعال' : 'غیرفعال'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center gap-2">
<button
onClick={() => handleViewReport(m)}
className="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"
title="گزارش ارسال"
>
<BarChart3 className="h-4 w-4" />
</button>
<button onClick={() => handleEdit(m.id)} className="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300" title="ویرایش">
<Edit3 className="h-4 w-4" />
</button>
<button onClick={() => setDeleteId(m.id.toString())} className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300" title="حذف">
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Mobile */}
<div className="md:hidden p-4 space-y-4">
{(methods || []).map((m: ShippingMethod) => (
<div key={m.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{m.name}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">کد: {m.code} وزن: {m.min_weight}-{m.max_weight}</p>
{/* Mobile */}
<div className="md:hidden p-4 space-y-4">
{(methods || []).map((m: ShippingMethod) => (
<div key={m.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<div className="flex-1">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{m.name}</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">کد: {m.code} وزن: {m.min_weight}-{m.max_weight}</p>
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ساعات پاسخگویی: {formatOpenHours(m.open_hours)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">اولویت: {m.priority}</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleViewReport(m)}
className="flex items-center gap-1 px-2 py-1 text-xs text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300"
>
<BarChart3 className="h-3 w-3" />
گزارش
</button>
<button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
<Edit3 className="h-3 w-3" />
ویرایش
</button>
<button onClick={() => setDeleteId(m.id.toString())} className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
<Trash2 className="h-3 w-3" />
حذف
</button>
</div>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ساعات پاسخگویی: {formatOpenHours(m.open_hours)}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mb-3">اولویت: {m.priority}</div>
<div className="flex items-center gap-2">
<button onClick={() => handleEdit(m.id)} className="flex items-center gap-1 px-2 py-1 text-xs text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">
<Edit3 className="h-3 w-3" />
ویرایش
</button>
<button onClick={() => setDeleteId(m.id.toString())} className="flex items-center gap-1 px-2 py-1 text-xs text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">
<Trash2 className="h-3 w-3" />
حذف
</button>
</div>
</div>
))}
</div>
</div>
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف روش ارسال">
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">آیا از حذف این روش ارسال اطمینان دارید؟</p>
<div className="flex justify-end space-x-2 space-x-reverse">
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
<Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
))}
</div>
</div>
</Modal>
<Modal isOpen={!!deleteId} onClose={() => setDeleteId(null)} title="حذف روش ارسال">
<div className="space-y-4">
<p className="text-gray-600 dark:text-gray-400">آیا از حذف این روش ارسال اطمینان دارید؟</p>
<div className="flex justify-end space-x-2 space-x-reverse">
<Button variant="secondary" onClick={() => setDeleteId(null)} disabled={isDeleting}>انصراف</Button>
<Button variant="danger" onClick={handleDeleteConfirm} loading={isDeleting}>حذف</Button>
</div>
</div>
</Modal>
</div>
</PageContainer>
);

View File

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