'use client'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { Swiper, SwiperSlide } from 'swiper/react'; import 'swiper/css'; /** Высота графиков на странице аналитики — доля от высоты экрана (обновляется при resize). */ function useAnalyticsChartHeight(fraction = 0.55) { const [height, setHeight] = useState(300); useEffect(() => { const update = () => setHeight(Math.round((typeof window !== 'undefined' ? window.innerHeight : 600) * fraction)); update(); window.addEventListener('resize', update); return () => window.removeEventListener('resize', update); }, [fraction]); return height; } import dynamic from 'next/dynamic'; import { useAuth } from '@/contexts/AuthContext'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { getIncomeStats } from '@/api/income'; import { getLast30DaysRange, toAnalyticsRange, getAnalyticsOverview, getAnalyticsStudents, getAnalyticsRevenue, getAnalyticsGradesByDay, type StudentStat, type AnalyticsRevenueResponse, } from '@/api/analytics'; import { DashboardLayout, Panel, SectionHeader, ListRow, } from '@/components/dashboard/ui'; import { RevenueChart } from '@/components/dashboard/RevenueChart'; import type { IncomeChartData } from '@/api/dashboard'; import { DateRangePicker } from '@/components/common/DateRangePicker'; import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material'; import type { Swiper as SwiperType } from 'swiper'; const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); import type { ApexOptions } from 'apexcharts'; const navButtonStyle: React.CSSProperties = { width: 40, height: 40, padding: 0, borderRadius: 14, border: 'none', background: 'var(--md-sys-color-primary)', color: 'var(--md-sys-color-on-primary)', fontSize: 20, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, boxShadow: 'var(--ios26-shadow-soft)', transition: 'opacity 0.2s ease, box-shadow 0.2s ease', }; type DateRange = { start_date: string; end_date: string }; const formatCurrency = (v: number) => new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v); export default function AnalyticsPage() { const { user } = useAuth(); const chartHeight = useAnalyticsChartHeight(0.8); const defaultRange = getLast30DaysRange(); const [rangeIncome, setRangeIncome] = useState(() => defaultRange); const [rangeLessons, setRangeLessons] = useState(() => defaultRange); const [rangeSuccess, setRangeSuccess] = useState(() => defaultRange); const [incomeData, setIncomeData] = useState(null); const [incomeLoading, setIncomeLoading] = useState(true); const [overviewLessons, setOverviewLessons] = useState(null); const [revenueLessons, setRevenueLessons] = useState(null); const [incomeForLessons, setIncomeForLessons] = useState<{ chart_data: { date: string; income: number; lessons: number }[] } | null>(null); const [lessonsLoading, setLessonsLoading] = useState(true); const [studentsSuccess, setStudentsSuccess] = useState<{ students: StudentStat[] } | null>(null); const [overviewSuccess, setOverviewSuccess] = useState(null); const [gradesByDaySuccess, setGradesByDaySuccess] = useState<{ by_day: { date: string; average_grade: number | null; lessons_count: number; graded_count: number }[]; summary: { total_lessons: number; graded_lessons: number; average_grade: number }; } | null>(null); const [successLoading, setSuccessLoading] = useState(true); const [swiperInstance, setSwiperInstance] = useState(null); const loadIncome = useCallback(async () => { if (user?.role !== 'mentor') return; setIncomeLoading(true); try { const d = await getIncomeStats({ period: 'range', start_date: rangeIncome.start_date, end_date: rangeIncome.end_date, }); setIncomeData(d); } catch { setIncomeData(null); } finally { setIncomeLoading(false); } }, [user?.role, rangeIncome]); const loadLessons = useCallback(async () => { if (user?.role !== 'mentor') return; setLessonsLoading(true); try { const r = toAnalyticsRange(rangeLessons); const [ov, rev, income] = await Promise.all([ getAnalyticsOverview(r).catch(() => null), getAnalyticsRevenue(r).catch(() => null), getIncomeStats({ period: 'range', start_date: rangeLessons.start_date, end_date: rangeLessons.end_date }).catch(() => null), ]); setOverviewLessons(ov); setRevenueLessons(rev); setIncomeForLessons(income ?? null); } catch { setOverviewLessons(null); setRevenueLessons(null); setIncomeForLessons(null); } finally { setLessonsLoading(false); } }, [user?.role, rangeLessons]); const loadSuccess = useCallback(async () => { if (user?.role !== 'mentor') return; setSuccessLoading(true); try { const r = toAnalyticsRange(rangeSuccess); const [stu, ov, grades] = await Promise.all([ getAnalyticsStudents(r).catch(() => null), getAnalyticsOverview(r).catch(() => null), getAnalyticsGradesByDay(r).catch(() => null), ]); setStudentsSuccess(stu ? { students: stu.students } : null); setOverviewSuccess(ov); setGradesByDaySuccess(grades ? { by_day: grades.by_day, summary: grades.summary } : null); } catch { setStudentsSuccess(null); setOverviewSuccess(null); setGradesByDaySuccess(null); } finally { setSuccessLoading(false); } }, [user?.role, rangeSuccess]); useEffect(() => { loadIncome(); }, [loadIncome]); useEffect(() => { loadLessons(); }, [loadLessons]); useEffect(() => { loadSuccess(); }, [loadSuccess]); if (user?.role !== 'mentor') { return (
Аналитика доступна только менторам.
); } const incomeChartData: IncomeChartData[] = useMemo( () => (incomeData?.chart_data ?? []).map((d: { date: string; income: number; lessons: number }) => ({ date: d.date, income: d.income, lessons: d.lessons, })), [incomeData?.chart_data], ); return (
{/* Доход */} } /> {incomeLoading && !incomeData ? (
) : (
Общий доход
{incomeData?.summary ? formatCurrency(incomeData.summary.total_income || 0) : '—'}
Средняя цена
{incomeData?.summary ? formatCurrency(incomeData.summary.average_lesson_price || 0) : '—'}
)}
{/* Занятия */} } /> {lessonsLoading && !revenueLessons ? (
) : (
{(() => { const byDay = revenueLessons?.by_day?.length ? revenueLessons.by_day : incomeForLessons?.chart_data?.map((d) => ({ date: d.date, revenue: d.income, lessons_count: d.lessons })) ?? []; return byDay.length ? ( ) : (
Нет данных за период
); })()}
Всего
{overviewLessons?.lessons?.total ?? '—'}
Проведено
{overviewLessons?.lessons?.completed ?? '—'}
Отменено
{overviewLessons?.lessons?.cancelled ?? '—'}
)}
{/* Успех учеников — средняя оценка по дням, продуктивность репетитора */} } /> {successLoading && !gradesByDaySuccess ? (
) : (
{gradesByDaySuccess?.by_day?.length ? ( ) : (
Нет данных за период
)}
Средняя оценка
{gradesByDaySuccess?.summary?.average_grade ?? overviewSuccess?.grades?.average ?? '—'}
Занятий с оценкой
{gradesByDaySuccess?.summary?.graded_lessons ?? '—'}
Активных учеников
{overviewSuccess?.students?.active ?? '—'}
)}
{/* Топ занятий по доходам + Топ ученики — одна панель в 2 колонки */}
} />
{(() => { const topLessons = (incomeData?.top_lessons ?? []).slice(0, 10); if (!topLessons.length) { return

Нет данных за период

; } return topLessons.map((item: any, i: number) => ( )); })()}
} /> {studentsSuccess?.students?.length ? (
{studentsSuccess.students.slice(0, 10).map((s, i) => ( ))}
) : (

Нет данных

)}
); } function LessonsByDayChart({ byDay, height = 250 }: { byDay: { date: string; revenue: number; lessons_count: number }[]; height?: number }) { const categories = useMemo(() => byDay.map((d) => d.date), [byDay]); const series = useMemo(() => [{ name: 'Занятий', data: byDay.map((d) => d.lessons_count) }], [byDay]); const options = useMemo( () => ({ chart: { id: 'lessons-by-day', toolbar: { show: true, tools: { zoomin: true, zoomout: true, pan: true, reset: true }, autoSelected: 'pan' as const, }, zoom: { enabled: true, type: 'x' as const, allowMouseWheelZoom: true }, pan: { enabled: true, type: 'x' as const }, }, stroke: { curve: 'smooth' as const, width: 2 }, colors: ['var(--md-sys-color-primary)'], dataLabels: { enabled: false }, xaxis: { categories, tickPlacement: 'on', axisBorder: { show: false }, axisTicks: { show: false }, labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, }, yaxis: { labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, }, fill: { type: 'gradient' as const, gradient: { shadeIntensity: 0.5, opacityFrom: 0.5, opacityTo: 0.1 } }, grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, tooltip: { y: { formatter: (val: number) => `${val} занятий` } }, }), [categories], ); return (
); } function GradesByDayChart({ byDay, height = 250, }: { byDay: { date: string; average_grade: number | null; lessons_count: number; graded_count: number }[]; height?: number; }) { const categories = useMemo(() => byDay.map((d) => d.date), [byDay]); const series = useMemo( () => [{ name: 'Средняя оценка', data: byDay.map((d) => d.average_grade ?? 0) }], [byDay], ); const options = useMemo( () => ({ chart: { id: 'grades-by-day', toolbar: { show: true, tools: { zoomin: true, zoomout: true, pan: true, reset: true }, autoSelected: 'pan' as const, }, zoom: { enabled: true, type: 'x' as const, autoScaleYaxis: false, allowMouseWheelZoom: true, }, pan: { enabled: true, type: 'x' as const }, }, stroke: { curve: 'smooth' as const, width: 2 }, colors: ['var(--md-sys-color-primary)'], dataLabels: { enabled: false }, xaxis: { categories, tickPlacement: 'on', axisBorder: { show: false }, axisTicks: { show: false }, labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, }, yaxis: { min: 0, max: 5, labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, }, fill: { type: 'gradient' as const, gradient: { shadeIntensity: 0.5, opacityFrom: 0.5, opacityTo: 0.1 } }, grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, tooltip: { y: { formatter: (val: number) => (val ? `Ср. оценка: ${val}` : '—') }, }, }), [categories], ); return (
); } function StudentSuccessChart({ students }: { students: StudentStat[] }) { const categories = useMemo(() => students.map((s) => s.name.length > 12 ? s.name.slice(0, 10) + '…' : s.name), [students]); const series = useMemo(() => [{ name: 'Средняя оценка', data: students.map((s) => Number(s.average_grade) || 0) }], [students]); const options = useMemo( () => ({ chart: { id: 'student-success', type: 'bar', toolbar: { show: false } }, plotOptions: { bar: { borderRadius: 6, horizontal: false, columnWidth: '60%' } }, colors: ['var(--md-sys-color-primary)'], dataLabels: { enabled: false }, xaxis: { categories, labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' }, maxWidth: 100 }, }, yaxis: { min: 0, max: 5, labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, }, grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, tooltip: { y: { formatter: (val: number) => `Оценка: ${val}` } }, }), [categories], ); return (
); }