fix
This commit is contained in:
parent
5b62d189f8
commit
3690a8c1f6
|
|
@ -13,11 +13,13 @@
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
"@tanstack/react-query-devtools": "^5.80.6",
|
"@tanstack/react-query-devtools": "^5.80.6",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"apexcharts": "^5.3.6",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.263.1",
|
"lucide-react": "^0.263.1",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
|
"react-apexcharts": "^1.9.0",
|
||||||
"react-date-object": "2.1.9",
|
"react-date-object": "2.1.9",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hook-form": "^7.57.0",
|
"react-hook-form": "^7.57.0",
|
||||||
|
|
@ -1344,6 +1346,62 @@
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.80.6",
|
"version": "5.80.6",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.6.tgz",
|
"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"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
|
|
@ -1973,6 +2037,20 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/arch": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
|
||||||
|
|
@ -5787,6 +5865,19 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-date-object": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,13 @@
|
||||||
"@tanstack/react-query": "^5.80.6",
|
"@tanstack/react-query": "^5.80.6",
|
||||||
"@tanstack/react-query-devtools": "^5.80.6",
|
"@tanstack/react-query-devtools": "^5.80.6",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
"apexcharts": "^5.3.6",
|
||||||
"axios": "^1.9.0",
|
"axios": "^1.9.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.263.1",
|
"lucide-react": "^0.263.1",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
|
"react-apexcharts": "^1.9.0",
|
||||||
"react-date-object": "2.1.9",
|
"react-date-object": "2.1.9",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"react-hook-form": "^7.57.0",
|
"react-hook-form": "^7.57.0",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { BarChart as RechartsBarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { CardTitle } from '../ui/Typography';
|
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 {
|
interface BarChartProps {
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -17,21 +24,30 @@ export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
|
||||||
)}
|
)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
||||||
<RechartsBarChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
<RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
<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
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||||
interval={0}
|
tickFormatter={(value) => englishToPersian(value)}
|
||||||
angle={-45}
|
interval="preserveStartEnd"
|
||||||
textAnchor="end"
|
height={40}
|
||||||
height={60}
|
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||||
width={40}
|
tickFormatter={(value) => formatNumber(value)}
|
||||||
|
width={72}
|
||||||
|
tickMargin={8}
|
||||||
|
tickCount={4}
|
||||||
|
allowDecimals={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
|
|
@ -41,9 +57,12 @@ export const BarChart = ({ data, title, color = '#3b82f6' }: BarChartProps) => {
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
fontSize: '12px',
|
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>
|
</RechartsBarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart as RechartsLineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { CardTitle } from '../ui/Typography';
|
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 {
|
interface LineChartProps {
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -17,21 +24,24 @@ export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) =>
|
||||||
)}
|
)}
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
<ResponsiveContainer width="100%" height={250} minHeight={200}>
|
||||||
<RechartsLineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
<RechartsLineChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 10 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-300 dark:stroke-gray-600" />
|
<CartesianGrid strokeDasharray="4 4" className="stroke-gray-200 dark:stroke-gray-700" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="name"
|
dataKey="name"
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||||
interval={0}
|
tickFormatter={(value) => englishToPersian(value)}
|
||||||
angle={-45}
|
interval="preserveStartEnd"
|
||||||
textAnchor="end"
|
height={40}
|
||||||
height={60}
|
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
className="text-gray-600 dark:text-gray-400"
|
className="text-gray-600 dark:text-gray-400"
|
||||||
tick={{ fontSize: 10 }}
|
tick={{ fontSize: 11, fontFamily: 'inherit' }}
|
||||||
width={40}
|
tickFormatter={(value) => formatNumber(value)}
|
||||||
|
width={72}
|
||||||
|
tickMargin={8}
|
||||||
|
tickCount={4}
|
||||||
|
allowDecimals={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
|
|
@ -41,14 +51,17 @@ export const LineChart = ({ data, title, color = '#10b981' }: LineChartProps) =>
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
|
fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
|
formatter={(value: any) => formatNumber(value)}
|
||||||
|
labelFormatter={(label: any) => englishToPersian(label)}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
stroke={color}
|
stroke={color}
|
||||||
strokeWidth={2}
|
strokeWidth={3}
|
||||||
dot={{ r: 3, strokeWidth: 2 }}
|
dot={false}
|
||||||
activeDot={{ r: 5 }}
|
activeDot={{ r: 5 }}
|
||||||
/>
|
/>
|
||||||
</RechartsLineChart>
|
</RechartsLineChart>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
import { PieChart as RechartsPieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||||
import { CardTitle } from '../ui/Typography';
|
import { CardTitle } from '../ui/Typography';
|
||||||
|
import { englishToPersian, formatWithThousands } from '@/utils/numberUtils';
|
||||||
|
|
||||||
interface PieChartProps {
|
interface PieChartProps {
|
||||||
data: any[];
|
data: any[];
|
||||||
|
|
@ -22,7 +23,7 @@ export const PieChart = ({ data, title, colors = DEFAULT_COLORS }: PieChartProps
|
||||||
style={{ backgroundColor: entry.color }}
|
style={{ backgroundColor: entry.color }}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs sm:text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
<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>
|
</span>
|
||||||
</div>
|
</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)',
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
formatter={(value: any, name: any) => [`${Math.round(value)}%`, name]}
|
formatter={(value: any, name: any) => [`${englishToPersian(Math.round(value))}%`, name]}
|
||||||
/>
|
/>
|
||||||
</RechartsPieChart>
|
</RechartsPieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,9 @@ export const API_ROUTES = {
|
||||||
PAYMENT_METHODS_REPORT: "reports/payments/methods",
|
PAYMENT_METHODS_REPORT: "reports/payments/methods",
|
||||||
PAYMENT_TRANSACTIONS_REPORT: "reports/payments/transactions",
|
PAYMENT_TRANSACTIONS_REPORT: "reports/payments/transactions",
|
||||||
SHIPMENTS_BY_METHOD_REPORT: "reports/shipments/by-method",
|
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
|
// Product Comments APIs
|
||||||
GET_PRODUCT_COMMENTS: "products/comments",
|
GET_PRODUCT_COMMENTS: "products/comments",
|
||||||
|
|
|
||||||
|
|
@ -1,167 +1,107 @@
|
||||||
import { Users, ShoppingBag, DollarSign, TrendingUp, BarChart3, Plus, Clock } from 'lucide-react';
|
import { ApexAreaChartCard } from '../components/charts/ApexAreaChartCard';
|
||||||
import { StatsCard } from '../components/dashboard/StatsCard';
|
import { ApexBarChartCard } from '../components/charts/ApexBarChartCard';
|
||||||
import { BarChart } from '../components/charts/BarChart';
|
import { useMemo } from 'react';
|
||||||
import { lazy, Suspense } from 'react';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useOrderStats } from './orders/core/_hooks';
|
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 { PieChart } from '../components/charts/PieChart';
|
||||||
import { Table } from '../components/ui/Table';
|
import { Table } from '../components/ui/Table';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { PermissionWrapper } from '../components/common/PermissionWrapper';
|
|
||||||
import { PageContainer, PageTitle, CardTitle } from '../components/ui/Typography';
|
import { PageContainer, PageTitle, CardTitle } from '../components/ui/Typography';
|
||||||
import { ChartData, TableColumn } from '../types';
|
import { ChartData, TableColumn } from '../types';
|
||||||
|
|
||||||
const useDashboardStats = () => {
|
export const Dashboard = () => {
|
||||||
const { data, isLoading, error } = useOrderStats(true);
|
const navigate = useNavigate();
|
||||||
const items = [
|
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: 'کل سفارشات',
|
key: 'order_number',
|
||||||
value: data?.total_orders_count ?? 0,
|
label: 'شماره سفارش',
|
||||||
icon: ShoppingBag,
|
render: (value: string) => `#${value}`,
|
||||||
color: 'yellow' as const,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'مجموع فروش',
|
key: 'customer',
|
||||||
value: data?.total_amount_of_sale ?? 0,
|
label: 'مشتری',
|
||||||
icon: DollarSign,
|
render: (_value, row: any) => {
|
||||||
color: 'green' as const,
|
const customer = row.user || row.customer;
|
||||||
|
const name = `${customer?.first_name || ''} ${customer?.last_name || ''}`.trim();
|
||||||
|
return name || 'نامشخص';
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'سفارشهای در انتظار',
|
key: 'final_total',
|
||||||
value: data?.total_order_pending ?? 0,
|
label: 'مبلغ',
|
||||||
icon: Clock,
|
render: (_value, row: any) => formatCurrency(row.final_total || row.total_amount || 0),
|
||||||
color: 'blue' as const,
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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',
|
key: 'status',
|
||||||
label: 'وضعیت',
|
label: 'وضعیت',
|
||||||
render: (value) => (
|
render: (value: any) => <StatusBadge status={value} type="order" />,
|
||||||
<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>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{ key: 'createdAt', label: 'تاریخ عضویت' },
|
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'created_at',
|
||||||
label: 'عملیات',
|
label: 'تاریخ',
|
||||||
render: () => (
|
render: (value: string) => formatDate(value),
|
||||||
<div className="flex space-x-2">
|
},
|
||||||
<Button size="sm" variant="secondary">
|
];
|
||||||
ویرایش
|
|
||||||
</Button>
|
const ordersTableData = (recentOrders?.orders || []).map((item) => item.order ?? item);
|
||||||
<PermissionWrapper permission={22}>
|
|
||||||
<Button size="sm" variant="danger">
|
|
||||||
حذف
|
|
||||||
</Button>
|
|
||||||
</PermissionWrapper>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Dashboard = () => {
|
|
||||||
const { items: statsData, isLoading: statsLoading, error: statsError } = useDashboardStats();
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
{/* Header with mobile-responsive layout */}
|
{/* 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">
|
<div className="flex flex-col space-y-3 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||||
<PageTitle>داشبورد</PageTitle>
|
<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>
|
||||||
</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 */}
|
{/* Charts - Better mobile layout */}
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 sm:gap-6">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<BarChart
|
<ApexBarChartCard
|
||||||
data={chartData}
|
data={monthlySalesData}
|
||||||
title="فروش ماهانه"
|
title="فروش ماهانه"
|
||||||
color="#3b82f6"
|
color="#3b82f6"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<Suspense fallback={<div className="card p-6 animate-pulse bg-gray-100 dark:bg-gray-800 h-64" />}>
|
<ApexAreaChartCard
|
||||||
<LineChart
|
data={registrationGrowthData}
|
||||||
data={chartData}
|
title="روند رشد ثبتنام کاربران"
|
||||||
title="روند رشد"
|
|
||||||
color="#10b981"
|
color="#10b981"
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -170,20 +110,22 @@ export const Dashboard = () => {
|
||||||
<div className="xl:col-span-2 min-w-0">
|
<div className="xl:col-span-2 min-w-0">
|
||||||
<div className="card p-3 sm:p-4 lg:p-6">
|
<div className="card p-3 sm:p-4 lg:p-6">
|
||||||
<CardTitle className="mb-3 sm:mb-4">
|
<CardTitle className="mb-3 sm:mb-4">
|
||||||
کاربران اخیر
|
آخرین سفارشات در انتظار
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table
|
<Table columns={orderColumns} data={ordersTableData} loading={isOrdersLoading} />
|
||||||
columns={userColumns}
|
</div>
|
||||||
data={recentUsers}
|
<div className="mt-4 flex justify-end">
|
||||||
/>
|
<Button variant="secondary" onClick={() => navigate('/orders')}>
|
||||||
|
مشاهده همه
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<PieChart
|
<PieChart
|
||||||
data={pieData}
|
data={salesByCategoryData}
|
||||||
title="دستگاههای کاربری"
|
title="توزیع فروش بر اساس دستهبندی"
|
||||||
colors={['#3b82f6', '#10b981', '#f59e0b']}
|
colors={['#3b82f6', '#10b981', '#f59e0b']}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,7 @@ const DiscountCodeFormPage = () => {
|
||||||
description: category.description || `دستهبندی #${category.id}`
|
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),
|
resolver: yupResolver(schema as any),
|
||||||
mode: 'onChange',
|
mode: 'onChange',
|
||||||
defaultValues: { status: 'active', type: 'percentage', application_level: 'invoice', single_use: false }
|
defaultValues: { status: 'active', type: 'percentage', application_level: 'invoice', single_use: false }
|
||||||
|
|
@ -171,6 +171,23 @@ const DiscountCodeFormPage = () => {
|
||||||
|
|
||||||
const applicationLevel = watch('application_level');
|
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(() => {
|
useEffect(() => {
|
||||||
if (isEdit && dc) {
|
if (isEdit && dc) {
|
||||||
reset({
|
reset({
|
||||||
|
|
@ -231,8 +248,8 @@ const DiscountCodeFormPage = () => {
|
||||||
...cleanRestrictions,
|
...cleanRestrictions,
|
||||||
user_ids: [selectedUserId],
|
user_ids: [selectedUserId],
|
||||||
} : cleanRestrictions.user_group ? cleanRestrictions : undefined,
|
} : cleanRestrictions.user_group ? cleanRestrictions : undefined,
|
||||||
product_ids: selectedProductIds.length > 0 ? selectedProductIds : undefined,
|
product_ids: selectedApplicationLevel === 'product' && selectedProductIds.length > 0 ? selectedProductIds : undefined,
|
||||||
category_ids: selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
|
category_ids: selectedApplicationLevel === 'category' && selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit && id) {
|
if (isEdit && id) {
|
||||||
|
|
@ -472,7 +489,7 @@ const DiscountCodeFormPage = () => {
|
||||||
name="application_level"
|
name="application_level"
|
||||||
value={option.value}
|
value={option.value}
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={() => setSelectedApplicationLevel(option.value)}
|
onChange={() => handleApplicationLevelChange(option.value)}
|
||||||
className="sr-only"
|
className="sr-only"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -32,7 +32,7 @@ const TicketConfigPage = () => {
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>("departments");
|
const [activeTab, setActiveTab] = useState<TabKey>("departments");
|
||||||
|
|
||||||
const { data: departments } = useTicketDepartments({ activeOnly: true });
|
const { data: departments } = useTicketDepartments({ activeOnly: true });
|
||||||
const { data: statuses } = useTicketStatuses({ activeOnly: false });
|
const { data: statuses } = useTicketStatuses();
|
||||||
const { data: subjects } = useTicketSubjects({ activeOnly: false });
|
const { data: subjects } = useTicketSubjects({ activeOnly: false });
|
||||||
|
|
||||||
const { mutate: createDepartment, isPending: isCreatingDepartment } =
|
const { mutate: createDepartment, isPending: isCreatingDepartment } =
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const TicketDetailPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { data: ticket, isLoading, error } = useTicket(id);
|
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: sendReply, isPending: isReplying } = useReplyTicket();
|
||||||
const { mutate: updateStatus, isPending: isUpdatingStatus } =
|
const { mutate: updateStatus, isPending: isUpdatingStatus } =
|
||||||
useUpdateTicketStatusMutation();
|
useUpdateTicketStatusMutation();
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,11 @@ export const QUERY_KEYS = {
|
||||||
// Shipment Statistics
|
// Shipment Statistics
|
||||||
GET_SHIPMENTS_BY_METHOD_REPORT: "get_shipments_by_method_report",
|
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
|
// Product Comments
|
||||||
GET_PRODUCT_COMMENTS: "get_product_comments",
|
GET_PRODUCT_COMMENTS: "get_product_comments",
|
||||||
UPDATE_COMMENT_STATUS: "update_comment_status",
|
UPDATE_COMMENT_STATUS: "update_comment_status",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue