'use client'; import { useEffect, useState, useMemo } from 'react'; import { useSearchParams } from 'next/navigation'; import { useAuth } from '@/contexts/AuthContext'; import dynamic from 'next/dynamic'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { DateCalendar } from '@mui/x-date-pickers/DateCalendar'; import { PickersDay, PickersDayProps } from '@mui/x-date-pickers/PickersDay'; import dayjs, { Dayjs } from 'dayjs'; import isoWeek from 'dayjs/plugin/isoWeek'; import 'dayjs/locale/ru'; import Popover from '@mui/material/Popover'; import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material'; dayjs.extend(isoWeek); import { loadComponent } from '@/lib/material-components'; import { useOptimizedFetch } from '@/hooks/useOptimizedFetch'; import { apiClient } from '@/lib/api-client'; import { SubjectNameSelect } from '@/components/dashboard/SubjectNameSelect'; import { checkUserByEmail, addStudentInvitation, generateInvitationLink, getMentorshipRequestsPending, acceptMentorshipRequest, rejectMentorshipRequest, type MentorshipRequestItem, } from '@/api/students'; import { getLessons, type Lesson } from '@/api/schedule'; import { getHomeworkSubmissionsBySubject, type HomeworkSubmission } from '@/api/homework'; import { format, addDays, subMonths } from 'date-fns'; import { ru } from 'date-fns/locale'; import { DashboardLayout, Panel, SectionHeader } from '@/components/dashboard/ui'; import { DateRangePicker } from '@/components/common/DateRangePicker'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; import { getErrorMessage } from '@/lib/error-utils'; import { useToast } from '@/contexts/ToastContext'; const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); import type { ApexOptions } from 'apexcharts'; const CHART_COLORS = ['#6750A4', '#7D5260']; function getSubjectFromLesson(lesson: Lesson): string { if (typeof lesson.subject === 'string' && lesson.subject?.trim()) return lesson.subject.trim(); const sub = (lesson as any).subject_name; if (typeof sub === 'string' && sub?.trim()) return sub.trim(); const ms = (lesson as any).mentor_subject; if (ms?.name) return ms.name; return ''; } function getDatesInRange(startStr: string, endStr: string): string[] { const dates: string[] = []; let d = new Date(startStr); const end = new Date(endStr); while (d <= end) { dates.push(format(d, 'yyyy-MM-dd')); d = addDays(d, 1); } return dates; } interface SubjectProgress { subject: string; total_lessons: number; completed_lessons: number; homework_count: number; homework_completed: number; homework_average: number; } interface StudentProgressResponse { student: { id: number; name: string; email: string; }; overall: { average_grade: number; total_lessons: number; completed_lessons: number; total_grades: number; }; subjects: SubjectProgress[]; progress_timeline: Array<{ date: string | null; grade: number; subject: string; comment: string; }>; daily_stats?: Array<{ date: string; total_lessons: number; completed_lessons: number; }>; } export default function StudentsPage() { const { user: currentUser } = useAuth(); const { showToast } = useToast(); const [componentsLoaded, setComponentsLoaded] = useState(false); const [selectedStudent, setSelectedStudent] = useState(null); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); const [selectedSubject, setSelectedSubject] = useState(null); const [progress, setProgress] = useState(null); const [progressLoading, setProgressLoading] = useState(false); const [periodAnchor, setPeriodAnchor] = useState(null); const [panelExpanded, setPanelExpanded] = useState(false); const [localStart, setLocalStart] = useState(null); const [localEnd, setLocalEnd] = useState(null); const [rangeMode, setRangeMode] = useState<'start' | 'end'>('start'); const [lessons, setLessons] = useState([]); const [lessonsLoading, setLessonsLoading] = useState(false); const [homeworkSubmissions, setHomeworkSubmissions] = useState([]); const [homeworkLoading, setHomeworkLoading] = useState(false); // Панель «Добавить студента» const [showAddPanel, setShowAddPanel] = useState(false); const [addMode, setAddMode] = useState<'email' | 'code' | 'link'>('link'); const [addEmail, setAddEmail] = useState(''); const [addCode, setAddCode] = useState(''); const [addCheckResult, setAddCheckResult] = useState<{ exists: boolean; is_client: boolean } | null>(null); const [addSubmitting, setAddSubmitting] = useState(false); const [addSubmitted, setAddSubmitted] = useState(false); const [addError, setAddError] = useState(''); const [invitationLink, setInvitationLink] = useState(currentUser?.invitation_link || null); const [linkCopied, setLinkCopied] = useState(false); useEffect(() => { if (currentUser?.invitation_link) { setInvitationLink(currentUser.invitation_link); } }, [currentUser?.invitation_link]); const searchParams = useSearchParams(); const tabFromUrl = searchParams?.get('tab'); // Табы: студенты | запросы на менторство | ожидают ответа const [activeTab, setActiveTab] = useState<'students' | 'requests' | 'awaiting'>( tabFromUrl === 'requests' ? 'requests' : tabFromUrl === 'awaiting' ? 'awaiting' : 'students' ); useEffect(() => { if (tabFromUrl === 'requests') setActiveTab('requests'); if (tabFromUrl === 'awaiting') setActiveTab('awaiting'); }, [tabFromUrl]); const [mentorshipRequests, setMentorshipRequests] = useState([]); const [requestsLoading, setRequestsLoading] = useState(false); const [requestActionId, setRequestActionId] = useState(null); useEffect(() => { Promise.all([ loadComponent('elevated-card'), loadComponent('filled-button'), loadComponent('icon'), loadComponent('list'), loadComponent('list-item'), ]).then(() => { setComponentsLoaded(true); }).catch((err) => { console.error('Error loading components:', err); setComponentsLoaded(true); }); }, []); useEffect(() => { if (periodAnchor) { setLocalStart(startDate); setLocalEnd(endDate); setRangeMode('start'); } }, [periodAnchor, startDate, endDate]); const { data: studentsData, loading, refetch } = useOptimizedFetch({ // Для менторов список студентов доступен через manage/clients url: '/manage/clients/', cacheKey: 'students_list', cacheTTL: 5 * 60 * 1000, // 5 минут }); // Загрузка запросов на менторство: при переключении на вкладку и при монтировании (для показа табов) useEffect(() => { setRequestsLoading(true); getMentorshipRequestsPending() .then((data) => setMentorshipRequests(data)) .catch(() => setMentorshipRequests([])) .finally(() => setRequestsLoading(false)); }, [activeTab]); // при смене таба и при первом рендере // Загружаем детальный прогресс ученика при выборе и изменении фильтров useEffect(() => { if (!selectedStudent) { setProgress(null); setSelectedSubject(null); return; } const controller = new AbortController(); const params: string[] = []; if (selectedSubject) { params.push(`subject=${encodeURIComponent(selectedSubject)}`); } if (startDate) { params.push(`start_date=${startDate.format('YYYY-MM-DD')}`); } if (endDate) { params.push(`end_date=${endDate.format('YYYY-MM-DD')}`); } const qs = params.length ? `?${params.join('&')}` : ''; setProgressLoading(true); apiClient .get(`/student-progress/${selectedStudent.id}/progress/${qs}`, { signal: controller.signal as any, }) .then((res) => { setProgress(res.data); if (res.data.subjects.length > 0) { const kept = selectedSubject && res.data.subjects.some((s) => s.subject === selectedSubject); if (!kept) setSelectedSubject(res.data.subjects[0].subject); } else { setSelectedSubject(null); } }) .catch((err: any) => { if (err.name !== 'CanceledError' && err.name !== 'AbortError') { console.error('Ошибка загрузки прогресса студента', err); } setProgress(null); }) .finally(() => { setProgressLoading(false); }); return () => controller.abort(); }, [selectedStudent, selectedSubject, startDate, endDate]); const startStr = startDate && endDate ? startDate.format('YYYY-MM-DD') : ''; const endStr = startDate && endDate ? endDate.format('YYYY-MM-DD') : ''; useEffect(() => { if (!selectedStudent || !startStr || !endStr) { setLessons([]); return; } let cancelled = false; setLessonsLoading(true); getLessons({ start_date: startStr, end_date: endStr, client_id: String(selectedStudent.id), }) .then((res) => { if (cancelled) return; const list = (res.results || []).filter((l: Lesson) => { const sub = getSubjectFromLesson(l); return selectedSubject ? sub === selectedSubject : true; }); list.sort((a: Lesson, b: Lesson) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); setLessons(list); }) .catch(() => { if (!cancelled) setLessons([]); }) .finally(() => { if (!cancelled) setLessonsLoading(false); }); return () => { cancelled = true; }; }, [selectedStudent, startStr, endStr, selectedSubject]); useEffect(() => { if (!selectedStudent?.user?.id || !selectedSubject || !startStr || !endStr) { setHomeworkSubmissions([]); return; } let cancelled = false; setHomeworkLoading(true); getHomeworkSubmissionsBySubject({ subject: selectedSubject, start_date: startStr, end_date: endStr, child_id: String(selectedStudent.user.id), }) .then((res) => { if (cancelled) return; setHomeworkSubmissions(res.results || []); }) .catch(() => { if (!cancelled) setHomeworkSubmissions([]); }) .finally(() => { if (!cancelled) setHomeworkLoading(false); }); return () => { cancelled = true; }; }, [selectedStudent?.user?.id, selectedSubject, startStr, endStr]); const periodStats = useMemo(() => { const completed = lessons.filter((l) => l.status === 'completed').length; const total = lessons.length; const attendanceRate = total > 0 ? Math.round((completed / total) * 100) : 0; const withGrades = lessons.filter((l) => l.status === 'completed' && ((l as any).mentor_grade != null || (l as any).school_grade != null)); let sum = 0; let count = 0; withGrades.forEach((l) => { const mg = (l as any).mentor_grade; const sg = (l as any).school_grade; if (mg != null) { sum += mg; count++; } if (sg != null) { sum += sg; count++; } }); const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0; const hwGraded = homeworkSubmissions.filter((s) => s.score != null && s.checked_at).length; return { completedLessons: completed, totalLessons: total, attendanceRate, avgGrade, hwGraded }; }, [lessons, homeworkSubmissions]); const gradesChart = useMemo(() => { if (!startStr || !endStr) return { series: [], categories: [] }; const allDates = getDatesInRange(startStr, endStr); const categories = allDates.map((d) => { const [, m, day] = d.split('-'); return `${day}.${m}`; }); const byDate: Record = {}; lessons .filter((l) => l.status === 'completed' && ((l as any).mentor_grade != null || (l as any).school_grade != null)) .forEach((l) => { const key = (l.start_time as string)?.slice?.(0, 10) || ''; if (!key) return; byDate[key] = { mentor: (l as any).mentor_grade ?? null, school: (l as any).school_grade ?? null }; }); const mentorGrades = allDates.map((d) => byDate[d]?.mentor ?? null); const schoolGrades = allDates.map((d) => byDate[d]?.school ?? null); return { series: [ { name: 'Оценка репетитора', data: mentorGrades }, { name: 'Оценка в школе', data: schoolGrades }, ], categories, }; }, [lessons, startStr, endStr]); const homeworkChart = useMemo(() => { if (!startStr || !endStr) return { series: [], categories: [] }; const allDates = getDatesInRange(startStr, endStr); const categories = allDates.map((d) => { const [, m, day] = d.split('-'); return `${day}.${m}`; }); const byDate: Record = {}; homeworkSubmissions .filter((s) => s.checked_at && s.score != null) .forEach((s) => { const key = format(new Date(s.checked_at!), 'yyyy-MM-dd'); byDate[key] = s.score ?? null; }); const scores = allDates.map((d) => byDate[d] ?? null); return { series: [{ name: 'Оценка за ДЗ', data: scores }], categories, }; }, [homeworkSubmissions, startStr, endStr]); const attendanceChart = useMemo(() => { if (!startStr || !endStr) return { series: [], categories: [] }; const allDates = getDatesInRange(startStr, endStr); const categories = allDates.map((d) => { const [, m, day] = d.split('-'); return `${day}.${m}`; }); const byDate: Record = {}; lessons.filter((l) => l.status === 'completed').forEach((l) => { const key = (l.start_time as string)?.slice?.(0, 10) || ''; if (!key) return; byDate[key] = (byDate[key] ?? 0) + 1; }); const data = allDates.map((d) => byDate[d] ?? 0); return { series: [{ name: 'Занятия проведены', data }], categories }; }, [lessons, startStr, endStr]); const chartOptionsBase = useMemo( () => ({ chart: { toolbar: { show: false }, zoom: { enabled: false } }, stroke: { curve: 'smooth' as const, width: 2 }, colors: CHART_COLORS, dataLabels: { enabled: false }, xaxis: { axisBorder: { show: false }, axisTicks: { show: false }, labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' } }, }, yaxis: { labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' } }, }, legend: { position: 'bottom' as const, horizontalAlign: 'center' as const, labels: { colors: 'var(--md-sys-color-on-surface-variant)' }, }, grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, }), [], ); if (!componentsLoaded) { return (
Загрузка...
); } const students = studentsData?.results || []; const pendingInvitations = (studentsData as any)?.pending_invitations || []; const filteredStudents = students; return (
{/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */} {(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
{mentorshipRequests.length > 0 && ( )} {pendingInvitations.length > 0 && ( )}
)} {activeTab === 'awaiting' ? ( pendingInvitations.length === 0 ? (

Нет ожидающих подтверждения

) : (
{pendingInvitations.map((inv: any) => { const st = inv.student || {}; const title = [st.first_name, st.last_name].filter(Boolean).join(' ') || st.email || 'Ученик'; const initials = [st.first_name?.[0], st.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || st.email?.[0]?.toUpperCase() || 'У'; return (
{initials}
{title}
{st.email || '—'}
); })}
) ) : activeTab === 'requests' ? (
{requestsLoading ? (
Загрузка запросов...
) : mentorshipRequests.length === 0 ? (

Нет новых запросов на менторство

) : (
{mentorshipRequests.map((req) => { const st = req.student; const title = [st.first_name, st.last_name].filter(Boolean).join(' ') || st.email || 'Ученик'; const initials = [st.first_name?.[0], st.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || st.email?.[0]?.toUpperCase() || 'У'; return (
{initials}
{title}
{st.email || '—'}
{req.created_at && (
{format(new Date(req.created_at), 'd MMM yyyy, HH:mm', { locale: ru })}
)}
); })}
)}
) : loading ? (
Загрузка студентов...
) : filteredStudents.length === 0 ? (

Нет студентов

) : (
{(activeTab === 'students' ? filteredStudents : []).map((student: any) => { const fullName = `${student?.user?.first_name || ''} ${student?.user?.last_name || ''}`.trim(); const title = fullName || student?.user?.email || 'Студент'; const initials = [student?.user?.first_name?.[0], student?.user?.last_name?.[0]].filter(Boolean).map((c) => c?.toUpperCase()).join('') || (student?.user?.email?.[0]?.toUpperCase()) || 'С'; const subtitleParts: string[] = []; if (typeof student?.total_lessons === 'number') subtitleParts.push(`${student.total_lessons} занятий`); if (typeof student?.completed_lessons === 'number') subtitleParts.push(`${student.completed_lessons} завершено`); const subtitle = subtitleParts.join(' • ') || (student?.user?.email || ''); const avatarUrl = student?.user?.avatar_url || student?.user?.avatar || ''; return ( { setSelectedStudent(student); const defaultStart = dayjs().subtract(3, 'month'); const defaultEnd = dayjs(); setStartDate(defaultStart); setEndDate(defaultEnd); }} >
{avatarUrl ? ( // eslint-disable-next-line @next/next/no-img-element {title} ) : (
{initials}
)}
{title}
{subtitle}
{student?.user?.login_link && (
)}
); })} {/* Карточка-действие «Добавить студента» — только во вкладке Студенты */} {activeTab === 'students' && ( { setShowAddPanel(true); setSelectedStudent(null); setAddSubmitted(false); setAddError(''); setAddEmail(''); setAddCode(''); setAddCheckResult(null); }} >
+
Добавить студента
Создайте карточку нового ученика
)}
)} {(selectedStudent || showAddPanel) && (
{showAddPanel ? 'Добавить студента' : 'Прогресс ученика'}
{showAddPanel ? 'Введите email или 8-символьный код' : `${selectedStudent?.user?.first_name || ''} ${selectedStudent?.user?.last_name || ''}`.trim()}
{!showAddPanel && ( )}
{showAddPanel ? (
{addSubmitted ? (
Отправлено. Ждём подтверждения от ученика{addCheckResult?.exists === false ? ' (и установки пароля по ссылке из письма)' : ''}. После подтверждения учеником и при необходимости родителем взаимодействие будет разрешено.
) : ( <>
{addMode === 'email' ? (
{ setAddEmail(e.target.value); setAddError(''); }} onBlur={async () => { const em = addEmail.trim().toLowerCase(); if (!em) { setAddCheckResult(null); return; } try { const res = await checkUserByEmail(em); setAddCheckResult(res); } catch { setAddCheckResult(null); } }} placeholder="example@mail.ru" style={{ padding: '12px 14px', borderRadius: 12, border: '1px solid var(--md-sys-color-outline)', background: 'var(--md-sys-color-surface-container-low)', color: 'var(--md-sys-color-on-surface)', fontSize: 16, }} /> {addCheckResult?.exists && addCheckResult?.is_client && (

Ученик уже зарегистрирован. Введите его 8-символьный универсальный код (в личном кабинете ученика).

)}
) : addMode === 'code' ? (
{ const v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8); setAddCode(v); setAddError(''); }} placeholder="A1B2C3D4" style={{ padding: '12px 14px', borderRadius: 12, border: '1px solid var(--md-sys-color-outline)', background: 'var(--md-sys-color-surface-container-low)', color: 'var(--md-sys-color-on-surface)', fontSize: 16, letterSpacing: 4, }} />
) : (

Отправьте эту ссылку ученику. Он сможет зарегистрироваться, указав только имя и фамилию.

{invitationLink ? (
{invitationLink}
) : ( )}
)} {addMode !== 'link' && ( )} )} {currentUser?.universal_code && (
Ваш 8-символьный код
{(currentUser.universal_code || '').split('').map((char, i) => ( {char} ))}
Поделитесь кодом с учеником — он сможет отправить вам запрос на связь
)}
) : (
Предмет: setSelectedSubject(v)} disabled={!progress || !progress.subjects.length} placeholder="Выберите предмет" />
Период: { if (v.start_date) setStartDate(dayjs(v.start_date)); else setStartDate(null); if (v.end_date) setEndDate(dayjs(v.end_date)); else setEndDate(null); }} disabled={false} />
setPeriodAnchor(null)} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} transformOrigin={{ vertical: 'top', horizontal: 'left' }} slotProps={{ paper: { sx: { mt: 1, borderRadius: 12, minWidth: 300, maxWidth: 360, overflow: 'hidden', }, }, }} >
Выберите период
{localStart && localEnd ? `${localStart.format('D MMM')} — ${localEnd.format('D MMM')}` : '—'}
{rangeMode === 'start' ? 'Клик: начало периода' : 'Клик: конец периода'}
{[ { label: 'Эта неделя', fn: () => { setLocalStart(dayjs().startOf('isoWeek')); setLocalEnd(dayjs().endOf('isoWeek')); setRangeMode('end'); } }, { label: 'Прошлая неделя', fn: () => { const w = dayjs().subtract(1, 'week'); setLocalStart(w.startOf('isoWeek')); setLocalEnd(w.endOf('isoWeek')); setRangeMode('end'); } }, { label: 'Последние 7 дней', fn: () => { setLocalStart(dayjs().subtract(6, 'day')); setLocalEnd(dayjs()); setRangeMode('end'); } }, { label: 'Текущий месяц', fn: () => { setLocalStart(dayjs().startOf('month')); setLocalEnd(dayjs().endOf('month')); setRangeMode('end'); } }, { label: 'След. месяц', fn: () => { const m = dayjs().add(1, 'month'); setLocalStart(m.startOf('month')); setLocalEnd(m.endOf('month')); setRangeMode('end'); } }, { label: 'Сбросить', fn: () => { setLocalStart(null); setLocalEnd(null); setRangeMode('start'); } }, ].map(({ label, fn }) => ( ))}
{ const d = val ? dayjs(val as Date) : null; if (!d) return; if (rangeMode === 'start') { setLocalStart(d); setLocalEnd(null); setRangeMode('end'); } else { if (localStart && d.isBefore(localStart, 'day')) { setLocalStart(d); setLocalEnd(localStart); } else { setLocalEnd(d); } } }} slots={{ day: (props: PickersDayProps) => { const { day, selected, sx, ...rest } = props; const d = dayjs(day as Date); const start = localStart ? dayjs(localStart).startOf('day') : null; const end = localEnd ? dayjs(localEnd).startOf('day') : null; const isStart = start && d.isSame(start, 'day'); const isEnd = end && d.isSame(end, 'day'); const inBetween = start && end && !isStart && !isEnd && d.isAfter(start) && d.isBefore(end); const inRange = isStart || isEnd || inBetween; return ( ); }, }} sx={{ width: '100%', '& .MuiPickersCalendarHeader-root': { marginBottom: 0 } }} />
{(lessonsLoading && lessons.length === 0) ? (
) : startStr && endStr ? ( <>
Занятий проведено
{periodStats.completedLessons}
из {periodStats.totalLessons}
Посещаемость
{periodStats.attendanceRate}%
Средняя оценка
{periodStats.avgGrade || '—'}
ДЗ с оценкой
{periodStats.hwGraded}
Успеваемость (репетитор и школа)
{gradesChart.categories.length === 0 ? (
Нет оценок за период
) : ( )}
Успеваемость по ДЗ
{homeworkChart.categories.length === 0 ? (
Нет оценок за ДЗ за период
) : ( )}
Посещаемость
{attendanceChart.categories.length === 0 ? (
Нет проведённых занятий за период
) : ( )}
) : (
Выберите период
)}
)}
)}
); }