uchill/front_material/components/dashboard/RevenueChartClient.tsx

327 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Клиентский компонент графика доходов (без 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>
);
};