327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
/**
|
||
* Клиентский компонент графика доходов (без SSR)
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import React from 'react';
|
||
import {
|
||
LineChart,
|
||
Line,
|
||
XAxis,
|
||
YAxis,
|
||
CartesianGrid,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
Area,
|
||
AreaChart,
|
||
Legend
|
||
} from 'recharts';
|
||
import { IncomeChartData } from '@/api/dashboard';
|
||
|
||
interface RevenueChartClientProps {
|
||
data: IncomeChartData[];
|
||
loading?: boolean;
|
||
period?: 'day' | 'week' | 'month';
|
||
}
|
||
|
||
export const RevenueChartClient: React.FC<RevenueChartClientProps> = ({ data, loading, period = 'month' }) => {
|
||
if (loading || !data || data.length === 0) {
|
||
return (
|
||
<div style={{
|
||
height: '200px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: '14px'
|
||
}}>
|
||
{loading ? 'Загрузка...' : 'Нет данных'}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Форматируем данные для графика в зависимости от периода
|
||
let chartData: Array<{
|
||
date: string;
|
||
income: number;
|
||
lessons: number;
|
||
fullDate?: string;
|
||
rawDate: Date | null;
|
||
}> = [];
|
||
|
||
try {
|
||
chartData = (Array.isArray(data) ? data : []).map((item) => {
|
||
const rawDateString = item?.date ?? '';
|
||
let dateLabel = rawDateString; // По умолчанию используем исходную строку
|
||
let date: Date | null = null;
|
||
|
||
// Парсим дату в зависимости от формата, который возвращает backend
|
||
if (period === 'day') {
|
||
// Для дня формат: "00:00", "01:00" и т.д. - просто используем как есть
|
||
dateLabel = rawDateString || '';
|
||
} else if (period === 'week') {
|
||
// Для недели формат: "26.01", "27.01" и т.д. (день.месяц)
|
||
if (rawDateString && /^\d{2}\.\d{2}$/.test(rawDateString)) {
|
||
const [day, month] = rawDateString.split('.').map(Number);
|
||
const now = new Date();
|
||
date = new Date(now.getFullYear(), (month || 1) - 1, day || 1);
|
||
if (!isNaN(date.getTime())) {
|
||
dateLabel = date.toLocaleDateString('ru-RU', {
|
||
weekday: 'short',
|
||
day: 'numeric',
|
||
});
|
||
}
|
||
}
|
||
} else {
|
||
// Для месяца формат: "01.01", "02.01" и т.д. (день.месяц)
|
||
if (rawDateString && /^\d{2}\.\d{2}$/.test(rawDateString)) {
|
||
const [day, month] = rawDateString.split('.').map(Number);
|
||
const now = new Date();
|
||
date = new Date(now.getFullYear(), (month || 1) - 1, day || 1);
|
||
if (!isNaN(date.getTime())) {
|
||
dateLabel = date.toLocaleDateString('ru-RU', {
|
||
day: 'numeric',
|
||
month: 'short',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
date: dateLabel || '',
|
||
income: Math.round(Number(item?.income ?? 0)) || 0,
|
||
lessons: Number(item?.lessons ?? 0) || 0,
|
||
fullDate: rawDateString,
|
||
rawDate: date,
|
||
};
|
||
});
|
||
} catch (e) {
|
||
console.error('[RevenueChartClient] Ошибка при подготовке данных графика', e);
|
||
// Если что-то пошло не так с данными, показываем безопасный fallback,
|
||
// чтобы не было бесконечных перезагрузок страницы
|
||
return (
|
||
<div
|
||
style={{
|
||
height: '200px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontSize: '14px',
|
||
}}
|
||
>
|
||
Ошибка загрузки графика
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Кастомный tooltip в стиле Material Design 3
|
||
const CustomTooltip = ({ active, payload }: any) => {
|
||
if (active && payload && payload.length) {
|
||
return (
|
||
<div style={{
|
||
background: 'var(--md-sys-color-surface-container-high)',
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
borderRadius: '12px',
|
||
padding: '12px',
|
||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||
}}>
|
||
<p style={{
|
||
margin: '0 0 8px 0',
|
||
fontSize: '12px',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
fontWeight: '500'
|
||
}}>
|
||
{payload[0]?.payload?.fullDate ? (() => {
|
||
let date: Date;
|
||
|
||
// Используем rawDate если он есть (уже распарсен)
|
||
if (payload[0].payload.rawDate && !isNaN(new Date(payload[0].payload.rawDate).getTime())) {
|
||
date = new Date(payload[0].payload.rawDate);
|
||
} else if (
|
||
period === 'day' &&
|
||
typeof payload[0].payload.fullDate === 'string' &&
|
||
/^\d{2}:\d{2}$/.test(payload[0].payload.fullDate)
|
||
) {
|
||
// Формат "HH:MM"
|
||
const [hours, minutes] = payload[0].payload.fullDate.split(':').map(Number);
|
||
date = new Date();
|
||
date.setHours(hours, minutes || 0, 0, 0);
|
||
} else if (
|
||
(period === 'week' || period === 'month') &&
|
||
typeof payload[0].payload.fullDate === 'string' &&
|
||
/^\d{2}\.\d{2}$/.test(payload[0].payload.fullDate)
|
||
) {
|
||
// Формат "DD.MM"
|
||
const [day, month] = payload[0].payload.fullDate.split('.').map(Number);
|
||
date = new Date();
|
||
date.setMonth(month - 1, day);
|
||
date.setFullYear(new Date().getFullYear());
|
||
} else {
|
||
// Пробуем ISO формат
|
||
date = new Date(payload[0].payload.fullDate);
|
||
}
|
||
|
||
if (isNaN(date.getTime())) {
|
||
return payload[0].payload.date || payload[0].payload.fullDate; // Возвращаем отформатированную дату или исходную строку
|
||
}
|
||
|
||
if (period === 'day') {
|
||
return date.toLocaleDateString('ru-RU', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric'
|
||
}) + ' ' + date.toLocaleTimeString('ru-RU', {
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
} else if (period === 'week') {
|
||
return date.toLocaleDateString('ru-RU', {
|
||
weekday: 'long',
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric'
|
||
});
|
||
} else {
|
||
return date.toLocaleDateString('ru-RU', {
|
||
day: 'numeric',
|
||
month: 'long',
|
||
year: 'numeric'
|
||
});
|
||
}
|
||
})() : ''}
|
||
</p>
|
||
<div style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '4px'
|
||
}}>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px'
|
||
}}>
|
||
<div style={{
|
||
width: '8px',
|
||
height: '8px',
|
||
borderRadius: '50%',
|
||
background: 'var(--md-sys-color-primary)'
|
||
}}></div>
|
||
<span style={{
|
||
fontSize: '14px',
|
||
color: 'var(--md-sys-color-on-surface)'
|
||
}}>
|
||
Доход: <strong>{payload[0].value.toLocaleString('ru-RU')} ₽</strong>
|
||
</span>
|
||
</div>
|
||
{payload[1] && (
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '8px'
|
||
}}>
|
||
<div style={{
|
||
width: '8px',
|
||
height: '8px',
|
||
borderRadius: '50%',
|
||
background: 'var(--md-sys-color-tertiary)'
|
||
}}></div>
|
||
<span style={{
|
||
fontSize: '14px',
|
||
color: 'var(--md-sys-color-on-surface)'
|
||
}}>
|
||
Занятий: <strong>{payload[1].value}</strong>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return (
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<AreaChart
|
||
data={chartData}
|
||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||
>
|
||
<defs>
|
||
<linearGradient id="colorIncome" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="5%" stopColor="var(--md-sys-color-primary)" stopOpacity={0.3}/>
|
||
<stop offset="95%" stopColor="var(--md-sys-color-primary)" stopOpacity={0}/>
|
||
</linearGradient>
|
||
</defs>
|
||
<CartesianGrid
|
||
strokeDasharray="3 3"
|
||
stroke="var(--md-sys-color-outline-variant)"
|
||
opacity={0.3}
|
||
/>
|
||
<XAxis
|
||
dataKey="date"
|
||
stroke="var(--md-sys-color-on-surface-variant)"
|
||
style={{ fontSize: '12px' }}
|
||
tick={{ fill: 'var(--md-sys-color-on-surface-variant)' }}
|
||
/>
|
||
<YAxis
|
||
yAxisId="income"
|
||
stroke="var(--md-sys-color-primary)"
|
||
style={{ fontSize: '12px' }}
|
||
tick={{ fill: 'var(--md-sys-color-on-surface-variant)' }}
|
||
tickFormatter={(value) => `${value} ₽`}
|
||
label={{
|
||
value: 'Доход (₽)',
|
||
angle: -90,
|
||
position: 'insideLeft',
|
||
style: { textAnchor: 'middle', fill: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' }
|
||
}}
|
||
/>
|
||
<YAxis
|
||
yAxisId="lessons"
|
||
orientation="right"
|
||
stroke="var(--md-sys-color-tertiary)"
|
||
style={{ fontSize: '12px' }}
|
||
tick={{ fill: 'var(--md-sys-color-on-surface-variant)' }}
|
||
label={{
|
||
value: 'Занятий',
|
||
angle: 90,
|
||
position: 'insideRight',
|
||
style: { textAnchor: 'middle', fill: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' }
|
||
}}
|
||
/>
|
||
<Tooltip content={<CustomTooltip />} />
|
||
<Legend
|
||
wrapperStyle={{ paddingTop: '20px' }}
|
||
iconType="circle"
|
||
formatter={(value) => {
|
||
if (value === 'Доход') {
|
||
return <span style={{ color: 'var(--md-sys-color-on-surface)' }}>Доход</span>;
|
||
}
|
||
return <span style={{ color: 'var(--md-sys-color-on-surface)' }}>Занятий</span>;
|
||
}}
|
||
/>
|
||
<Area
|
||
yAxisId="income"
|
||
type="monotone"
|
||
dataKey="income"
|
||
stroke="var(--md-sys-color-primary)"
|
||
strokeWidth={2}
|
||
fill="url(#colorIncome)"
|
||
name="Доход"
|
||
/>
|
||
<Line
|
||
yAxisId="lessons"
|
||
type="monotone"
|
||
dataKey="lessons"
|
||
stroke="var(--md-sys-color-tertiary)"
|
||
strokeWidth={2}
|
||
dot={{ fill: 'var(--md-sys-color-tertiary)', r: 4 }}
|
||
name="Занятий"
|
||
/>
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
);
|
||
};
|