uchill/front_material/app/(protected)/analytics/page.tsx

513 lines
21 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.

'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" data-tour="analytics-root">
<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>
);
}