513 lines
22 KiB
TypeScript
513 lines
22 KiB
TypeScript
'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<DateRange>(() => defaultRange);
|
||
const [rangeLessons, setRangeLessons] = useState<DateRange>(() => defaultRange);
|
||
const [rangeSuccess, setRangeSuccess] = useState<DateRange>(() => defaultRange);
|
||
|
||
const [incomeData, setIncomeData] = useState<any>(null);
|
||
const [incomeLoading, setIncomeLoading] = useState(true);
|
||
const [overviewLessons, setOverviewLessons] = useState<any>(null);
|
||
const [revenueLessons, setRevenueLessons] = useState<AnalyticsRevenueResponse | null>(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<any>(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<SwiperType | null>(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 (
|
||
<div className="ios26-dashboard" style={{ padding: 24, textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||
Аналитика доступна только менторам.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<DashboardLayout className="ios26-dashboard-analytics">
|
||
<div className="ios26-analytics-swiper-wrap">
|
||
<Swiper
|
||
onSwiper={setSwiperInstance}
|
||
loop
|
||
slidesPerView={1}
|
||
spaceBetween={0}
|
||
className="ios26-analytics-swiper"
|
||
>
|
||
<SwiperSlide>
|
||
{/* Доход */}
|
||
<Panel padding="md">
|
||
<SectionHeader
|
||
title="Доход"
|
||
trailing={<DateRangePicker value={rangeIncome} onChange={setRangeIncome} disabled={incomeLoading} />}
|
||
/>
|
||
{incomeLoading && !incomeData ? (
|
||
<div className="ios26-analytics-chart-placeholder" style={{ minHeight: chartHeight, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<LoadingSpinner size="medium" />
|
||
</div>
|
||
) : (
|
||
<div className="ios26-analytics-chart-row">
|
||
<div className="ios26-analytics-chart-col">
|
||
<RevenueChart data={incomeChartData} loading={incomeLoading} period="week" showLessons={false} height={chartHeight} />
|
||
</div>
|
||
<div className="ios26-stat-grid ios26-stat-grid--aside">
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Общий доход</div>
|
||
<div className="ios26-stat-value ios26-stat-value--primary">
|
||
{incomeData?.summary ? formatCurrency(incomeData.summary.total_income || 0) : '—'}
|
||
</div>
|
||
</div>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Средняя цена</div>
|
||
<div className="ios26-stat-value ios26-stat-value--primary">
|
||
{incomeData?.summary ? formatCurrency(incomeData.summary.average_lesson_price || 0) : '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
</SwiperSlide>
|
||
|
||
<SwiperSlide>
|
||
{/* Занятия */}
|
||
<Panel padding="md">
|
||
<SectionHeader
|
||
title="Занятия"
|
||
trailing={<DateRangePicker value={rangeLessons} onChange={setRangeLessons} disabled={lessonsLoading} />}
|
||
/>
|
||
{lessonsLoading && !revenueLessons ? (
|
||
<div className="ios26-analytics-chart-placeholder" style={{ minHeight: chartHeight, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<LoadingSpinner size="medium" />
|
||
</div>
|
||
) : (
|
||
<div className="ios26-analytics-chart-row">
|
||
<div className="ios26-analytics-chart-col">
|
||
{(() => {
|
||
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 ? (
|
||
<LessonsByDayChart byDay={byDay} height={chartHeight} />
|
||
) : (
|
||
<div className="ios26-analytics-chart-placeholder" style={{ height: chartHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||
Нет данных за период
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
<div className="ios26-stat-grid ios26-stat-grid--aside">
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Всего</div>
|
||
<div className="ios26-stat-value">
|
||
{overviewLessons?.lessons?.total ?? '—'}
|
||
</div>
|
||
</div>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Проведено</div>
|
||
<div className="ios26-stat-value ios26-stat-value--primary">
|
||
{overviewLessons?.lessons?.completed ?? '—'}
|
||
</div>
|
||
</div>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Отменено</div>
|
||
<div className="ios26-stat-value ios26-stat-value--error">
|
||
{overviewLessons?.lessons?.cancelled ?? '—'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
</SwiperSlide>
|
||
|
||
<SwiperSlide>
|
||
{/* Успех учеников — средняя оценка по дням, продуктивность репетитора */}
|
||
<Panel padding="md">
|
||
<SectionHeader
|
||
title="Успех учеников"
|
||
trailing={<DateRangePicker value={rangeSuccess} onChange={setRangeSuccess} disabled={successLoading} />}
|
||
/>
|
||
{successLoading && !gradesByDaySuccess ? (
|
||
<div className="ios26-analytics-chart-placeholder" style={{ minHeight: chartHeight, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<LoadingSpinner size="medium" />
|
||
</div>
|
||
) : (
|
||
<div className="ios26-analytics-chart-row">
|
||
<div className="ios26-analytics-chart-col">
|
||
{gradesByDaySuccess?.by_day?.length ? (
|
||
<GradesByDayChart byDay={gradesByDaySuccess.by_day} height={chartHeight} />
|
||
) : (
|
||
<div className="ios26-analytics-chart-placeholder" style={{ height: chartHeight, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||
Нет данных за период
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="ios26-stat-grid ios26-stat-grid--aside">
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Средняя оценка</div>
|
||
<div className="ios26-stat-value">{gradesByDaySuccess?.summary?.average_grade ?? overviewSuccess?.grades?.average ?? '—'}</div>
|
||
</div>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Занятий с оценкой</div>
|
||
<div className="ios26-stat-value">{gradesByDaySuccess?.summary?.graded_lessons ?? '—'}</div>
|
||
</div>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Активных учеников</div>
|
||
<div className="ios26-stat-value">{overviewSuccess?.students?.active ?? '—'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
</SwiperSlide>
|
||
|
||
<SwiperSlide>
|
||
{/* Топ занятий по доходам + Топ ученики — одна панель в 2 колонки */}
|
||
<Panel padding="md">
|
||
<div className="ios26-analytics-two-cols">
|
||
<div className="ios26-analytics-col">
|
||
<SectionHeader title="Топ занятий по доходам" trailing={<DateRangePicker value={rangeIncome} onChange={setRangeIncome} disabled={incomeLoading} />} />
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
{(() => {
|
||
const topLessons = (incomeData?.top_lessons ?? []).slice(0, 10);
|
||
if (!topLessons.length) {
|
||
return <p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: 0, padding: '8px 0' }}>Нет данных за период</p>;
|
||
}
|
||
return topLessons.map((item: any, i: number) => (
|
||
<ListRow
|
||
key={`${item.lesson_title ?? ''}-${item.target_name ?? ''}-${i}`}
|
||
label={`${i + 1}. ${item.lesson_title || item.target_name || 'Занятие'}`}
|
||
value={formatCurrency(item.total_income ?? 0)}
|
||
highlight="primary"
|
||
last={i === topLessons.length - 1}
|
||
/>
|
||
));
|
||
})()}
|
||
</div>
|
||
</div>
|
||
<div className="ios26-analytics-col">
|
||
<SectionHeader title="Топ ученики" trailing={<DateRangePicker value={rangeSuccess} onChange={setRangeSuccess} disabled={successLoading} />} />
|
||
{studentsSuccess?.students?.length ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||
{studentsSuccess.students.slice(0, 10).map((s, i) => (
|
||
<ListRow
|
||
key={s.id}
|
||
label={s.name}
|
||
value={`${s.lessons_completed} занятий · ср. ${s.average_grade}`}
|
||
last={i === Math.min(studentsSuccess.students.length, 10) - 1}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: 0 }}>Нет данных</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Panel>
|
||
</SwiperSlide>
|
||
</Swiper>
|
||
<div className="ios26-analytics-nav">
|
||
<button
|
||
type="button"
|
||
className="ios26-analytics-nav-btn"
|
||
style={navButtonStyle}
|
||
onClick={() => swiperInstance?.slidePrev()}
|
||
aria-label="Предыдущий слайд"
|
||
>
|
||
<KeyboardArrowLeft fontSize="medium" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ios26-analytics-nav-btn"
|
||
style={navButtonStyle}
|
||
onClick={() => swiperInstance?.slideNext()}
|
||
aria-label="Следующий слайд"
|
||
>
|
||
<KeyboardArrowRight fontSize="medium" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</DashboardLayout>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="ios26-analytics-chart" style={{ width: '100%', height: `${height}px` }}>
|
||
<ApexChart options={options as unknown as ApexOptions} series={series} type="area" height={height} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="ios26-analytics-chart" style={{ width: '100%', height: `${height}px` }}>
|
||
<ApexChart options={options as unknown as ApexOptions} series={series} type="area" height={height} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div style={{ width: '100%', height: 250 }}>
|
||
<ApexChart options={options as unknown as ApexOptions} series={series} type="bar" height={250} />
|
||
</div>
|
||
);
|
||
}
|