From ef76defb28703e5823064e6555c67536d2dd8802 Mon Sep 17 00:00:00 2001 From: hosseintaromi Date: Thu, 8 Jan 2026 17:10:26 +0330 Subject: [PATCH] fix --- src/App.tsx | 16 + src/components/charts/PieChart.tsx | 95 +-- src/components/layout/Sidebar.tsx | 36 +- src/components/ui/JalaliDateTimePicker.tsx | 50 +- src/components/ui/MultiSelectAutocomplete.tsx | 15 +- .../ui/SingleSelectAutocomplete.tsx | 10 +- src/components/ui/Table.tsx | 2 +- src/constant/routes.ts | 11 + .../admin-user-form/AdminUserFormPage.tsx | 2 +- .../admin-users-list/AdminUsersListPage.tsx | 2 +- .../DiscountCodeFormPage.tsx | 6 +- .../orders/order-detail/OrderDetailPage.tsx | 7 +- .../orders/orders-list/OrdersListPage.tsx | 6 +- src/pages/payment-card/core/_hooks.ts | 1 + src/pages/payment-card/core/_models.ts | 1 + src/pages/payment-card/core/_requests.ts | 1 + src/pages/payment-ipg/core/_hooks.ts | 1 + src/pages/payment-ipg/core/_models.ts | 1 + src/pages/payment-ipg/core/_requests.ts | 1 + .../payment-ipg/ipg-list/IPGListPage.tsx | 1 + .../comments-list/ProductCommentsListPage.tsx | 358 +++++++++++ src/pages/products/comments/core/_hooks.ts | 56 ++ src/pages/products/comments/core/_models.ts | 47 ++ src/pages/products/comments/core/_requests.ts | 52 ++ .../products/product-form/ProductFormPage.tsx | 2 +- .../products-list/ProductsListPage.tsx | 4 +- .../discount-statistics/core/_hooks.ts | 29 + .../discount-statistics/core/_models.ts | 82 +++ .../discount-statistics/core/_requests.ts | 29 + .../CustomerDiscountUsagePage.tsx | 354 +++++++++++ .../DiscountUsageReportPage.tsx | 385 ++++++++++++ .../reports/payment-statistics/core/_hooks.ts | 13 + .../payment-statistics/core/_models.ts | 56 ++ .../payment-statistics/core/_requests.ts | 17 + .../PaymentMethodsReportPage.tsx | 525 ++++++++++++++++ .../shipment-statistics/core/_hooks.ts | 13 + .../shipment-statistics/core/_models.ts | 77 +++ .../shipment-statistics/core/_requests.ts | 17 + .../ShipmentsByMethodReportPage.tsx | 593 ++++++++++++++++++ src/pages/shipping-methods/core/_models.ts | 1 + .../ShippingMethodFormPage.tsx | 12 + .../ticket-config/TicketConfigPage.tsx | 8 +- .../ticket-detail/TicketDetailPage.tsx | 2 +- .../tickets/tickets-list/TicketsListPage.tsx | 4 +- .../users-admin-list/UsersAdminListPage.tsx | 2 +- src/pages/wallet/core/_hooks.ts | 1 + src/pages/wallet/core/_models.ts | 1 + src/pages/wallet/core/_requests.ts | 1 + .../wallet/wallet-list/WalletListPage.tsx | 1 + src/utils/query-key.ts | 15 + 50 files changed, 2926 insertions(+), 96 deletions(-) create mode 100644 src/pages/products/comments/comments-list/ProductCommentsListPage.tsx create mode 100644 src/pages/products/comments/core/_hooks.ts create mode 100644 src/pages/products/comments/core/_models.ts create mode 100644 src/pages/products/comments/core/_requests.ts create mode 100644 src/pages/reports/discount-statistics/core/_hooks.ts create mode 100644 src/pages/reports/discount-statistics/core/_models.ts create mode 100644 src/pages/reports/discount-statistics/core/_requests.ts create mode 100644 src/pages/reports/discount-statistics/customer-discount-usage/CustomerDiscountUsagePage.tsx create mode 100644 src/pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage.tsx create mode 100644 src/pages/reports/payment-statistics/core/_hooks.ts create mode 100644 src/pages/reports/payment-statistics/core/_models.ts create mode 100644 src/pages/reports/payment-statistics/core/_requests.ts create mode 100644 src/pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage.tsx create mode 100644 src/pages/reports/shipment-statistics/core/_hooks.ts create mode 100644 src/pages/reports/shipment-statistics/core/_models.ts create mode 100644 src/pages/reports/shipment-statistics/core/_requests.ts create mode 100644 src/pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage.tsx diff --git a/src/App.tsx b/src/App.tsx index 06c5577..9e380e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -79,6 +79,15 @@ const CardFormPage = lazy(() => import('./pages/payment-card/card-form/CardFormP // Wallet Page const WalletListPage = lazy(() => import('./pages/wallet/wallet-list/WalletListPage')); +// Reports Pages +const DiscountUsageReportPage = lazy(() => import('./pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage')); +const CustomerDiscountUsagePage = lazy(() => import('./pages/reports/discount-statistics/customer-discount-usage/CustomerDiscountUsagePage')); +const PaymentMethodsReportPage = lazy(() => import('./pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage')); +const ShipmentsByMethodReportPage = lazy(() => import('./pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage')); + +// Product Comments Page +const ProductCommentsListPage = lazy(() => import('./pages/products/comments/comments-list/ProductCommentsListPage')); + const ProtectedRoute = ({ children }: { children: any }) => { const { user, isLoading } = useAuth(); @@ -167,6 +176,7 @@ const AppRoutes = () => { } /> } /> } /> + } /> {/* Payment IPG Route */} } /> @@ -176,6 +186,12 @@ const AppRoutes = () => { {/* Wallet Route */} } /> + + {/* Reports Routes */} + } /> + } /> + } /> + } /> ); diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx index 052f3f7..2632122 100644 --- a/src/components/charts/PieChart.tsx +++ b/src/components/charts/PieChart.tsx @@ -10,19 +10,19 @@ interface PieChartProps { const DEFAULT_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps) => { - // Custom legend component for better mobile experience + // Custom legend component for left side const CustomLegend = (props: any) => { const { payload } = props; return ( -
+
{payload.map((entry: any, index: number) => ( -
+
- - {entry.value}: {entry.payload.value} + + {entry.value}: {Math.round(entry.payload.value)}%
))} @@ -37,43 +37,52 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps {title} )} -
- - - - {data.map((_, index) => ( - - ))} - - [`${value}`, name]} - /> - } - wrapperStyle={{ - paddingTop: '10px' - }} - /> - - +
+ {/* Legend on the left */} +
+ ({ + value: item.name, + color: colors[index % colors.length], + payload: item + }))} /> +
+ + {/* Chart on the right */} +
+ + + + {data.map((_, index) => ( + + ))} + + [`${Math.round(value)}%`, name]} + /> + + +
); diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 99a554a..375db62 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -19,7 +19,10 @@ import { X, MessageSquare, CreditCard, - Wallet + Wallet, + BarChart3, + FileText, + TrendingUp } from 'lucide-react'; import { useAuth } from '../../contexts/AuthContext'; import { PermissionWrapper } from '../common/PermissionWrapper'; @@ -91,6 +94,37 @@ const menuItems: MenuItem[] = [ icon: Sliders, path: '/product-options', }, + { + title: 'نظرات محصولات', + icon: MessageSquare, + path: '/products/comments', + }, + ] + }, + { + title: 'گزارش‌ها', + icon: BarChart3, + children: [ + { + title: 'گزارش کدهای تخفیف', + icon: BadgePercent, + path: '/reports/discount-usage', + }, + { + title: 'گزارش کاربر و کد تخفیف', + icon: Users, + path: '/reports/customer-discount-usage', + }, + { + title: 'گزارش روش‌های پرداخت', + icon: CreditCard, + path: '/reports/payment-methods', + }, + { + title: 'گزارش ارسال‌ها', + icon: Truck, + path: '/reports/shipments-by-method', + }, ] }, { diff --git a/src/components/ui/JalaliDateTimePicker.tsx b/src/components/ui/JalaliDateTimePicker.tsx index 00560d0..2a0452e 100644 --- a/src/components/ui/JalaliDateTimePicker.tsx +++ b/src/components/ui/JalaliDateTimePicker.tsx @@ -5,6 +5,7 @@ import persian from 'react-date-object/calendars/persian'; import persian_fa from 'react-date-object/locales/persian_fa'; import DateObject from 'react-date-object'; import { Label } from './Typography'; +import { X } from 'lucide-react'; interface JalaliDateTimePickerProps { label?: string; @@ -46,23 +47,38 @@ export const JalaliDateTimePicker: React.FC = ({ labe return (
{label && } - onChange(toIsoLike(val as DateObject | null))} - format="YYYY/MM/DD HH:mm" - calendar={persian} - locale={persian_fa} - calendarPosition="bottom-center" - disableDayPicker={false} - inputClass={`w-full border rounded-lg px-3 py-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`} - containerClassName="w-full" - placeholder={placeholder || 'تاریخ و ساعت'} - editable={false} - plugins={[]} - disableMonthPicker={false} - disableYearPicker={false} - showOtherDays - /> +
+ onChange(toIsoLike(val as DateObject | null))} + format="YYYY/MM/DD HH:mm" + calendar={persian} + locale={persian_fa} + calendarPosition="bottom-center" + disableDayPicker={false} + inputClass={`w-full border rounded-lg px-3 py-3 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`} + containerClassName="w-full" + placeholder={placeholder || 'تاریخ و ساعت'} + editable={false} + plugins={[]} + disableMonthPicker={false} + disableYearPicker={false} + showOtherDays + /> + {value && ( + + )} +
{error && (

{error}

)} diff --git a/src/components/ui/MultiSelectAutocomplete.tsx b/src/components/ui/MultiSelectAutocomplete.tsx index b3297b8..fbad729 100644 --- a/src/components/ui/MultiSelectAutocomplete.tsx +++ b/src/components/ui/MultiSelectAutocomplete.tsx @@ -95,14 +95,13 @@ export const MultiSelectAutocomplete: React.FC = ( {/* Selected Items Display */}
diff --git a/src/components/ui/SingleSelectAutocomplete.tsx b/src/components/ui/SingleSelectAutocomplete.tsx index 3e1feec..75eaa9a 100644 --- a/src/components/ui/SingleSelectAutocomplete.tsx +++ b/src/components/ui/SingleSelectAutocomplete.tsx @@ -106,12 +106,12 @@ export const SingleSelectAutocomplete: React.FC =
diff --git a/src/components/ui/Table.tsx b/src/components/ui/Table.tsx index 8cf0f8c..e7d302b 100644 --- a/src/components/ui/Table.tsx +++ b/src/components/ui/Table.tsx @@ -75,7 +75,7 @@ export const Table = ({ columns, data, loading = false }: TableProps) => { return ( <> -
+
diff --git a/src/constant/routes.ts b/src/constant/routes.ts index b7e91b2..6fb0cf8 100644 --- a/src/constant/routes.ts +++ b/src/constant/routes.ts @@ -145,4 +145,15 @@ export const API_ROUTES = { // Wallet APIs GET_WALLET_STATUS: "wallet/status", UPDATE_WALLET_STATUS: "wallet/status", + + // Reports APIs + DISCOUNT_USAGE_REPORT: "reports/discounts/usage", + CUSTOMER_DISCOUNT_USAGE_REPORT: "reports/discounts/customer-usage", + PAYMENT_METHODS_REPORT: "reports/payments/methods", + SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method", + + // Product Comments APIs + GET_PRODUCT_COMMENTS: "products/comments", + UPDATE_COMMENT_STATUS: (commentId: string) => `products/comments/${commentId}/status`, + DELETE_COMMENT: (commentId: string) => `products/comments/${commentId}`, }; diff --git a/src/pages/admin-users/admin-user-form/AdminUserFormPage.tsx b/src/pages/admin-users/admin-user-form/AdminUserFormPage.tsx index 361312f..36bc29f 100644 --- a/src/pages/admin-users/admin-user-form/AdminUserFormPage.tsx +++ b/src/pages/admin-users/admin-user-form/AdminUserFormPage.tsx @@ -228,7 +228,7 @@ const AdminUserFormPage = () => { diff --git a/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx b/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx index 9f4b44f..3aae7bf 100644 --- a/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx +++ b/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx @@ -339,7 +339,7 @@ const DiscountCodeFormPage = () => {
{
setNewStatus(e.target.value as OrderStatus)} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > diff --git a/src/pages/orders/orders-list/OrdersListPage.tsx b/src/pages/orders/orders-list/OrdersListPage.tsx index 67355e2..7606489 100644 --- a/src/pages/orders/orders-list/OrdersListPage.tsx +++ b/src/pages/orders/orders-list/OrdersListPage.tsx @@ -331,7 +331,7 @@ const OrdersListPage = () => { setFilters(prev => ({ ...prev, payment_status: e.target.value as any || undefined, page: 1 }))} - className="w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > @@ -500,7 +500,7 @@ const OrdersListPage = () => { 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" + > + + + + + +
+ +
+ + handleNumericFilterChange('productId', e.target.value)} + placeholder="مثلاً 123" + numeric + /> +
+ +
+ + handleNumericFilterChange('userId', e.target.value)} + placeholder="مثلاً 456" + numeric + /> +
+
+ + + {/* Table */} + {isLoading ? ( +
+
+ + ) : error ? ( +
+

خطا در دریافت داده‌ها

+
+ ) : ( + <> +
+
+ + + {data && data.total > 0 && totalPages > 1 && ( +
+ +
+ )} + + {data && data.total === 0 && ( +
+ +

نظری یافت نشد

+
+ )} + + )} + + {/* Status Update Modal */} + setStatusUpdateId(null)} + title="تغییر وضعیت نظر" + > +
+

+ آیا می‌خواهید وضعیت این نظر را به{' '} + + {newStatus === 'approved' ? 'تایید شده' : 'رد شده'} + {' '} + تغییر دهید؟ +

+
+ + +
+
+
+ + {/* Delete Modal */} + setDeleteId(null)} + title="حذف نظر" + > +
+

+ آیا از حذف این نظر اطمینان دارید؟ این عمل قابل بازگشت نیست. +

+
+ + +
+
+
+ + ); +}; + +export default ProductCommentsListPage; + diff --git a/src/pages/products/comments/core/_hooks.ts b/src/pages/products/comments/core/_hooks.ts new file mode 100644 index 0000000..f7d6ac5 --- /dev/null +++ b/src/pages/products/comments/core/_hooks.ts @@ -0,0 +1,56 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/utils/query-key"; +import toast from "react-hot-toast"; +import { + getProductComments, + updateCommentStatus, + deleteComment, +} from "./_requests"; +import { + ProductCommentFilters, + UpdateCommentStatusRequest, +} from "./_models"; + +export const useProductComments = (filters: ProductCommentFilters) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS, filters], + queryFn: () => getProductComments(filters), + }); +}; + +export const useUpdateCommentStatus = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + commentId, + payload, + }: { + commentId: string; + payload: UpdateCommentStatusRequest; + }) => updateCommentStatus(commentId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS] }); + toast.success("وضعیت نظر با موفقیت تغییر کرد"); + }, + onError: () => { + toast.error("خطا در تغییر وضعیت نظر"); + }, + }); +}; + +export const useDeleteComment = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (commentId: string) => deleteComment(commentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.GET_PRODUCT_COMMENTS] }); + toast.success("نظر با موفقیت حذف شد"); + }, + onError: () => { + toast.error("خطا در حذف نظر"); + }, + }); +}; + diff --git a/src/pages/products/comments/core/_models.ts b/src/pages/products/comments/core/_models.ts new file mode 100644 index 0000000..0b63654 --- /dev/null +++ b/src/pages/products/comments/core/_models.ts @@ -0,0 +1,47 @@ +export type CommentStatus = 'pending' | 'approved' | 'rejected'; + +export interface ProductCommentFilters { + status?: CommentStatus; + productId?: number; + userId?: number; + limit: number; + offset: number; +} + +export interface User { + first_name: string; + last_name: string; +} + +export interface ProductComment { + id: number; + user_id: number; + product_id: number; + rating: number; + subject: string; + comment: string; + comment_status: CommentStatus; + created_at: string; // ISO 8601 + updated_at: string; // ISO 8601 + user?: User; +} + +export interface ProductCommentsResponse { + comments: ProductComment[]; + total: number; + limit: number; + offset: number; + has_more: boolean; +} + +export interface UpdateCommentStatusRequest { + status: 'approved' | 'rejected'; +} + +export interface UpdateCommentStatusResponse extends ProductComment {} + +export interface DeleteCommentResponse { + message: string; + deleted: boolean; +} + diff --git a/src/pages/products/comments/core/_requests.ts b/src/pages/products/comments/core/_requests.ts new file mode 100644 index 0000000..6c6bf09 --- /dev/null +++ b/src/pages/products/comments/core/_requests.ts @@ -0,0 +1,52 @@ +import { + httpGetRequest, + httpPutRequest, + httpDeleteRequest, + APIUrlGenerator, +} from "@/utils/baseHttpService"; +import { API_ROUTES } from "@/constant/routes"; +import { + ProductCommentFilters, + ProductCommentsResponse, + UpdateCommentStatusRequest, + UpdateCommentStatusResponse, + DeleteCommentResponse, +} from "./_models"; + +export const getProductComments = async ( + filters: ProductCommentFilters +): Promise => { + const queryParams: Record = {}; + + if (filters.status) queryParams.status = filters.status; + if (filters.productId) queryParams.productId = filters.productId; + if (filters.userId) queryParams.userId = filters.userId; + queryParams.limit = filters.limit; + queryParams.offset = filters.offset; + + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.GET_PRODUCT_COMMENTS, queryParams) + ); + return response.data; +}; + +export const updateCommentStatus = async ( + commentId: string, + payload: UpdateCommentStatusRequest +): Promise => { + const response = await httpPutRequest( + APIUrlGenerator(API_ROUTES.UPDATE_COMMENT_STATUS(commentId)), + payload + ); + return response.data; +}; + +export const deleteComment = async ( + commentId: string +): Promise => { + const response = await httpDeleteRequest( + APIUrlGenerator(API_ROUTES.DELETE_COMMENT(commentId)) + ); + return response.data; +}; + diff --git a/src/pages/products/product-form/ProductFormPage.tsx b/src/pages/products/product-form/ProductFormPage.tsx index 04c9d20..b70a781 100644 --- a/src/pages/products/product-form/ProductFormPage.tsx +++ b/src/pages/products/product-form/ProductFormPage.tsx @@ -555,7 +555,7 @@ const ProductFormPage = () => { ) : ( {(categories || []).map((category) => ( @@ -221,7 +221,7 @@ const ProductsListPage = () => {
+ + + {[...Array(7)].map((_, i) => ( + + ))} + + + + {[...Array(5)].map((_, i) => ( + + {[...Array(7)].map((_, j) => ( + + ))} + + ))} + +
+
+
+
+
+
+
+ +); + +const CustomerDiscountUsagePage = () => { + const [filters, setFilters] = useState({ + user_id: 0, + limit: 50, + offset: 0, + }); + + const { data, isLoading, error } = useCustomerDiscountUsageReport(filters); + + const handleFilterChange = (key: keyof CustomerDiscountUsageFilters, value: any) => { + setFilters(prev => ({ + ...prev, + [key]: value, + offset: 0, + })); + }; + + const handleDateRangeChange = (from: string | undefined, to: string | undefined) => { + setFilters(prev => ({ + ...prev, + date_range: { + from, + to, + }, + offset: 0, + })); + }; + + const handleNumericFilterChange = (key: 'discount_id' | 'user_id', raw: string) => { + const converted = persianToEnglish(raw).replace(/[^\d]/g, ''); + const numeric = converted ? Number(converted) : undefined; + if (key === 'user_id') { + handleFilterChange('user_id', numeric || 0); + } else { + handleFilterChange(key, numeric); + } + }; + + const handlePageChange = (page: number) => { + setFilters(prev => ({ + ...prev, + offset: (page - 1) * prev.limit, + })); + }; + + const handleClearFilters = () => { + setFilters({ + user_id: 0, + limit: 50, + offset: 0, + }); + }; + + const columns: TableColumn[] = [ + { + key: 'discount_code', + label: 'کد تخفیف', + align: 'right', + }, + { + key: 'discount_name', + label: 'نام کد تخفیف', + align: 'right', + }, + { + key: 'order_number', + label: 'شماره سفارش', + align: 'right', + }, + { + key: 'amount', + label: 'مبلغ تخفیف', + align: 'right', + }, + { + key: 'used_at', + label: 'زمان استفاده', + align: 'right', + }, + ]; + + const tableData = (data?.usages || []).map(usage => ({ + discount_code: usage.discount_code, + discount_name: usage.discount_name, + order_number: usage.order_number || '-', + amount: formatCurrency(usage.amount), + used_at: formatDate(usage.used_at), + })); + + const currentPage = Math.floor(filters.offset / filters.limit) + 1; + const totalPages = data ? Math.ceil(data.total / filters.limit) : 1; + + return ( + + گزارش استفاده کاربر خاص از کدهای تخفیف + + {/* Filters */} +
+
+
+ +

فیلترها

+
+ +
+ +
+
+ + handleNumericFilterChange('user_id', e.target.value)} + placeholder="مثلاً 456" + numeric + required + /> +
+ +
+ + handleFilterChange('discount_code', e.target.value || undefined)} + placeholder="مثلاً SUMMER2025" + /> +
+ +
+ + handleNumericFilterChange('discount_id', e.target.value)} + placeholder="مثلاً 123" + numeric + /> +
+ +
+ + handleDateRangeChange(value, filters.date_range?.to)} + placeholder="انتخاب تاریخ شروع" + /> +
+ +
+ + handleDateRangeChange(filters.date_range?.from, value)} + placeholder="انتخاب تاریخ پایان" + /> +
+
+
+ + {/* Summary Cards */} + {data?.summary && ( +
+
+
+
+ +
+
+

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

+

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

+
+
+
+ +
+
+
+ +
+
+

مجموع تخفیف دریافتی

+

+ {formatCurrency(data.summary.total_discount_amount)} +

+
+
+
+ +
+
+
+ +
+
+

کدهای متفاوت

+

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

+
+
+
+ +
+
+
+ +
+
+

میانگین تخفیف هر سفارش

+

+ {formatCurrency(data.summary.average_discount_per_order)} +

+
+
+
+
+ )} + + {/* Table */} + {isLoading ? ( + + ) : error ? ( +
+

خطا در دریافت داده‌ها

+
+ ) : filters.user_id === 0 ? ( +
+

لطفاً شناسه کاربر را وارد کنید

+
+ ) : ( + <> +
+ + + + {data && data.total > 0 && totalPages > 1 && ( +
+ +
+ )} + + {data && data.total === 0 && ( +
+

داده‌ای یافت نشد

+
+ )} + + )} + + ); +}; + +export default CustomerDiscountUsagePage; + diff --git a/src/pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage.tsx b/src/pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage.tsx new file mode 100644 index 0000000..bec6f1e --- /dev/null +++ b/src/pages/reports/discount-statistics/discount-usage-report/DiscountUsageReportPage.tsx @@ -0,0 +1,385 @@ +import React, { useState } from 'react'; +import { useDiscountUsageReport } from '../core/_hooks'; +import { DiscountUsageFilters } from '../core/_models'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Table } from '@/components/ui/Table'; +import { TableColumn } from '@/types'; +import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker'; +import { PageContainer, PageTitle } from '@/components/ui/Typography'; +import { Pagination } from '@/components/ui/Pagination'; +import { Filter, TrendingUp, Users, DollarSign, Hash, X } from 'lucide-react'; +import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils'; + +const formatCurrency = (amount: number) => { + return formatWithThousands(amount) + ' تومان'; +}; + +const formatDate = (dateString: string) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString('fa-IR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const DiscountUsageReportSkeleton = () => ( + <> + {/* Summary Cards Skeleton */} +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* Table Skeleton */} +
+
+
+ + + {[...Array(6)].map((_, i) => ( + + ))} + + + + {[...Array(5)].map((_, i) => ( + + {[...Array(6)].map((_, j) => ( + + ))} + + ))} + +
+
+
+
+
+
+
+ +); + +const DiscountUsageReportPage = () => { + const [filters, setFilters] = useState({ + limit: 50, + offset: 0, + group_by_code: false, + }); + + const { data, isLoading, error } = useDiscountUsageReport(filters); + + const handleFilterChange = (key: keyof DiscountUsageFilters, value: any) => { + setFilters(prev => ({ + ...prev, + [key]: value, + offset: 0, // Reset pagination when filters change + })); + }; + + const handleDateRangeChange = (from: string | undefined, to: string | undefined) => { + setFilters(prev => ({ + ...prev, + date_range: { + from, + to, + }, + offset: 0, + })); + }; + + const handleNumericFilterChange = (key: 'discount_id' | 'user_id', raw: string) => { + const converted = persianToEnglish(raw).replace(/[^\d]/g, ''); + const numeric = converted ? Number(converted) : undefined; + handleFilterChange(key, numeric); + }; + + const handlePageChange = (page: number) => { + setFilters(prev => ({ + ...prev, + offset: (page - 1) * prev.limit, + })); + }; + + const handleClearFilters = () => { + setFilters({ + limit: 50, + offset: 0, + group_by_code: false, + }); + }; + + const columns: TableColumn[] = [ + { + key: 'discount_code', + label: 'کد تخفیف', + align: 'right', + }, + { + key: 'discount_name', + label: 'نام کد تخفیف', + align: 'right', + }, + { + key: 'usage_count', + label: 'تعداد استفاده', + align: 'right', + }, + { + key: 'total_amount', + label: 'مجموع تخفیف', + align: 'right', + }, + { + key: 'unique_users', + label: 'کاربران یونیک', + align: 'right', + }, + { + key: 'first_used_at', + label: 'اولین استفاده', + align: 'right', + }, + { + key: 'last_used_at', + label: 'آخرین استفاده', + align: 'right', + }, + ]; + + const tableData = (data?.usages || []).map(usage => ({ + discount_code: usage.discount_code, + discount_name: usage.discount_name, + usage_count: formatWithThousands(usage.usage_count), + total_amount: formatCurrency(usage.total_amount), + unique_users: formatWithThousands(usage.unique_users), + first_used_at: formatDate(usage.first_used_at), + last_used_at: formatDate(usage.last_used_at), + })); + + const currentPage = Math.floor(filters.offset / filters.limit) + 1; + const totalPages = data ? Math.ceil(data.total / filters.limit) : 1; + + return ( + + گزارش جامع استفاده از کدهای تخفیف + + {/* Filters */} +
+
+
+ +

فیلترها

+
+ +
+ +
+
+ + handleFilterChange('discount_code', e.target.value || undefined)} + placeholder="مثلاً SUMMER2025" + /> +
+ +
+ + handleNumericFilterChange('discount_id', e.target.value)} + placeholder="مثلاً 123" + numeric + /> +
+ +
+ + handleNumericFilterChange('user_id', e.target.value)} + placeholder="مثلاً 456" + numeric + /> +
+ +
+ + handleDateRangeChange(value, filters.date_range?.to)} + placeholder="انتخاب تاریخ شروع" + /> +
+ +
+ + handleDateRangeChange(filters.date_range?.from, value)} + placeholder="انتخاب تاریخ پایان" + /> +
+ +
+ +
+
+
+ + {/* Summary Cards */} + {data?.summary && ( +
+
+
+
+ +
+
+

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

+

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

+
+
+
+ +
+
+
+ +
+
+

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

+

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

+
+
+
+ +
+
+
+ +
+
+

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

+

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

+
+
+
+ +
+
+
+ +
+
+

کدهای یونیک

+

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

+
+
+
+ +
+
+
+ +
+
+

پرکاربردترین کد

+

+ {data.summary.most_used_code || '-'} +

+

+ {formatWithThousands(data.summary.most_used_code_count)} بار استفاده +

+
+
+
+
+ )} + + {/* Table */} + {isLoading ? ( + + ) : error ? ( +
+

خطا در دریافت داده‌ها

+
+ ) : ( + <> +
+ + + + {data && data.total > 0 && (data.total > filters.limit || data.has_more) && ( +
+ +
+ )} + + {data && data.total === 0 && ( +
+

داده‌ای یافت نشد

+
+ )} + + )} + + ); +}; + +export default DiscountUsageReportPage; + diff --git a/src/pages/reports/payment-statistics/core/_hooks.ts b/src/pages/reports/payment-statistics/core/_hooks.ts new file mode 100644 index 0000000..28c3d68 --- /dev/null +++ b/src/pages/reports/payment-statistics/core/_hooks.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/utils/query-key"; +import { getPaymentMethodsReport } from "./_requests"; +import { PaymentMethodsFilters } from "./_models"; + +export const usePaymentMethodsReport = (filters: PaymentMethodsFilters) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_PAYMENT_METHODS_REPORT, filters], + queryFn: () => getPaymentMethodsReport(filters), + enabled: filters.limit > 0, + }); +}; + diff --git a/src/pages/reports/payment-statistics/core/_models.ts b/src/pages/reports/payment-statistics/core/_models.ts new file mode 100644 index 0000000..efdb075 --- /dev/null +++ b/src/pages/reports/payment-statistics/core/_models.ts @@ -0,0 +1,56 @@ +export interface DateRange { + from?: string; // ISO 8601 + to?: string; // ISO 8601 +} + +export interface PaymentMethodsFilters { + user_id?: number; + date_range?: DateRange; + payment_type?: string; + status?: 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled'; + group_by_user?: boolean; + limit: number; + offset: number; +} + +export interface PaymentMethod { + user_id: number; + customer_name: string; + customer_phone: string; + payment_type: string; + successful_count: number; + failed_count: number; + total_attempts: number; + total_amount: number; // ریال + success_rate: number; // درصد (0-100) + first_used_at: string; // ISO 8601 + last_used_at: string; // ISO 8601 +} + +export interface PaymentTypeSummary { + count: number; + success_count: number; + failed_count: number; + total_amount: number; // ریال + percentage: number; + success_rate: number; // درصد +} + +export interface PaymentMethodsSummary { + total_transactions: number; + successful_transactions: number; + failed_transactions: number; + total_amount: number; // ریال + by_payment_type: Record; + overall_success_rate: number; // درصد +} + +export interface PaymentMethodsResponse { + payment_methods: PaymentMethod[]; + summary: PaymentMethodsSummary; + total: number; + has_more: boolean; + limit: number; + offset: number; +} + diff --git a/src/pages/reports/payment-statistics/core/_requests.ts b/src/pages/reports/payment-statistics/core/_requests.ts new file mode 100644 index 0000000..ba9d9fb --- /dev/null +++ b/src/pages/reports/payment-statistics/core/_requests.ts @@ -0,0 +1,17 @@ +import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService"; +import { API_ROUTES } from "@/constant/routes"; +import { + PaymentMethodsFilters, + PaymentMethodsResponse, +} from "./_models"; + +export const getPaymentMethodsReport = async ( + filters: PaymentMethodsFilters +): Promise => { + const response = await httpPostRequest( + APIUrlGenerator(API_ROUTES.PAYMENT_METHODS_REPORT), + filters + ); + return response.data; +}; + diff --git a/src/pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage.tsx b/src/pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage.tsx new file mode 100644 index 0000000..f083ca2 --- /dev/null +++ b/src/pages/reports/payment-statistics/payment-methods-report/PaymentMethodsReportPage.tsx @@ -0,0 +1,525 @@ +import React, { useState } from 'react'; +import { usePaymentMethodsReport } from '../core/_hooks'; +import { PaymentMethodsFilters } from '../core/_models'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Table } from '@/components/ui/Table'; +import { TableColumn } from '@/types'; +import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker'; +import { PageContainer, PageTitle } from '@/components/ui/Typography'; +import { Pagination } from '@/components/ui/Pagination'; +import { Filter, TrendingUp, Users, DollarSign, CreditCard, CheckCircle, XCircle, X } from 'lucide-react'; +import { formatWithThousands, persianToEnglish } from '@/utils/numberUtils'; +import { PieChart } from '@/components/charts/PieChart'; + +const formatCurrency = (amount: number) => { + return formatWithThousands(amount) + ' تومان'; +}; + +const formatDate = (dateString: string) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString('fa-IR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const formatPercentage = (value: number) => { + return formatWithThousands(value.toFixed(2)) + '%'; +}; + +const getPaymentTypeLabel = (type: string): string => { + const labels: Record = { + 'bank-topup': 'افزایش موجودی کیف پول', + 'card-to-card': 'پرداخت به روش کارت به کارت', + 'debit-rial-wallet': 'پرداخت از کیف ریالی', + 'debit-gold18k-wallet': 'پرداخت از کیف طلا', + }; + return labels[type] || type; +}; + +const PaymentMethodsReportSkeleton = () => ( + <> + {/* Summary Cards Skeleton */} +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* Pie Chart and Total Amount Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+ + {/* Payment Type Cards Skeleton */} +
+
+
+ {[...Array(5)].map((_, i) => ( +
+
+
+ {[...Array(5)].map((_, j) => ( +
+
+
+
+ ))} +
+
+ ))} +
+
+ + {/* Table Skeleton */} +
+
+
+ + + {[...Array(10)].map((_, i) => ( + + ))} + + + + {[...Array(5)].map((_, i) => ( + + {[...Array(10)].map((_, j) => ( + + ))} + + ))} + +
+
+
+
+
+
+
+ +); + +const PaymentMethodsReportPage = () => { + const [filters, setFilters] = useState({ + limit: 50, + offset: 0, + group_by_user: false, + }); + + const { data, isLoading, error } = usePaymentMethodsReport(filters); + + const handleFilterChange = (key: keyof PaymentMethodsFilters, value: any) => { + setFilters(prev => ({ + ...prev, + [key]: value, + offset: 0, + })); + }; + + const handleDateRangeChange = (from: string | undefined, to: string | undefined) => { + setFilters(prev => ({ + ...prev, + date_range: { + from, + to, + }, + offset: 0, + })); + }; + + const handleNumericFilterChange = (key: 'user_id', raw: string) => { + const converted = persianToEnglish(raw).replace(/[^\d]/g, ''); + const numeric = converted ? Number(converted) : undefined; + handleFilterChange(key, numeric); + }; + + const handlePageChange = (page: number) => { + setFilters(prev => ({ + ...prev, + offset: (page - 1) * prev.limit, + })); + }; + + const handleClearFilters = () => { + setFilters({ + limit: 50, + offset: 0, + group_by_user: false, + }); + }; + + const columns: TableColumn[] = [ + { + key: 'customer_name', + label: 'نام مشتری', + align: 'right', + }, + { + key: 'customer_phone', + label: 'شماره تماس', + align: 'right', + }, + { + key: 'payment_type', + label: 'نوع پرداخت', + align: 'right', + }, + { + key: 'successful_count', + label: 'موفق', + align: 'right', + }, + { + key: 'failed_count', + label: 'ناموفق', + align: 'right', + }, + { + key: 'total_attempts', + label: 'کل تلاش‌ها', + align: 'right', + }, + { + key: 'total_amount', + label: 'مجموع مبلغ', + align: 'right', + }, + { + key: 'success_rate', + label: 'نرخ موفقیت', + align: 'right', + }, + { + key: 'first_used_at', + label: 'اولین استفاده', + align: 'right', + }, + { + key: 'last_used_at', + label: 'آخرین استفاده', + align: 'right', + }, + ]; + + const tableData = (data?.payment_methods || []).map(method => ({ + customer_name: method.customer_name || '-', + customer_phone: method.customer_phone || '-', + payment_type: getPaymentTypeLabel(method.payment_type), + successful_count: formatWithThousands(method.successful_count), + failed_count: formatWithThousands(method.failed_count), + total_attempts: formatWithThousands(method.total_attempts), + total_amount: formatCurrency(method.total_amount), + success_rate: formatPercentage(method.success_rate), + first_used_at: formatDate(method.first_used_at), + last_used_at: formatDate(method.last_used_at), + })) || []; + + const currentPage = Math.floor(filters.offset / filters.limit) + 1; + const totalPages = data ? Math.ceil(data.total / filters.limit) : 1; + + return ( + + گزارش روش‌های پرداخت + + {/* Filters */} +
+
+
+ +

فیلترها

+
+ +
+ +
+
+ + handleNumericFilterChange('user_id', e.target.value)} + placeholder="مثلاً 456" + numeric + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + handleDateRangeChange(value, filters.date_range?.to)} + placeholder="انتخاب تاریخ شروع" + /> +
+ +
+ + handleDateRangeChange(filters.date_range?.from, value)} + placeholder="انتخاب تاریخ پایان" + /> +
+ +
+ +
+
+
+ + {/* Summary Cards */} + {data?.summary && ( + <> +
+
+
+
+ +
+
+

کل تراکنش‌ها

+

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

+
+
+
+ +
+
+
+ +
+
+

تراکنش‌های موفق

+

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

+
+
+
+ +
+
+
+ +
+
+

تراکنش‌های ناموفق

+

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

+
+
+
+ +
+
+
+ +
+
+

نرخ موفقیت کلی

+

+ {formatPercentage(data.summary.overall_success_rate)} +

+
+
+
+
+ + {/* Payment Type Breakdown */} + {Object.keys(data.summary.by_payment_type).length > 0 && ( + <> + {/* Pie Chart and Total Amount */} +
+ {/* Pie Chart */} +
+

+ نمودار توزیع روش‌های پرداخت +

+ ({ + name: getPaymentTypeLabel(type), + value: stats.percentage, + }))} + title="درصد استفاده از هر روش پرداخت" + colors={['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6', '#f97316']} + /> +
+ + {/* Total Amount Card */} +
+
+
+ +
+

مجموع مبلغ

+

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

+
+
+
+ + {/* Payment Type Cards */} +
+

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

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

{getPaymentTypeLabel(type)}

+
+
+ کل: + {formatWithThousands(stats.count)} +
+
+ موفق: + {formatWithThousands(stats.success_count)} +
+
+ ناموفق: + {formatWithThousands(stats.failed_count)} +
+
+ نرخ موفقیت: + {formatPercentage(stats.success_rate)} +
+
+ درصد از کل: + {formatPercentage(stats.percentage)} +
+
+
+ ))} +
+
+ + )} + + )} + + {/* Table */} + {isLoading ? ( + + ) : error ? ( +
+

خطا در دریافت داده‌ها

+
+ ) : ( + <> +
+
+ + + + + {data && data.total > 0 && totalPages > 1 && ( +
+ +
+ )} + + {data && data.total === 0 && ( +
+

داده‌ای یافت نشد

+
+ )} + + )} + + ); +}; + +export default PaymentMethodsReportPage; + diff --git a/src/pages/reports/shipment-statistics/core/_hooks.ts b/src/pages/reports/shipment-statistics/core/_hooks.ts new file mode 100644 index 0000000..c2ff9cd --- /dev/null +++ b/src/pages/reports/shipment-statistics/core/_hooks.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/utils/query-key"; +import { getShipmentsByMethodReport } from "./_requests"; +import { ShipmentsByMethodFilters } from "./_models"; + +export const useShipmentsByMethodReport = (filters: ShipmentsByMethodFilters) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_SHIPMENTS_BY_METHOD_REPORT, filters], + queryFn: () => getShipmentsByMethodReport(filters), + enabled: filters.limit > 0, + }); +}; + diff --git a/src/pages/reports/shipment-statistics/core/_models.ts b/src/pages/reports/shipment-statistics/core/_models.ts new file mode 100644 index 0000000..24121a6 --- /dev/null +++ b/src/pages/reports/shipment-statistics/core/_models.ts @@ -0,0 +1,77 @@ +export interface DateRange { + from?: string; // ISO 8601 + to?: string; // ISO 8601 +} + +export interface ShipmentsByMethodFilters { + shipping_method_code?: string; + shipping_method_id?: number; + date_range?: DateRange; + customer_name?: string; + user_id?: number; + status?: 'pending' | 'confirmed' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; + payment_status?: 'pending' | 'paid' | 'failed' | 'refunded' | 'cancelled'; + min_shipping_cost?: number; + max_shipping_cost?: number; + group_by_method?: boolean; + limit: number; + offset: number; +} + +export interface Shipment { + order_id: number; + order_number: string; + user_id: number; + customer_name: string; + customer_phone: string; + shipping_method_id: number; + shipping_method: string; + shipping_method_code: string; + shipping_cost: number; // ریال + delivery_date?: string; // YYYY-MM-DD + delivery_from_hour?: number; // 0-23 + delivery_to_hour?: number; // 0-23 + status: string; + payment_status: string; + total_weight: number; // گرم + order_amount: number; // ریال + created_at: string; // ISO 8601 + shipped_at?: string; // ISO 8601 + delivered_at?: string; // ISO 8601 +} + +export interface MethodSummary { + shipping_method_id: number; + shipping_method: string; + shipping_method_code: string; + shipment_count: number; + total_revenue: number; // ریال + total_shipping_cost: number; // ریال + average_weight: number; // گرم + delivered_count: number; + cancelled_count: number; +} + +export interface ShipmentsSummary { + total_shipments: number; + total_shipping_cost: number; // ریال + total_order_amount: number; // ریال + total_weight: number; // گرم + pending_shipments: number; + shipped_count: number; + delivered_count: number; + cancelled_count: number; + average_shipping_cost: number; // ریال + average_delivery_time?: number; // ساعت +} + +export interface ShipmentsByMethodResponse { + shipments: Shipment[]; + summary: ShipmentsSummary; + method_summaries?: MethodSummary[]; + total: number; + has_more: boolean; + limit: number; + offset: number; +} + diff --git a/src/pages/reports/shipment-statistics/core/_requests.ts b/src/pages/reports/shipment-statistics/core/_requests.ts new file mode 100644 index 0000000..38d0059 --- /dev/null +++ b/src/pages/reports/shipment-statistics/core/_requests.ts @@ -0,0 +1,17 @@ +import { httpPostRequest, APIUrlGenerator } from "@/utils/baseHttpService"; +import { API_ROUTES } from "@/constant/routes"; +import { + ShipmentsByMethodFilters, + ShipmentsByMethodResponse, +} from "./_models"; + +export const getShipmentsByMethodReport = async ( + filters: ShipmentsByMethodFilters +): Promise => { + const response = await httpPostRequest( + APIUrlGenerator(API_ROUTES.SHIPMENTS_BY_METHOD_REPORT), + filters + ); + return response.data; +}; + diff --git a/src/pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage.tsx b/src/pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage.tsx new file mode 100644 index 0000000..7d7f365 --- /dev/null +++ b/src/pages/reports/shipment-statistics/shipments-by-method-report/ShipmentsByMethodReportPage.tsx @@ -0,0 +1,593 @@ +import React, { useState } from 'react'; +import { useShipmentsByMethodReport } from '../core/_hooks'; +import { ShipmentsByMethodFilters } from '../core/_models'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { Table } from '@/components/ui/Table'; +import { TableColumn } from '@/types'; +import { JalaliDateTimePicker } from '@/components/ui/JalaliDateTimePicker'; +import { PageContainer, PageTitle } from '@/components/ui/Typography'; +import { Pagination } from '@/components/ui/Pagination'; +import { Filter, Truck, DollarSign, Package, Users, Clock, X } from 'lucide-react'; +import { formatWithThousands, parseFormattedNumber, persianToEnglish } from '@/utils/numberUtils'; + +const formatCurrency = (amount: number) => { + return formatWithThousands(amount) + ' تومان'; +}; + +const formatDate = (dateString: string) => { + if (!dateString) return '-'; + return new Date(dateString).toLocaleDateString('fa-IR', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +const formatWeight = (weight: number) => { + return formatWithThousands(weight) + ' گرم'; +}; + +const ShipmentsByMethodReportSkeleton = () => ( + <> + {/* Summary Cards Skeleton */} +
+ {[...Array(8)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ + {/* Method Summaries Skeleton */} +
+
+
+ {[...Array(3)].map((_, i) => ( +
+
+
+ {[...Array(6)].map((_, j) => ( +
+
+
+
+ ))} +
+
+ ))} +
+
+ + {/* Table Skeleton */} +
+
+
+ + + {[...Array(9)].map((_, i) => ( + + ))} + + + + {[...Array(5)].map((_, i) => ( + + {[...Array(9)].map((_, j) => ( + + ))} + + ))} + +
+
+
+
+
+
+
+ +); + +const ShipmentsByMethodReportPage = () => { + const [filters, setFilters] = useState({ + limit: 50, + offset: 0, + group_by_method: false, + }); + + const { data, isLoading, error } = useShipmentsByMethodReport(filters); + + const handleFilterChange = (key: keyof ShipmentsByMethodFilters, value: any) => { + setFilters(prev => ({ + ...prev, + [key]: value, + offset: 0, + })); + }; + + const handleDateRangeChange = (from: string | undefined, to: string | undefined) => { + setFilters(prev => ({ + ...prev, + date_range: { + from, + to, + }, + offset: 0, + })); + }; + + const handleNumericFilterChange = (key: 'shipping_method_id' | 'user_id' | 'min_shipping_cost' | 'max_shipping_cost', raw: string) => { + const converted = persianToEnglish(raw).replace(/[^\d]/g, ''); + const numeric = converted ? Number(converted) : undefined; + handleFilterChange(key, numeric); + }; + + const handlePageChange = (page: number) => { + setFilters(prev => ({ + ...prev, + offset: (page - 1) * prev.limit, + })); + }; + + const handleClearFilters = () => { + setFilters({ + limit: 50, + offset: 0, + group_by_method: false, + }); + }; + + const columns: TableColumn[] = [ + { + key: 'order_number', + label: 'شماره سفارش', + align: 'right', + }, + { + key: 'customer_name', + label: 'نام مشتری', + align: 'right', + }, + { + key: 'customer_phone', + label: 'شماره تماس', + align: 'right', + }, + { + key: 'shipping_method', + label: 'روش ارسال', + align: 'right', + }, + { + key: 'shipping_cost', + label: 'هزینه ارسال', + align: 'right', + }, + { + key: 'order_amount', + label: 'مبلغ سفارش', + align: 'right', + }, + { + key: 'total_weight', + label: 'وزن', + align: 'right', + }, + { + key: 'status', + label: 'وضعیت', + align: 'right', + }, + { + key: 'payment_status', + label: 'وضعیت پرداخت', + align: 'right', + }, + { + key: 'created_at', + label: 'زمان ثبت', + align: 'right', + }, + ]; + + const tableData = (data?.shipments || []).map(shipment => ({ + order_number: shipment.order_number || '-', + customer_name: shipment.customer_name || '-', + customer_phone: shipment.customer_phone || '-', + shipping_method: shipment.shipping_method || '-', + shipping_cost: formatCurrency(shipment.shipping_cost), + order_amount: formatCurrency(shipment.order_amount), + total_weight: formatWeight(shipment.total_weight), + status: shipment.status, + payment_status: shipment.payment_status, + created_at: formatDate(shipment.created_at), + })) || []; + + const currentPage = Math.floor(filters.offset / filters.limit) + 1; + const totalPages = data ? Math.ceil(data.total / filters.limit) : 1; + + return ( + + گزارش ارسال‌ها بر اساس روش + + {/* Filters */} +
+
+
+ +

فیلترها

+
+ +
+ +
+
+ + +
+ +
+ + handleNumericFilterChange('shipping_method_id', e.target.value)} + placeholder="مثلاً 1" + numeric + /> +
+ +
+ + handleNumericFilterChange('user_id', e.target.value)} + placeholder="مثلاً 456" + numeric + /> +
+ +
+ + handleFilterChange('customer_name', e.target.value || undefined)} + placeholder="جستجو در نام" + /> +
+ +
+ + +
+ +
+ + +
+ +
+ + handleNumericFilterChange('min_shipping_cost', e.target.value)} + placeholder="مثلاً 10000" + numeric + thousandSeparator + /> +
+ +
+ + handleNumericFilterChange('max_shipping_cost', e.target.value)} + placeholder="مثلاً 50000" + numeric + thousandSeparator + /> +
+ +
+ + handleDateRangeChange(value, filters.date_range?.to)} + placeholder="انتخاب تاریخ شروع" + /> +
+ +
+ + handleDateRangeChange(filters.date_range?.from, value)} + placeholder="انتخاب تاریخ پایان" + /> +
+ +
+ +
+
+
+ + {/* Summary Cards */} + {data?.summary && ( +
+
+
+
+ +
+
+

کل ارسال‌ها

+

+ {formatWithThousands(data.summary.total_shipments)} +

+
+
+
+ +
+
+
+ +
+
+

مجموع هزینه ارسال

+

+ {formatCurrency(data.summary.total_shipping_cost)} +

+
+
+
+ +
+
+
+ +
+
+

مجموع مبلغ سفارشات

+

+ {formatCurrency(data.summary.total_order_amount)} +

+
+
+
+ +
+
+
+ +
+
+

میانگین هزینه

+

+ {formatCurrency(data.summary.average_shipping_cost)} +

+
+
+
+ +
+
+
+ +
+
+

در انتظار

+

+ {formatWithThousands(data.summary.pending_shipments)} +

+
+
+
+ +
+
+
+ +
+
+

ارسال شده

+

+ {formatWithThousands(data.summary.shipped_count)} +

+
+
+
+ +
+
+
+ +
+
+

تحویل داده شده

+

+ {formatWithThousands(data.summary.delivered_count)} +

+
+
+
+ +
+
+
+ +
+
+

لغو شده

+

+ {formatWithThousands(data.summary.cancelled_count)} +

+
+
+
+
+ )} + + {/* Method Summaries */} + {data?.method_summaries && data.method_summaries.length > 0 && ( +
+

+ آمار هر روش ارسال +

+
+ {data.method_summaries.map((method) => ( +
+

+ {method.shipping_method || method.shipping_method_code} +

+
+
+ تعداد ارسال: + {formatWithThousands(method.shipment_count)} +
+
+ مجموع درآمد: + {formatCurrency(method.total_revenue)} +
+
+ مجموع هزینه: + {formatCurrency(method.total_shipping_cost)} +
+
+ میانگین وزن: + {formatWeight(method.average_weight)} +
+
+ تحویل شده: + {formatWithThousands(method.delivered_count)} +
+
+ لغو شده: + {formatWithThousands(method.cancelled_count)} +
+
+
+ ))} +
+
+ )} + + {/* Table */} + {isLoading ? ( + + ) : error ? ( +
+

خطا در دریافت داده‌ها

+
+ ) : ( + <> +
+ + + + {data && data.total > 0 && totalPages > 1 && ( +
+ +
+ )} + + {data && data.total === 0 && ( +
+

داده‌ای یافت نشد

+
+ )} + + )} + + ); +}; + +export default ShipmentsByMethodReportPage; + diff --git a/src/pages/shipping-methods/core/_models.ts b/src/pages/shipping-methods/core/_models.ts index 45c1a9a..58a46e6 100644 --- a/src/pages/shipping-methods/core/_models.ts +++ b/src/pages/shipping-methods/core/_models.ts @@ -15,6 +15,7 @@ export interface ShippingMethod { time_note?: string; open_hours: ShippingOpenHour[]; addresses: string[]; + needs_address: boolean; created_at?: string; updated_at?: string; } diff --git a/src/pages/shipping-methods/shipping-method-form/ShippingMethodFormPage.tsx b/src/pages/shipping-methods/shipping-method-form/ShippingMethodFormPage.tsx index 75f7ce7..5e95757 100644 --- a/src/pages/shipping-methods/shipping-method-form/ShippingMethodFormPage.tsx +++ b/src/pages/shipping-methods/shipping-method-form/ShippingMethodFormPage.tsx @@ -34,6 +34,7 @@ const ShippingMethodFormPage = () => { }, ], addresses: [] as string[], + needs_address: false, }); useEffect(() => { @@ -60,6 +61,7 @@ const ShippingMethodFormPage = () => { }, ], addresses: data.addresses || [], + needs_address: data.needs_address ?? false, }); } }, [isEdit, data]); @@ -94,6 +96,7 @@ const ShippingMethodFormPage = () => { !Number.isNaN(item.to_hour) ), addresses: form.addresses, + needs_address: form.needs_address, }; if (isEdit && id) { update({ id: Number(id), ...payload }, { onSuccess: () => navigate('/shipping-methods') }); @@ -243,6 +246,15 @@ const ShippingMethodFormPage = () => { فعال +
+ +

+ در صورت فعال بودن، کاربر باید حتماً آدرس تحویل را وارد کند +

+
diff --git a/src/pages/tickets/ticket-config/TicketConfigPage.tsx b/src/pages/tickets/ticket-config/TicketConfigPage.tsx index 903899e..0150d8b 100644 --- a/src/pages/tickets/ticket-config/TicketConfigPage.tsx +++ b/src/pages/tickets/ticket-config/TicketConfigPage.tsx @@ -351,7 +351,7 @@ const TicketConfigPage = () => { is_active: e.target.value, })) } - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > @@ -435,7 +435,7 @@ const TicketConfigPage = () => { is_active: e.target.value, })) } - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > @@ -492,7 +492,7 @@ const TicketConfigPage = () => { department_id: e.target.value, })) } - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > {departments?.map((department) => ( @@ -539,7 +539,7 @@ const TicketConfigPage = () => { is_active: e.target.value, })) } - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > diff --git a/src/pages/tickets/ticket-detail/TicketDetailPage.tsx b/src/pages/tickets/ticket-detail/TicketDetailPage.tsx index 27af413..7975e13 100644 --- a/src/pages/tickets/ticket-detail/TicketDetailPage.tsx +++ b/src/pages/tickets/ticket-detail/TicketDetailPage.tsx @@ -218,7 +218,7 @@ const TicketDetailPage = () => { onChange={(e) => setStatusId(e.target.value ? Number(e.target.value) : undefined) } - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > {statuses?.map((status) => ( diff --git a/src/pages/tickets/tickets-list/TicketsListPage.tsx b/src/pages/tickets/tickets-list/TicketsListPage.tsx index f29d868..b8cc912 100644 --- a/src/pages/tickets/tickets-list/TicketsListPage.tsx +++ b/src/pages/tickets/tickets-list/TicketsListPage.tsx @@ -191,7 +191,7 @@ const TicketsListPage = () => { e.target.value ? Number(e.target.value) : undefined ) } - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > {statuses?.map((status) => ( @@ -213,7 +213,7 @@ const TicketsListPage = () => { e.target.value ? Number(e.target.value) : undefined ) } - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" + className="w-full px-3 py-3 text-base border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-gray-100 transition-all duration-200" > {departments?.map((department) => ( diff --git a/src/pages/users-admin/users-admin-list/UsersAdminListPage.tsx b/src/pages/users-admin/users-admin-list/UsersAdminListPage.tsx index 0c8ca0a..70c8d1a 100644 --- a/src/pages/users-admin/users-admin-list/UsersAdminListPage.tsx +++ b/src/pages/users-admin/users-admin-list/UsersAdminListPage.tsx @@ -286,7 +286,7 @@ const UsersAdminListPage: React.FC = () => {