413 lines
17 KiB
TypeScript
413 lines
17 KiB
TypeScript
import { ArrowUp, DocumentDownload, ArrowDown } from 'iconsax-react'
|
||
import { type FC, useState, useMemo } from 'react'
|
||
import {
|
||
Area,
|
||
AreaChart,
|
||
CartesianGrid,
|
||
ResponsiveContainer,
|
||
Tooltip,
|
||
XAxis,
|
||
YAxis
|
||
} from 'recharts'
|
||
import DatePicker from '@/components/DatePicker'
|
||
import Button from '@/components/Button'
|
||
import { useGetPaymentsChart } from '../hooks/useStatsData'
|
||
import { formatPrice } from '@/helpers/func'
|
||
import PageLoading from '@/components/PageLoading'
|
||
import moment from 'moment-jalaali'
|
||
|
||
const RevenueChart: FC = () => {
|
||
const [selectedTab] = useState<
|
||
'daily' | 'monthly' | 'yearly'
|
||
>('monthly')
|
||
const [startDate, setStartDate] = useState<string>('')
|
||
const [endDate, setEndDate] = useState<string>('')
|
||
|
||
const convertToPersian = (gregorianDate: string | null | undefined): string | undefined => {
|
||
if (!gregorianDate) return undefined
|
||
const persianDate = moment(gregorianDate, 'YYYY-MM-DD').format('jYYYY-jMM-jDD')
|
||
return persianDate
|
||
}
|
||
|
||
const apiParams = useMemo(() => {
|
||
const params: {
|
||
period?: 'daily' | 'monthly' | 'yearly';
|
||
startDate?: string;
|
||
endDate?: string;
|
||
} = {}
|
||
// فقط اگر تاریخ انتخاب شده باشد ارسال شود
|
||
if (startDate && startDate.trim() !== '') params.startDate = startDate
|
||
if (endDate && endDate.trim() !== '') params.endDate = endDate
|
||
return params
|
||
}, [startDate, endDate])
|
||
|
||
const { data: paymentsChartData, isLoading } = useGetPaymentsChart(apiParams)
|
||
|
||
const formatDateLabel = (dateString: string, period: 'daily' | 'monthly' | 'yearly'): string => {
|
||
const date = new Date(dateString)
|
||
const options: Intl.DateTimeFormatOptions = {
|
||
year: 'numeric',
|
||
}
|
||
if (period === 'monthly') {
|
||
options.month = 'long'
|
||
} else if (period === 'daily') {
|
||
options.month = 'short'
|
||
options.day = 'numeric'
|
||
}
|
||
const persianDate = new Intl.DateTimeFormat('fa-IR', options).format(date)
|
||
return persianDate
|
||
}
|
||
|
||
const chartData = useMemo(() => {
|
||
if (!paymentsChartData?.data) return []
|
||
return paymentsChartData.data.map((item) => ({
|
||
month: formatDateLabel(item.date, selectedTab),
|
||
online: item.online,
|
||
cash: item.cash,
|
||
}))
|
||
}, [paymentsChartData?.data, selectedTab])
|
||
|
||
const xAxisInterval = useMemo(() => {
|
||
const dataLength = chartData.length
|
||
if (dataLength <= 7) return 0
|
||
if (dataLength <= 15) return 1
|
||
if (dataLength <= 30) return 2
|
||
if (dataLength <= 60) return 4
|
||
return Math.floor(dataLength / 12)
|
||
}, [chartData.length])
|
||
|
||
const totalRevenue = useMemo(() => {
|
||
return chartData.reduce((sum, item) => sum + item.online + item.cash, 0)
|
||
}, [chartData])
|
||
|
||
const revenueChange = useMemo(() => {
|
||
if (chartData.length < 2) return null
|
||
const currentPeriod = chartData[chartData.length - 1]
|
||
const previousPeriod = chartData[chartData.length - 2]
|
||
const currentTotal = currentPeriod.online + currentPeriod.cash
|
||
const previousTotal = previousPeriod.online + previousPeriod.cash
|
||
if (previousTotal === 0) return null
|
||
const change = ((currentTotal - previousTotal) / previousTotal) * 100
|
||
return change
|
||
}, [chartData])
|
||
|
||
const formatYAxis = (value: number) => {
|
||
if (value === 0) return '۰'
|
||
if (value >= 1000000) {
|
||
const millions = value / 1000000
|
||
const formatted = millions
|
||
.toFixed(1)
|
||
.replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[parseInt(d)])
|
||
return `${formatted} میلیون`
|
||
}
|
||
return value.toLocaleString('fa-IR')
|
||
}
|
||
|
||
const CustomTooltip = (props: {
|
||
active?: boolean
|
||
payload?: Array<{
|
||
dataKey?: string
|
||
value?: number
|
||
color?: string
|
||
payload?: {
|
||
cash?: number
|
||
online?: number
|
||
month?: string
|
||
}
|
||
}>
|
||
label?: string
|
||
}) => {
|
||
const { active, payload, label } = props
|
||
if (!active || !payload || payload.length === 0 || !label) {
|
||
return null
|
||
}
|
||
|
||
// استفاده از paymentsChartData.data مستقیم برای دریافت دادههای واقعی
|
||
// پیدا کردن آخرین آیتمی که label آن با formatDateLabel match میکند
|
||
let dataPoint: { cash: number; online: number } | null = null
|
||
|
||
if (paymentsChartData?.data) {
|
||
// پیدا کردن همه آیتمهای match شده و استفاده از آخرین
|
||
const matchingItems: Array<{ cash: number; online: number }> = []
|
||
for (const item of paymentsChartData.data) {
|
||
const formattedLabel = formatDateLabel(item.date, selectedTab)
|
||
if (formattedLabel === label) {
|
||
matchingItems.push({ cash: item.cash, online: item.online })
|
||
}
|
||
}
|
||
if (matchingItems.length > 0) {
|
||
dataPoint = matchingItems[matchingItems.length - 1]
|
||
}
|
||
}
|
||
|
||
// اگر پیدا نکردیم، از chartData استفاده میکنیم
|
||
if (!dataPoint) {
|
||
const matchingItems = chartData.filter((item) => item.month === label)
|
||
if (matchingItems.length > 0) {
|
||
dataPoint = matchingItems[matchingItems.length - 1]
|
||
}
|
||
}
|
||
|
||
if (!dataPoint) {
|
||
return null
|
||
}
|
||
|
||
const cashValue = dataPoint.cash
|
||
const onlineValue = dataPoint.online
|
||
|
||
return (
|
||
<div className='bg-white p-3 rounded-xl shadow-lg border border-gray-200'>
|
||
<p className='text-sm font-medium text-gray-900 mb-2'>{label}</p>
|
||
<div className='space-y-1'>
|
||
<div className='flex items-center gap-2'>
|
||
<div className='w-2 h-2 rounded-full bg-[#B0BAC9]' />
|
||
<span className='text-xs text-gray-600'>پرداخت نقدی:</span>
|
||
<span className='text-xs font-medium text-gray-900'>
|
||
{cashValue.toLocaleString('fa-IR')} تومان
|
||
</span>
|
||
</div>
|
||
<div className='flex items-center gap-2'>
|
||
<div className='w-2 h-2 rounded-full bg-[#6B7FED]' />
|
||
<span className='text-xs text-gray-600'>پرداخت آنلاین:</span>
|
||
<span className='text-xs font-medium text-gray-900'>
|
||
{onlineValue.toLocaleString('fa-IR')} تومان
|
||
</span>
|
||
</div>
|
||
<div className='pt-1 mt-1 border-t border-gray-100'>
|
||
<span className='text-xs text-gray-600'>جمع:</span>
|
||
<span className='text-xs font-medium text-gray-900 mr-2'>
|
||
{(cashValue + onlineValue).toLocaleString('fa-IR')} تومان
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className='bg-white rounded-3xl p-6 shadow-sm h-full overflow-visible'>
|
||
{/* Header */}
|
||
<div className='flex items-start justify-between my-5'>
|
||
<h3 className='text-base font-bold'>مجموع درآمد</h3>
|
||
|
||
|
||
</div>
|
||
|
||
<div className='flex items-end gap-2'>
|
||
<DatePicker
|
||
label='تاریخ شروع'
|
||
placeholder='انتخاب تاریخ شروع'
|
||
defaulValue={convertToPersian(startDate)}
|
||
onChange={setStartDate}
|
||
/>
|
||
<DatePicker
|
||
label='تاریخ پایان'
|
||
placeholder='انتخاب تاریخ پایان'
|
||
defaulValue={convertToPersian(endDate)}
|
||
onChange={setEndDate}
|
||
/>
|
||
<Button className=''>
|
||
<div className='flex gap-3'>
|
||
<DocumentDownload
|
||
size={18}
|
||
color='#fff'
|
||
/>
|
||
<span>گرفتن گزارش</span>
|
||
</div>
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
{/* <div className='flex items-center gap-2 mt-5 mb-5'>
|
||
<button
|
||
onClick={() => setSelectedTab('yearly')}
|
||
className={`px-4 py-1.5 rounded-full text-xs transition-all border ${selectedTab === 'yearly'
|
||
? 'bg-black text-white border-black'
|
||
: 'bg-white text-gray-600 border-gray-200'
|
||
}`}
|
||
>
|
||
سالانه
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedTab('monthly')}
|
||
className={`px-4 py-1.5 rounded-full text-xs transition-all border ${selectedTab === 'monthly'
|
||
? 'bg-black text-white border-black'
|
||
: 'bg-white text-gray-600 border-gray-200'
|
||
}`}
|
||
>
|
||
ماهانه
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedTab('daily')}
|
||
className={`px-4 py-1.5 rounded-full text-xs transition-all border ${selectedTab === 'daily'
|
||
? 'bg-black text-white border-black'
|
||
: 'bg-white text-gray-600 border-gray-200'
|
||
}`}
|
||
>
|
||
روزانه
|
||
</button>
|
||
</div> */}
|
||
|
||
{/* مبلغ و درصد */}
|
||
|
||
<div>
|
||
<span className='text-sm text-gray-500'>مجموع درآمد</span>
|
||
</div>
|
||
<div className='flex items-center justify-between mb-5'>
|
||
<div className='flex items-center gap-3'>
|
||
<p className='text-2xl font-bold'>
|
||
{formatPrice(totalRevenue)}
|
||
</p>
|
||
{revenueChange !== null && (
|
||
<div
|
||
className={`flex items-center gap-1 px-2 py-0.5 rounded-full ${revenueChange >= 0
|
||
? 'bg-green-50'
|
||
: 'bg-red-50'
|
||
}`}
|
||
>
|
||
{revenueChange >= 0 ? (
|
||
<ArrowUp
|
||
size={12}
|
||
color='#10b981'
|
||
/>
|
||
) : (
|
||
<ArrowDown
|
||
size={12}
|
||
color='#ef4444'
|
||
/>
|
||
)}
|
||
<span
|
||
className={`text-xs font-medium ${revenueChange >= 0
|
||
? 'text-green-600'
|
||
: 'text-red-600'
|
||
}`}
|
||
>
|
||
{Math.abs(revenueChange).toFixed(1)}%
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className='flex items-center gap-3'>
|
||
<div className='flex items-center gap-2'>
|
||
<div className='w-2 h-2 rounded-full bg-[#6B7FED]' />
|
||
<span className='text-xs text-gray-500'>
|
||
پرداخت آنلاین
|
||
</span>
|
||
</div>
|
||
<div className='flex items-center gap-2'>
|
||
<div className='w-2 h-2 rounded-full bg-[#B0BAC9]' />
|
||
<span className='text-xs text-gray-500'>
|
||
پرداخت نقدی
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* نمودار */}
|
||
{isLoading ? (
|
||
<div className='w-full h-[400px] min-h-[400px] flex items-center justify-center'>
|
||
<PageLoading />
|
||
</div>
|
||
) : chartData.length === 0 ? (
|
||
<div className='w-full h-[400px] min-h-[400px] flex items-center justify-center text-gray-500'>
|
||
دادهای برای نمایش وجود ندارد
|
||
</div>
|
||
) : (
|
||
<div className='w-full h-[400px] min-h-[400px]'>
|
||
<ResponsiveContainer width='100%' height='100%'>
|
||
<AreaChart
|
||
data={chartData}
|
||
margin={{ top: 10, right: 10, left: 10, bottom: 40 }}
|
||
>
|
||
<defs>
|
||
<linearGradient
|
||
id='colorOnline'
|
||
x1='0'
|
||
y1='0'
|
||
x2='0'
|
||
y2='1'
|
||
>
|
||
<stop
|
||
offset='5%'
|
||
stopColor='#6B7FED'
|
||
stopOpacity={0.3}
|
||
/>
|
||
<stop
|
||
offset='95%'
|
||
stopColor='#6B7FED'
|
||
stopOpacity={0}
|
||
/>
|
||
</linearGradient>
|
||
<linearGradient
|
||
id='colorCash'
|
||
x1='0'
|
||
y1='0'
|
||
x2='0'
|
||
y2='1'
|
||
>
|
||
<stop
|
||
offset='5%'
|
||
stopColor='#B0BAC9'
|
||
stopOpacity={0.2}
|
||
/>
|
||
<stop
|
||
offset='95%'
|
||
stopColor='#B0BAC9'
|
||
stopOpacity={0}
|
||
/>
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid
|
||
strokeDasharray='0'
|
||
stroke='#f0f0f0'
|
||
vertical={false}
|
||
/>
|
||
<XAxis
|
||
dataKey='month'
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fill: '#9CA3AF', fontSize: 11 }}
|
||
reversed
|
||
interval={xAxisInterval}
|
||
angle={chartData.length > 15 ? -45 : 0}
|
||
textAnchor={chartData.length > 15 ? 'end' : 'middle'}
|
||
height={chartData.length > 15 ? 60 : 40}
|
||
minTickGap={10}
|
||
/>
|
||
<YAxis
|
||
orientation='right'
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tick={{ fill: '#9CA3AF', fontSize: 10 }}
|
||
tickFormatter={formatYAxis}
|
||
domain={[0, 'dataMax + 2000000']}
|
||
tickMargin={50}
|
||
/>
|
||
<Tooltip content={<CustomTooltip />} />
|
||
<Area
|
||
type='natural'
|
||
dataKey='cash'
|
||
stroke='#B0BAC9'
|
||
strokeWidth={2}
|
||
fillOpacity={1}
|
||
fill='url(#colorCash)'
|
||
/>
|
||
<Area
|
||
type='natural'
|
||
dataKey='online'
|
||
stroke='#6B7FED'
|
||
strokeWidth={2}
|
||
fillOpacity={1}
|
||
fill='url(#colorOnline)'
|
||
/>
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default RevenueChart
|