diff --git a/package-lock.json b/package-lock.json index 5e67e45..c731b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,11 +13,13 @@ "@tanstack/react-query": "^5.80.6", "@tanstack/react-query-devtools": "^5.80.6", "@types/js-cookie": "^3.0.6", + "apexcharts": "^5.3.6", "axios": "^1.9.0", "clsx": "^2.0.0", "js-cookie": "^3.0.5", "lucide-react": "^0.263.1", "react": "^19.2.3", + "react-apexcharts": "^1.9.0", "react-date-object": "2.1.9", "react-dom": "^19.2.3", "react-hook-form": "^7.57.0", @@ -1344,6 +1346,62 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@svgdotjs/svg.draggable.js": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.draggable.js/-/svg.draggable.js-3.0.6.tgz", + "integrity": "sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==", + "license": "MIT", + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, + "node_modules/@svgdotjs/svg.filter.js": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.9.tgz", + "integrity": "sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw==", + "license": "MIT", + "dependencies": { + "@svgdotjs/svg.js": "^3.2.4" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/@svgdotjs/svg.js": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", + "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Fuzzyma" + } + }, + "node_modules/@svgdotjs/svg.resize.js": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.resize.js/-/svg.resize.js-2.0.5.tgz", + "integrity": "sha512-4heRW4B1QrJeENfi7326lUPYBCevj78FJs8kfeDxn5st0IYPIRXoTtOSYvTzFWgaWWXd3YCDE6ao4fmv91RthA==", + "license": "MIT", + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.select.js": "^4.0.1" + } + }, + "node_modules/@svgdotjs/svg.select.js": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", + "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", + "license": "MIT", + "engines": { + "node": ">= 14.18" + }, + "peerDependencies": { + "@svgdotjs/svg.js": "^3.2.4" + } + }, "node_modules/@tanstack/query-core": { "version": "5.80.6", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.6.tgz", @@ -1833,6 +1891,12 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, + "node_modules/@yr/monotone-cubic-spline": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@yr/monotone-cubic-spline/-/monotone-cubic-spline-1.0.3.tgz", + "integrity": "sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1973,6 +2037,20 @@ "node": ">= 8" } }, + "node_modules/apexcharts": { + "version": "5.3.6", + "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.3.6.tgz", + "integrity": "sha512-sVEPw+J0Gp0IHQabKu8cfdsxlfME0e36Wid7RIaPclGM2OUt+O7O4+6mfAmTUYhy5bDk8cNHzEhPfVtLCIXEJA==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "@svgdotjs/svg.draggable.js": "^3.0.4", + "@svgdotjs/svg.filter.js": "^3.0.8", + "@svgdotjs/svg.js": "^3.2.4", + "@svgdotjs/svg.resize.js": "^2.0.2", + "@svgdotjs/svg.select.js": "^4.0.1", + "@yr/monotone-cubic-spline": "^1.0.3" + } + }, "node_modules/arch": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", @@ -5787,6 +5865,19 @@ "node": ">=0.10.0" } }, + "node_modules/react-apexcharts": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/react-apexcharts/-/react-apexcharts-1.9.0.tgz", + "integrity": "sha512-DDBzQFuKdwyCEZnji1yIcjlnV8hRr4VDabS5Y3iuem/WcTq6n4VbjWPzbPm3aOwW4I+rf/gA3zWqhws4z9CwLw==", + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "apexcharts": ">=4.0.0", + "react": ">=16.8.0" + } + }, "node_modules/react-date-object": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz", diff --git a/package.json b/package.json index cfe77f7..68a0e9d 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "@tanstack/react-query": "^5.80.6", "@tanstack/react-query-devtools": "^5.80.6", "@types/js-cookie": "^3.0.6", + "apexcharts": "^5.3.6", "axios": "^1.9.0", "clsx": "^2.0.0", "js-cookie": "^3.0.5", "lucide-react": "^0.263.1", "react": "^19.2.3", + "react-apexcharts": "^1.9.0", "react-date-object": "2.1.9", "react-dom": "^19.2.3", "react-hook-form": "^7.57.0", diff --git a/src/components/charts/ApexAreaChartCard.tsx b/src/components/charts/ApexAreaChartCard.tsx new file mode 100644 index 0000000..9ace606 --- /dev/null +++ b/src/components/charts/ApexAreaChartCard.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import ReactApexChart from 'react-apexcharts'; +import type { ApexOptions } from 'apexcharts'; +import { CardTitle } from '../ui/Typography'; +import { englishToPersian, formatWithThousands } from '@/utils/numberUtils'; + +interface ApexAreaChartCardProps { + data: { name: string; value: number }[]; + title?: string; + color?: string; +} + +const formatNumber = (value: number | string) => { + const formatted = formatWithThousands(value); + return englishToPersian(formatted); +}; + +export const ApexAreaChartCard = ({ data, title, color = '#3b82f6' }: ApexAreaChartCardProps) => { + const categories = data.map((item) => item.name); + const series = [ + { + name: title || '', + data: data.map((item) => item.value), + }, + ]; + + const options: ApexOptions = { + chart: { + type: 'area', + height: 250, + toolbar: { show: false }, + zoom: { enabled: false }, + fontFamily: 'inherit', + }, + colors: [color], + dataLabels: { enabled: false }, + stroke: { + curve: 'smooth', + width: 3, + }, + fill: { + type: 'gradient', + gradient: { + shadeIntensity: 1, + opacityFrom: 0.45, + opacityTo: 0.05, + stops: [0, 90, 100], + }, + }, + grid: { + strokeDashArray: 4, + padding: { left: 12, right: 12 }, + }, + xaxis: { + categories, + labels: { + style: { fontSize: '11px' }, + formatter: (value) => englishToPersian(value), + }, + axisBorder: { show: true }, + axisTicks: { show: true }, + }, + yaxis: { + labels: { + style: { fontSize: '11px' }, + formatter: (value) => formatNumber(value), + minWidth: 70, + align: 'right', + offsetX: 0, + }, + }, + tooltip: { + y: { + formatter: (value) => formatNumber(value), + }, + x: { + formatter: (value) => englishToPersian(value), + }, + }, + }; + + return ( +
+ {title && ( + + {title} + + )} + +
+ ); +}; diff --git a/src/components/charts/ApexBarChartCard.tsx b/src/components/charts/ApexBarChartCard.tsx new file mode 100644 index 0000000..c58b208 --- /dev/null +++ b/src/components/charts/ApexBarChartCard.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import ReactApexChart from 'react-apexcharts'; +import type { ApexOptions } from 'apexcharts'; +import { CardTitle } from '../ui/Typography'; +import { englishToPersian, formatWithThousands } from '@/utils/numberUtils'; + +interface ApexBarChartCardProps { + data: { name: string; value: number }[]; + title?: string; + color?: string; +} + +const formatNumber = (value: number | string) => { + const formatted = formatWithThousands(value); + return englishToPersian(formatted); +}; + +export const ApexBarChartCard = ({ data, title, color = '#3b82f6' }: ApexBarChartCardProps) => { + const categories = data.map((item) => item.name); + const series = [ + { + name: title || '', + data: data.map((item) => item.value), + }, + ]; + + const options: ApexOptions = { + chart: { + type: 'bar', + height: 250, + toolbar: { show: false }, + fontFamily: 'inherit', + }, + colors: [color], + plotOptions: { + bar: { + borderRadius: 6, + columnWidth: '40%', + }, + }, + dataLabels: { enabled: false }, + grid: { + strokeDashArray: 4, + padding: { left: 12, right: 12 }, + }, + xaxis: { + categories, + labels: { + style: { fontSize: '11px' }, + formatter: (value) => englishToPersian(value), + }, + }, + yaxis: { + labels: { + style: { fontSize: '11px' }, + formatter: (value) => formatNumber(value), + minWidth: 70, + align: 'right', + offsetX: 0, + }, + }, + tooltip: { + y: { + formatter: (value) => formatNumber(value), + }, + x: { + formatter: (value) => englishToPersian(value), + }, + }, + }; + + return ( +
+ {title && ( + + {title} + + )} + +
+ ); +}; diff --git a/src/components/charts/AreaChartCard.tsx b/src/components/charts/AreaChartCard.tsx new file mode 100644 index 0000000..dec6224 --- /dev/null +++ b/src/components/charts/AreaChartCard.tsx @@ -0,0 +1,79 @@ +import { AreaChart as RechartsAreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { CardTitle } from '../ui/Typography'; +import { englishToPersian, formatWithThousands } from '@/utils/numberUtils'; + +interface AreaChartCardProps { + data: any[]; + title?: string; + color?: string; +} + +const formatNumber = (value: number | string) => { + const formatted = formatWithThousands(value); + return englishToPersian(formatted); +}; + +export const AreaChartCard = ({ data, title, color = '#3b82f6' }: AreaChartCardProps) => { + return ( +
+ {title && ( + + {title} + + )} +
+ + + + + + + + + + englishToPersian(value)} + interval="preserveStartEnd" + minTickGap={16} + height={30} + /> + formatNumber(value)} + width={72} + tickMargin={8} + tickCount={4} + allowDecimals={false} + /> + formatNumber(value)} + labelFormatter={(label: any) => englishToPersian(label)} + /> + + + +
+
+ ); +}; diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx index 798798c..334b7b9 100644 --- a/src/components/charts/BarChart.tsx +++ b/src/components/charts/BarChart.tsx @@ -1,5 +1,12 @@ import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { CardTitle } from '../ui/Typography'; +import { englishToPersian, formatWithThousands } from '@/utils/numberUtils'; + +const formatNumber = (value: number | string) => { + const formatted = formatWithThousands(value); + return englishToPersian(formatted); +}; + interface BarChartProps { data: any[]; @@ -17,21 +24,30 @@ export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => { )}
- - + + + + + + + + englishToPersian(value)} + interval="preserveStartEnd" + height={40} /> formatNumber(value)} + width={72} + tickMargin={8} + tickCount={4} + allowDecimals={false} /> { borderRadius: '8px', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', fontSize: '12px', + fontFamily: 'inherit', }} + formatter={(value: any) => formatNumber(value)} + labelFormatter={(label: any) => englishToPersian(label)} /> - +
diff --git a/src/components/charts/LineChart.tsx b/src/components/charts/LineChart.tsx index 2290ddd..5f9a513 100644 --- a/src/components/charts/LineChart.tsx +++ b/src/components/charts/LineChart.tsx @@ -1,5 +1,12 @@ import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { CardTitle } from '../ui/Typography'; +import { englishToPersian, formatWithThousands } from '@/utils/numberUtils'; + +const formatNumber = (value: number | string) => { + const formatted = formatWithThousands(value); + return englishToPersian(formatted); +}; + interface LineChartProps { data: any[]; @@ -17,21 +24,24 @@ export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) => )}
- - + + englishToPersian(value)} + interval="preserveStartEnd" + height={40} /> formatNumber(value)} + width={72} + tickMargin={8} + tickCount={4} + allowDecimals={false} /> borderRadius: '8px', boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', fontSize: '12px', + fontFamily: 'inherit', }} + formatter={(value: any) => formatNumber(value)} + labelFormatter={(label: any) => englishToPersian(label)} /> diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx index 2632122..290dcd2 100644 --- a/src/components/charts/PieChart.tsx +++ b/src/components/charts/PieChart.tsx @@ -1,5 +1,6 @@ import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts'; import { CardTitle } from '../ui/Typography'; +import { englishToPersian, formatWithThousands } from '@/utils/numberUtils'; interface PieChartProps { data: any[]; @@ -22,7 +23,7 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps style={{ backgroundColor: entry.color }} /> - {entry.value}: {Math.round(entry.payload.value)}% + {entry.value}: {englishToPersian(Math.round(entry.payload.value))}%
))} @@ -77,8 +78,9 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', fontSize: '14px', fontWeight: '500', + fontFamily: 'inherit', }} - formatter={(value: any, name: any) => [`${Math.round(value)}%`, name]} + formatter={(value: any, name: any) => [`${englishToPersian(Math.round(value))}%`, name]} /> diff --git a/src/constant/routes.ts b/src/constant/routes.ts index 792f6ab..5efc491 100644 --- a/src/constant/routes.ts +++ b/src/constant/routes.ts @@ -157,6 +157,9 @@ export const API_ROUTES = { PAYMENT_METHODS_REPORT: "reports/payments/methods", PAYMENT_TRANSACTIONS_REPORT: "reports/payments/transactions", SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method", + SALES_GROWTH_REPORT: "reports/sales/growth", + USER_REGISTRATION_GROWTH_REPORT: "reports/user-registration/growth", + SALES_BY_CATEGORY_REPORT: "reports/sales/by-category", // Product Comments APIs GET_PRODUCT_COMMENTS: "products/comments", diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 1719ae7..5c16e30 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,167 +1,107 @@ -import { Users, ShoppingBag, DollarSign, TrendingUp, BarChart3, Plus, Clock } from 'lucide-react'; -import { StatsCard } from '../components/dashboard/StatsCard'; -import { BarChart } from '../components/charts/BarChart'; -import { lazy, Suspense } from 'react'; -import { useOrderStats } from './orders/core/_hooks'; +import { ApexAreaChartCard } from '../components/charts/ApexAreaChartCard'; +import { ApexBarChartCard } from '../components/charts/ApexBarChartCard'; +import { useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSalesGrowthReport, useUserRegistrationGrowthReport, useSalesByCategoryReport } from './reports/sales-statistics/core/_hooks'; +import { useOrders } from './orders/core/_hooks'; +import { StatusBadge } from '../components/ui/StatusBadge'; +import { formatCurrency, formatDate } from '../utils/formatters'; -const LineChart = lazy(() => import('../components/charts/LineChart').then(module => ({ default: module.LineChart }))); import { PieChart } from '../components/charts/PieChart'; import { Table } from '../components/ui/Table'; import { Button } from '../components/ui/Button'; -import { PermissionWrapper } from '../components/common/PermissionWrapper'; import { PageContainer, PageTitle, CardTitle } from '../components/ui/Typography'; import { ChartData, TableColumn } from '../types'; -const useDashboardStats = () => { - const { data, isLoading, error } = useOrderStats(true); - const items = [ +export const Dashboard = () => { + const navigate = useNavigate(); + const { data: salesGrowthReport } = useSalesGrowthReport({ group_by: 'month' }); + const { data: registrationGrowthReport } = useUserRegistrationGrowthReport({ group_by: 'month' }); + const { data: salesByCategoryReport } = useSalesByCategoryReport(); + const recentOrdersFilters = useMemo(() => ({ + page: 1, + limit: 5, + status: 'pending' as const, + }), []); + const { data: recentOrders, isLoading: isOrdersLoading } = useOrders(recentOrdersFilters); + + const monthlySalesData: ChartData[] = useMemo(() => { + return (salesGrowthReport?.sales || []).map((item) => ({ + name: item.month_name || `${item.year}/${item.month}`, + value: item.total_sales, + })); + }, [salesGrowthReport]); + + const registrationGrowthData: ChartData[] = useMemo(() => { + return (registrationGrowthReport?.registrations || []).map((item) => ({ + name: item.month_name || `${item.year}/${item.month}`, + value: item.total_users, + })); + }, [registrationGrowthReport]); + + const salesByCategoryData: ChartData[] = useMemo(() => { + return (salesByCategoryReport?.categories || []).map((item) => ({ + name: item.category_name, + value: item.percentage, + })); + }, [salesByCategoryReport]); + + const orderColumns: TableColumn[] = [ { - title: 'کل سفارشات', - value: data?.total_orders_count ?? 0, - icon: ShoppingBag, - color: 'yellow' as const, + key: 'order_number', + label: 'شماره سفارش', + render: (value: string) => `#${value}`, }, { - title: 'مجموع فروش', - value: data?.total_amount_of_sale ?? 0, - icon: DollarSign, - color: 'green' as const, + key: 'customer', + label: 'مشتری', + render: (_value, row: any) => { + const customer = row.user || row.customer; + const name = `${customer?.first_name || ''} ${customer?.last_name || ''}`.trim(); + return name || 'نامشخص'; + }, }, { - title: 'سفارش‌های در انتظار', - value: data?.total_order_pending ?? 0, - icon: Clock, - color: 'blue' as const, + key: 'final_total', + label: 'مبلغ', + render: (_value, row: any) => formatCurrency(row.final_total || row.total_amount || 0), }, { - title: 'میانگین سفارش', - value: data?.order_avg ?? 0, - icon: TrendingUp, - color: 'purple' as const, + key: 'status', + label: 'وضعیت', + render: (value: any) => , + }, + { + key: 'created_at', + label: 'تاریخ', + render: (value: string) => formatDate(value), }, ]; - return { items, isLoading, error }; -}; -const chartData: ChartData[] = [ - { name: 'فروردین', value: 4000 }, - { name: 'اردیبهشت', value: 3000 }, - { name: 'خرداد', value: 5000 }, - { name: 'تیر', value: 4500 }, - { name: 'مرداد', value: 6000 }, - { name: 'شهریور', value: 5500 }, -]; + const ordersTableData = (recentOrders?.orders || []).map((item) => item.order ?? item); -const pieData: ChartData[] = [ - { name: 'دسکتاپ', value: 45 }, - { name: 'موبایل', value: 35 }, - { name: 'تبلت', value: 20 }, -]; - -const recentUsers = [ - { id: 1, name: 'علی احمدی', email: 'ali@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۵' }, - { id: 2, name: 'فاطمه حسینی', email: 'fateme@example.com', role: 'مدیر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۴' }, - { id: 3, name: 'محمد رضایی', email: 'mohammad@example.com', role: 'کاربر', status: 'غیرفعال', createdAt: '۱۴۰۲/۰۸/۱۳' }, - { id: 4, name: 'زهرا کریمی', email: 'zahra@example.com', role: 'کاربر', status: 'فعال', createdAt: '۱۴۰۲/۰۸/۱۲' }, -]; - -const userColumns: TableColumn[] = [ - { key: 'name', label: 'نام', sortable: true }, - { key: 'email', label: 'ایمیل' }, - { key: 'role', label: 'نقش' }, - { - key: 'status', - label: 'وضعیت', - render: (value) => ( - - {value} - - ) - }, - { key: 'createdAt', label: 'تاریخ عضویت' }, - { - key: 'actions', - label: 'عملیات', - render: () => ( -
- - - - -
- ) - } -]; - -export const Dashboard = () => { - const { items: statsData, isLoading: statsLoading, error: statsError } = useDashboardStats(); return ( {/* Header with mobile-responsive layout */}
داشبورد -
- - - - -
- {/* Stats Cards - Mobile responsive grid */} -
- {statsLoading ? ( - <> - {[...Array(4)].map((_, idx) => ( -
- ))} - - ) : ( - statsData.map((stat, index) => ( - - )) - )} -
- {statsError && ( -
- خطا در دریافت آمار سفارشات -
- )} - {/* Charts - Better mobile layout */}
-
- }> - - +
@@ -170,20 +110,22 @@ export const Dashboard = () => {
- کاربران اخیر + آخرین سفارشات در انتظار
- +
+ +
+
diff --git a/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx b/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx index c6db9f2..9f70d41 100644 --- a/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx +++ b/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx @@ -163,7 +163,7 @@ const DiscountCodeFormPage = () => { description: category.description || `دسته‌بندی #${category.id}` })); - const { register, handleSubmit, control, formState: { errors, isValid }, reset, watch } = useForm({ + const { register, handleSubmit, control, formState: { errors, isValid }, reset, watch, setValue } = useForm({ resolver: yupResolver(schema as any), mode: 'onChange', defaultValues: { status: 'active', type: 'percentage', application_level: 'invoice', single_use: false } @@ -171,6 +171,23 @@ const DiscountCodeFormPage = () => { const applicationLevel = watch('application_level'); + const handleApplicationLevelChange = (value: string) => { + setSelectedApplicationLevel(value); + setValue('application_level', value as any, { shouldDirty: true, shouldValidate: true }); + if (value !== 'product') { + setSelectedProductIds([]); + } + if (value !== 'category') { + setSelectedCategoryIds([]); + } + }; + + useEffect(() => { + if (applicationLevel && applicationLevel !== selectedApplicationLevel) { + handleApplicationLevelChange(applicationLevel); + } + }, [applicationLevel]); + useEffect(() => { if (isEdit && dc) { reset({ @@ -231,8 +248,8 @@ const DiscountCodeFormPage = () => { ...cleanRestrictions, user_ids: [selectedUserId], } : cleanRestrictions.user_group ? cleanRestrictions : undefined, - product_ids: selectedProductIds.length > 0 ? selectedProductIds : undefined, - category_ids: selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined, + product_ids: selectedApplicationLevel === 'product' && selectedProductIds.length > 0 ? selectedProductIds : undefined, + category_ids: selectedApplicationLevel === 'category' && selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined, }; if (isEdit && id) { @@ -472,7 +489,7 @@ const DiscountCodeFormPage = () => { name="application_level" value={option.value} checked={isSelected} - onChange={() => setSelectedApplicationLevel(option.value)} + onChange={() => handleApplicationLevelChange(option.value)} className="sr-only" /> diff --git a/src/pages/reports/sales-statistics/core/_hooks.ts b/src/pages/reports/sales-statistics/core/_hooks.ts new file mode 100644 index 0000000..8e8f472 --- /dev/null +++ b/src/pages/reports/sales-statistics/core/_hooks.ts @@ -0,0 +1,35 @@ +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/utils/query-key"; +import { + getSalesGrowthReport, + getUserRegistrationGrowthReport, + getSalesByCategoryReport, +} from "./_requests"; +import { + SalesGrowthFilters, + UserRegistrationGrowthFilters, + SalesByCategoryFilters, +} from "./_models"; + +export const useSalesGrowthReport = (filters?: SalesGrowthFilters) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_SALES_GROWTH_REPORT, filters], + queryFn: () => getSalesGrowthReport(filters), + }); +}; + +export const useUserRegistrationGrowthReport = ( + filters?: UserRegistrationGrowthFilters +) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_USER_REGISTRATION_GROWTH_REPORT, filters], + queryFn: () => getUserRegistrationGrowthReport(filters), + }); +}; + +export const useSalesByCategoryReport = (filters?: SalesByCategoryFilters) => { + return useQuery({ + queryKey: [QUERY_KEYS.GET_SALES_BY_CATEGORY_REPORT, filters], + queryFn: () => getSalesByCategoryReport(filters), + }); +}; diff --git a/src/pages/reports/sales-statistics/core/_models.ts b/src/pages/reports/sales-statistics/core/_models.ts new file mode 100644 index 0000000..1334fbc --- /dev/null +++ b/src/pages/reports/sales-statistics/core/_models.ts @@ -0,0 +1,89 @@ +export interface SalesGrowthFilters { + group_by?: "month" | "year"; + status?: string; + from?: string; // ISO 8601 + to?: string; // ISO 8601 +} + +export interface SalesGrowthEntry { + year: number; + month: number; + month_name: string; + total_sales: number; // تومان + total_orders: number; + avg_per_order: number; +} + +export interface SalesGrowthSummary { + total_sales: number; + total_orders: number; + average_per_month: number; + growth_rate: number; + peak_month: string; + peak_month_sales: number; +} + +export interface SalesGrowthResponse { + sales: SalesGrowthEntry[]; + summary: SalesGrowthSummary; +} + +export interface UserRegistrationGrowthFilters { + group_by?: "month" | "year"; + from?: string; // ISO 8601 + to?: string; // ISO 8601 +} + +export interface UserRegistrationEntry { + year: number; + month: number; + month_name: string; + total_users: number; + verified_users: number; + unverified_users: number; +} + +export interface UserRegistrationSummary { + total_users: number; + total_verified: number; + total_unverified: number; + average_per_month: number; + growth_rate: number; + peak_month: string; + peak_month_count: number; + verification_rate: number; +} + +export interface UserRegistrationGrowthResponse { + registrations: UserRegistrationEntry[]; + summary: UserRegistrationSummary; +} + +export interface SalesByCategoryFilters { + status?: string; + from?: string; // ISO 8601 + to?: string; // ISO 8601 +} + +export interface SalesByCategoryEntry { + category_id: number; + category_name: string; + total_sales: number; // تومان + total_orders: number; + total_products: number; + percentage: number; + average_per_order: number; +} + +export interface SalesByCategorySummary { + total_sales: number; + total_orders: number; + total_categories: number; + top_category: string; + top_category_perc: number; +} + +export interface SalesByCategoryResponse { + categories: SalesByCategoryEntry[]; + summary: SalesByCategorySummary; +} diff --git a/src/pages/reports/sales-statistics/core/_requests.ts b/src/pages/reports/sales-statistics/core/_requests.ts new file mode 100644 index 0000000..eae3ad0 --- /dev/null +++ b/src/pages/reports/sales-statistics/core/_requests.ts @@ -0,0 +1,56 @@ +import { httpGetRequest, APIUrlGenerator } from "@/utils/baseHttpService"; +import { API_ROUTES } from "@/constant/routes"; +import { + SalesGrowthFilters, + SalesGrowthResponse, + UserRegistrationGrowthFilters, + UserRegistrationGrowthResponse, + SalesByCategoryFilters, + SalesByCategoryResponse, +} from "./_models"; + +export const getSalesGrowthReport = async ( + filters?: SalesGrowthFilters +): Promise => { + const queryParams: Record = {}; + + if (filters?.group_by) queryParams.group_by = filters.group_by; + if (filters?.status) queryParams.status = filters.status; + if (filters?.from) queryParams.from = filters.from; + if (filters?.to) queryParams.to = filters.to; + + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.SALES_GROWTH_REPORT, queryParams) + ); + return response.data; +}; + +export const getUserRegistrationGrowthReport = async ( + filters?: UserRegistrationGrowthFilters +): Promise => { + const queryParams: Record = {}; + + if (filters?.group_by) queryParams.group_by = filters.group_by; + if (filters?.from) queryParams.from = filters.from; + if (filters?.to) queryParams.to = filters.to; + + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.USER_REGISTRATION_GROWTH_REPORT, queryParams) + ); + return response.data; +}; + +export const getSalesByCategoryReport = async ( + filters?: SalesByCategoryFilters +): Promise => { + const queryParams: Record = {}; + + if (filters?.status) queryParams.status = filters.status; + if (filters?.from) queryParams.from = filters.from; + if (filters?.to) queryParams.to = filters.to; + + const response = await httpGetRequest( + APIUrlGenerator(API_ROUTES.SALES_BY_CATEGORY_REPORT, queryParams) + ); + return response.data; +}; diff --git a/src/pages/tickets/ticket-config/TicketConfigPage.tsx b/src/pages/tickets/ticket-config/TicketConfigPage.tsx index e5f5c25..e429f4d 100644 --- a/src/pages/tickets/ticket-config/TicketConfigPage.tsx +++ b/src/pages/tickets/ticket-config/TicketConfigPage.tsx @@ -32,7 +32,7 @@ const TicketConfigPage = () => { const [activeTab, setActiveTab] = useState("departments"); const { data: departments } = useTicketDepartments({ activeOnly: true }); - const { data: statuses } = useTicketStatuses({ activeOnly: false }); + const { data: statuses } = useTicketStatuses(); const { data: subjects } = useTicketSubjects({ activeOnly: false }); const { mutate: createDepartment, isPending: isCreatingDepartment } = diff --git a/src/pages/tickets/ticket-detail/TicketDetailPage.tsx b/src/pages/tickets/ticket-detail/TicketDetailPage.tsx index 907e388..e8bf4d5 100644 --- a/src/pages/tickets/ticket-detail/TicketDetailPage.tsx +++ b/src/pages/tickets/ticket-detail/TicketDetailPage.tsx @@ -33,7 +33,7 @@ const TicketDetailPage = () => { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const { data: ticket, isLoading, error } = useTicket(id); - const { data: statuses } = useTicketStatuses({ activeOnly: false }); + const { data: statuses } = useTicketStatuses({ activeOnly: true }); const { mutate: sendReply, isPending: isReplying } = useReplyTicket(); const { mutate: updateStatus, isPending: isUpdatingStatus } = useUpdateTicketStatusMutation(); diff --git a/src/utils/query-key.ts b/src/utils/query-key.ts index 07c3e1b..d3017fa 100644 --- a/src/utils/query-key.ts +++ b/src/utils/query-key.ts @@ -139,6 +139,11 @@ export const QUERY_KEYS = { // Shipment Statistics GET_SHIPMENTS_BY_METHOD_REPORT: "get_shipments_by_method_report", + // Sales Statistics + GET_SALES_GROWTH_REPORT: "get_sales_growth_report", + GET_USER_REGISTRATION_GROWTH_REPORT: "get_user_registration_growth_report", + GET_SALES_BY_CATEGORY_REPORT: "get_sales_by_category_report", + // Product Comments GET_PRODUCT_COMMENTS: "get_product_comments", UPDATE_COMMENT_STATUS: "update_comment_status",