This commit is contained in:
hosseintaromi 2026-01-24 14:51:27 +03:30
parent 5b62d189f8
commit 3690a8c1f6
17 changed files with 694 additions and 167 deletions

91
package-lock.json generated
View File

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

View File

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

View File

@ -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 (
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<CardTitle className="mb-3 sm:mb-4">
{title}
</CardTitle>
)}
<ReactApexChart options={options} series={series} type="area" height={250} />
</div>
);
};

View File

@ -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 (
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<CardTitle className="mb-3 sm:mb-4">
{title}
</CardTitle>
)}
<ReactApexChart options={options} series={series} type="bar" height={250} />
</div>
);
};

View File

@ -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 (
<div className="card p-3 sm:p-4 lg:p-6">
{title && (
<CardTitle className="mb-3 sm:mb-4">
{title}
</CardTitle>
)}
<div className="w-full">
<ResponsiveContainer width="100%" height={250} minHeight={200}>
<RechartsAreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<defs>
<linearGradient id={`areaFill-${color}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.5} />
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis
dataKey="name"
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => englishToPersian(value)}
interval="preserveStartEnd"
minTickGap={16}
height={30}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => formatNumber(value)}
width={72}
tickMargin={8}
tickCount={4}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'var(--toast-bg)',
color: 'var(--toast-color)',
border: 'none',
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)}
/>
<Area
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={3}
fill={`url(#areaFill-${color})`}
dot={false}
activeDot={{ r: 4 }}
/>
</RechartsAreaChart>
</ResponsiveContainer>
</div>
</div>
);
};

View File

@ -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) => {
)}
<div className="w-full">
<ResponsiveContainer width="100%" height={250} minHeight={200}>
<RechartsBarChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
<RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<defs>
<linearGradient id="barFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.9} />
<stop offset="95%" stopColor={color} stopOpacity={0.4} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis
dataKey="name"
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 10 }}
interval={0}
angle={-45}
textAnchor="end"
height={60}
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => englishToPersian(value)}
interval="preserveStartEnd"
height={40}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 10 }}
width={40}
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => formatNumber(value)}
width={72}
tickMargin={8}
tickCount={4}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
@ -41,9 +57,12 @@ export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
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)}
/>
<Bar dataKey="value" fill={color} radius={[2, 2, 0, 0]} />
<Bar dataKey="value" fill="url(#barFill)" radius={[8, 8, 0, 0]} barSize={28} />
</RechartsBarChart>
</ResponsiveContainer>
</div>

View File

@ -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) =>
)}
<div className="w-full">
<ResponsiveContainer width="100%" height={250} minHeight={200}>
<RechartsLineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
<XAxis
dataKey="name"
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 10 }}
interval={0}
angle={-45}
textAnchor="end"
height={60}
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => englishToPersian(value)}
interval="preserveStartEnd"
height={40}
/>
<YAxis
className="text-gray-600 dark:text-gray-400"
tick={{ fontSize: 10 }}
width={40}
tick={{ fontSize: 11, fontFamily: 'inherit' }}
tickFormatter={(value) => formatNumber(value)}
width={72}
tickMargin={8}
tickCount={4}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
@ -41,14 +51,17 @@ export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) =>
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)}
/>
<Line
type="monotone"
dataKey="value"
stroke={color}
strokeWidth={2}
dot={{ r: 3, strokeWidth: 2 }}
strokeWidth={3}
dot={false}
activeDot={{ r: 5 }}
/>
</RechartsLineChart>

View File

@ -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 }}
/>
<span className="text-xs sm:text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
<span className="font-medium">{entry.value}</span>: <span className="font-bold">{Math.round(entry.payload.value)}%</span>
<span className="font-medium">{entry.value}</span>: <span className="font-bold">{englishToPersian(Math.round(entry.payload.value))}%</span>
</span>
</div>
))}
@ -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]}
/>
</RechartsPieChart>
</ResponsiveContainer>

View File

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

View File

@ -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,
},
];
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 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) => (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${value === 'فعال'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}>
{value}
</span>
)
render: (value: any) => <StatusBadge status={value} type="order" />,
},
{ key: 'createdAt', label: 'تاریخ عضویت' },
{
key: 'actions',
label: 'عملیات',
render: () => (
<div className="flex space-x-2">
<Button size="sm" variant="secondary">
ویرایش
</Button>
<PermissionWrapper permission={22}>
<Button size="sm" variant="danger">
حذف
</Button>
</PermissionWrapper>
</div>
)
}
];
key: 'created_at',
label: 'تاریخ',
render: (value: string) => formatDate(value),
},
];
const ordersTableData = (recentOrders?.orders || []).map((item) => item.order ?? item);
export const Dashboard = () => {
const { items: statsData, isLoading: statsLoading, error: statsError } = useDashboardStats();
return (
<PageContainer>
{/* Header with mobile-responsive layout */}
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
<PageTitle>داشبورد</PageTitle>
<div className="flex justify-start gap-3">
<button
className="flex items-center justify-center w-12 h-12 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full transition-colors duration-200 text-gray-600 dark:text-gray-300"
title="گزارش‌گیری"
>
<BarChart3 className="h-5 w-5" />
</button>
<PermissionWrapper permission={25}>
<button
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>
</PermissionWrapper>
</div>
</div>
{/* Stats Cards - Mobile responsive grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4 lg:gap-6">
{statsLoading ? (
<>
{[...Array(4)].map((_, idx) => (
<div key={idx} className="card p-6 animate-pulse bg-gray-100 dark:bg-gray-800 h-24" />
))}
</>
) : (
statsData.map((stat, index) => (
<StatsCard key={index} {...stat} />
))
)}
</div>
{statsError && (
<div className="mt-2 text-sm text-red-600 dark:text-red-400">
خطا در دریافت آمار سفارشات
</div>
)}
{/* Charts - Better mobile layout */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
<div className="min-w-0">
<BarChart
data={chartData}
<ApexBarChartCard
data={monthlySalesData}
title="فروش ماهانه"
color="#3b82f6"
/>
</div>
<div className="min-w-0">
<Suspense fallback={<div className="card p-6 animate-pulse bg-gray-100 dark:bg-gray-800 h-64" />}>
<LineChart
data={chartData}
title="روند رشد"
<ApexAreaChartCard
data={registrationGrowthData}
title="روند رشد ثبت‌نام کاربران"
color="#10b981"
/>
</Suspense>
</div>
</div>
@ -170,20 +110,22 @@ export const Dashboard = () => {
<div className="xl:col-span-2 min-w-0">
<div className="card p-3 sm:p-4 lg:p-6">
<CardTitle className="mb-3 sm:mb-4">
کاربران اخیر
آخرین سفارشات در انتظار
</CardTitle>
<div className="overflow-x-auto">
<Table
columns={userColumns}
data={recentUsers}
/>
<Table columns={orderColumns} data={ordersTableData} loading={isOrdersLoading} />
</div>
<div className="mt-4 flex justify-end">
<Button variant="secondary" onClick={() => navigate('/orders')}>
مشاهده همه
</Button>
</div>
</div>
</div>
<div className="min-w-0">
<PieChart
data={pieData}
title="دستگاه‌های کاربری"
data={salesByCategoryData}
title="توزیع فروش بر اساس دسته‌بندی"
colors={['#3b82f6', '#10b981', '#f59e0b']}
/>
</div>

View File

@ -163,7 +163,7 @@ const DiscountCodeFormPage = () => {
description: category.description || `دسته‌بندی #${category.id}`
}));
const { register, handleSubmit, control, formState: { errors, isValid }, reset, watch } = useForm<CreateDiscountCodeRequest>({
const { register, handleSubmit, control, formState: { errors, isValid }, reset, watch, setValue } = useForm<CreateDiscountCodeRequest>({
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"
/>

View File

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

View File

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

View File

@ -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<SalesGrowthResponse> => {
const queryParams: Record<string, string | number | null> = {};
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<SalesGrowthResponse>(
APIUrlGenerator(API_ROUTES.SALES_GROWTH_REPORT, queryParams)
);
return response.data;
};
export const getUserRegistrationGrowthReport = async (
filters?: UserRegistrationGrowthFilters
): Promise<UserRegistrationGrowthResponse> => {
const queryParams: Record<string, string | number | null> = {};
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<UserRegistrationGrowthResponse>(
APIUrlGenerator(API_ROUTES.USER_REGISTRATION_GROWTH_REPORT, queryParams)
);
return response.data;
};
export const getSalesByCategoryReport = async (
filters?: SalesByCategoryFilters
): Promise<SalesByCategoryResponse> => {
const queryParams: Record<string, string | number | null> = {};
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<SalesByCategoryResponse>(
APIUrlGenerator(API_ROUTES.SALES_BY_CATEGORY_REPORT, queryParams)
);
return response.data;
};

View File

@ -32,7 +32,7 @@ const TicketConfigPage = () => {
const [activeTab, setActiveTab] = useState<TabKey>("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 } =

View File

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

View File

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