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

1698 lines
72 KiB
TypeScript
Raw 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 { 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<any | null>(null);
const [startDate, setStartDate] = useState<Dayjs | null>(null);
const [endDate, setEndDate] = useState<Dayjs | null>(null);
const [selectedSubject, setSelectedSubject] = useState<string | null>(null);
const [progress, setProgress] = useState<StudentProgressResponse | null>(null);
const [progressLoading, setProgressLoading] = useState(false);
const [periodAnchor, setPeriodAnchor] = useState<HTMLElement | null>(null);
const [panelExpanded, setPanelExpanded] = useState(false);
const [localStart, setLocalStart] = useState<Dayjs | null>(null);
const [localEnd, setLocalEnd] = useState<Dayjs | null>(null);
const [rangeMode, setRangeMode] = useState<'start' | 'end'>('start');
const [lessons, setLessons] = useState<Lesson[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(false);
const [homeworkSubmissions, setHomeworkSubmissions] = useState<HomeworkSubmission[]>([]);
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<string | null>(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<MentorshipRequestItem[]>([]);
const [requestsLoading, setRequestsLoading] = useState(false);
const [requestActionId, setRequestActionId] = useState<number | null>(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<StudentProgressResponse>(`/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<string, { mentor: number | null; school: number | null }> = {};
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<string, number | null> = {};
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<string, number> = {};
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 (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '50vh'
}}>
<div>Загрузка...</div>
</div>
);
}
const students = studentsData?.results || [];
const pendingInvitations = (studentsData as any)?.pending_invitations || [];
const filteredStudents = students;
return (
<div
className="page-students"
style={{
padding: '24px',
}}
>
{/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
{(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
<div
className="students-tabs"
style={{
display: 'flex',
gap: 4,
marginBottom: 24,
borderBottom: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
paddingBottom: 0,
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<button
type="button"
onClick={() => setActiveTab('students')}
style={{
padding: '12px 20px',
border: 'none',
borderBottom: activeTab === 'students' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
background: 'transparent',
color: activeTab === 'students' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
}}
>
Студенты
</button>
{mentorshipRequests.length > 0 && (
<button
type="button"
onClick={() => setActiveTab('requests')}
style={{
padding: '12px 20px',
border: 'none',
borderBottom: activeTab === 'requests' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
background: 'transparent',
color: activeTab === 'requests' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
}}
>
Запросы на менторство ({mentorshipRequests.length})
</button>
)}
{pendingInvitations.length > 0 && (
<button
type="button"
onClick={() => setActiveTab('awaiting')}
style={{
padding: '12px 20px',
border: 'none',
borderBottom: activeTab === 'awaiting' ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent',
background: 'transparent',
color: activeTab === 'awaiting' ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
}}
>
Ожидают ответа ({pendingInvitations.length})
</button>
)}
</div>
)}
{activeTab === 'awaiting' ? (
pendingInvitations.length === 0 ? (
<md-elevated-card style={{ padding: 40, borderRadius: 20, textAlign: 'center' }}>
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
Нет ожидающих подтверждения
</p>
</md-elevated-card>
) : (
<div className="students-cards-grid">
{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 (
<md-elevated-card
key={`inv-${inv.id}`}
style={{
borderRadius: '24px',
overflow: 'hidden',
background: 'var(--md-sys-color-surface-container-low)',
boxShadow: '0 8px 24px rgba(0,0,0,0.06)',
border: '2px dashed var(--md-sys-color-outline-variant)',
}}
>
<div
style={{
height: '300px',
width: '100%',
background: 'linear-gradient(135deg, rgba(116,68,253,0.12), rgba(255,255,255,0.5))',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
style={{
fontSize: 28,
fontWeight: 700,
color: 'var(--md-sys-color-primary)',
textAlign: 'center',
}}
>
{initials}
</span>
</div>
<div style={{ padding: '14px 14px 16px' }}>
<div style={{ fontSize: 18, fontWeight: 700, color: 'var(--md-sys-color-on-surface)', marginBottom: 6 }}>
{title}
</div>
<div style={{ fontSize: 15, color: 'var(--md-sys-color-on-surface-variant)' }}>
{st.email || '—'}
</div>
</div>
</md-elevated-card>
);
})}
</div>
)
) : activeTab === 'requests' ? (
<div style={{ padding: '0 0 40px' }}>
{requestsLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div>Загрузка запросов...</div>
</div>
) : mentorshipRequests.length === 0 ? (
<md-elevated-card
style={{
padding: 40,
borderRadius: 20,
textAlign: 'center',
}}
>
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
Нет новых запросов на менторство
</p>
</md-elevated-card>
) : (
<div className="students-cards-grid">
{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 (
<md-elevated-card
key={req.id}
style={{
borderRadius: '24px',
overflow: 'hidden',
background: 'var(--md-sys-color-surface)',
boxShadow: '0 8px 24px rgba(0,0,0,0.08)',
}}
>
<div
style={{
height: '300px',
width: '100%',
background: 'linear-gradient(135deg, rgba(116,68,253,0.18), rgba(255,255,255,0.5))',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: 80,
height: 80,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(103, 80, 164, 0.18)',
color: 'var(--md-sys-color-primary)',
fontSize: 32,
fontWeight: 700,
overflow: 'hidden',
flexShrink: 0,
}}
>
{initials}
</div>
</div>
<div style={{ padding: '14px 14px 16px' }}>
<div
style={{
fontSize: '18px',
fontWeight: 700,
color: 'var(--md-sys-color-on-surface)',
lineHeight: 1.2,
marginBottom: 6,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
}}
>
{title}
</div>
<div
style={{
fontSize: '15px',
color: 'var(--md-sys-color-on-surface-variant)',
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
}}
>
{st.email || '—'}
</div>
{req.created_at && (
<div style={{ fontSize: 12, color: 'var(--md-sys-color-outline)', marginTop: 6 }}>
{format(new Date(req.created_at), 'd MMM yyyy, HH:mm', { locale: ru })}
</div>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
disabled={requestActionId === req.id}
onClick={async (e) => {
e.stopPropagation();
setRequestActionId(req.id);
try {
await acceptMentorshipRequest(req.id);
setMentorshipRequests((prev) => {
const next = prev.filter((r) => r.id !== req.id);
if (next.length === 0) setActiveTab('students');
return next;
});
refetch?.();
} finally {
setRequestActionId(null);
}
}}
style={{
padding: '8px 14px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 14,
fontWeight: 600,
cursor: requestActionId === req.id ? 'not-allowed' : 'pointer',
}}
>
{requestActionId === req.id ? '…' : 'Принять'}
</button>
<button
type="button"
disabled={requestActionId === req.id}
onClick={async (e) => {
e.stopPropagation();
setRequestActionId(req.id);
try {
await rejectMentorshipRequest(req.id);
setMentorshipRequests((prev) => {
const next = prev.filter((r) => r.id !== req.id);
if (next.length === 0) setActiveTab('students');
return next;
});
} finally {
setRequestActionId(null);
}
}}
style={{
padding: '8px 14px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
fontWeight: 600,
cursor: requestActionId === req.id ? 'not-allowed' : 'pointer',
}}
>
{requestActionId === req.id ? '…' : 'Отклонить'}
</button>
</div>
</div>
</md-elevated-card>
);
})}
</div>
)}
</div>
) : loading ? (
<div style={{
display: 'flex',
justifyContent: 'center',
padding: '40px'
}}>
<div>Загрузка студентов...</div>
</div>
) : (
<div className="students-cards-grid">
{(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 (
<md-elevated-card
key={student.id}
style={{
borderRadius: '24px',
overflow: 'hidden',
cursor: 'pointer',
background: 'var(--md-sys-color-surface)',
boxShadow: '0 8px 24px rgba(0,0,0,0.08)',
}}
onClick={() => {
setSelectedStudent(student);
const defaultStart = dayjs().subtract(3, 'month');
const defaultEnd = dayjs();
setStartDate(defaultStart);
setEndDate(defaultEnd);
}}
>
<div
style={{
height: '300px',
width: '100%',
background: avatarUrl
? 'transparent'
: 'linear-gradient(135deg, rgba(116,68,253,0.18), rgba(255,255,255,0.5))',
position: 'relative',
}}
>
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={avatarUrl}
alt={title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: 'block',
}}
/>
) : (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-primary)',
fontWeight: 700,
fontSize: 28,
}}
>
{initials}
</div>
)}
</div>
<div style={{ padding: '14px 14px 16px' }}>
<div
style={{
fontSize: '18px',
fontWeight: 700,
color: 'var(--md-sys-color-on-surface)',
lineHeight: 1.2,
marginBottom: 6,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
}}
>
{title}
</div>
<div
style={{
fontSize: '18px',
color: 'var(--md-sys-color-on-surface-variant)',
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
}}
>
{subtitle}
</div>
{student?.user?.login_link && (
<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button
type="button"
onClick={async (e) => {
e.stopPropagation();
await navigator.clipboard.writeText(student.user.login_link);
showToast('Ссылка для входа скопирована', 'success');
}}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-primary)',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
}}
>
Копировать вход
</button>
</div>
)}
</div>
</md-elevated-card>
);
})}
{/* Карточка-действие «Добавить студента» — только во вкладке Студенты */}
{activeTab === 'students' && (
<md-elevated-card
style={{
borderRadius: '24px',
overflow: 'hidden',
cursor: 'pointer',
background: 'var(--md-sys-color-surface-container-low)',
boxShadow: '0 8px 24px rgba(0,0,0,0.06)',
}}
onClick={() => {
setShowAddPanel(true);
setSelectedStudent(null);
setAddSubmitted(false);
setAddError('');
setAddEmail('');
setAddCode('');
setAddCheckResult(null);
}}
>
<div
style={{
height: '300px',
width: '100%',
background:
'linear-gradient(135deg, rgba(116,68,253,0.18), rgba(255,255,255,0.5))',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
width: 80,
height: 80,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'rgba(103, 80, 164, 0.18)',
color: 'var(--md-sys-color-primary)',
fontSize: 40,
fontWeight: 600,
}}
>
+
</div>
</div>
<div style={{ padding: '14px 14px 16px', textAlign: 'center' }}>
<div
style={{
fontSize: 18,
fontWeight: 700,
color: 'var(--md-sys-color-on-surface)',
marginBottom: 6,
}}
>
Добавить студента
</div>
<div
style={{
fontSize: 15,
color: 'var(--md-sys-color-on-surface-variant)',
lineHeight: 1.2,
}}
>
Создайте карточку нового ученика
</div>
</div>
</md-elevated-card>
)}
</div>
)}
{(selectedStudent || showAddPanel) && (
<div
className="students-side-panel"
style={{
position: 'fixed',
top: 0,
right: 0,
height: '100vh',
width: panelExpanded ? '80vw' : '420px',
padding: '20px 20px 24px',
background: 'var(--md-sys-color-surface)',
boxShadow: '0 0 24px rgba(0,0,0,0.18)',
borderTopLeftRadius: 24,
borderBottomLeftRadius: 24,
display: 'flex',
flexDirection: 'column',
gap: 16,
zIndex: 40,
transition: 'width 0.25s ease',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<div
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--md-sys-color-on-surface)',
marginBottom: 4,
}}
>
{showAddPanel ? 'Добавить студента' : 'Прогресс ученика'}
</div>
<div
style={{
fontSize: 16,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
{showAddPanel ? 'Введите email или 8-символьный код' : `${selectedStudent?.user?.first_name || ''} ${selectedStudent?.user?.last_name || ''}`.trim()}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{!showAddPanel && (
<button
type="button"
onClick={() => setPanelExpanded((v) => !v)}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
}}
title={panelExpanded ? 'Свернуть панель' : 'Раскрыть панель (80% ширины)'}
>
{panelExpanded ? (
<KeyboardArrowRight sx={{ fontSize: 24 }} />
) : (
<KeyboardArrowLeft sx={{ fontSize: 24 }} />
)}
</button>
)}
<button
type="button"
onClick={() => {
setSelectedStudent(null);
setShowAddPanel(false);
setPanelExpanded(false);
}}
style={{
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 18,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
</button>
</div>
</div>
{showAddPanel ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{addSubmitted ? (
<div
style={{
padding: 20,
borderRadius: 12,
background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-on-primary-container)',
fontSize: 16,
}}
>
Отправлено. Ждём подтверждения от ученика{addCheckResult?.exists === false ? ' (и установки пароля по ссылке из письма)' : ''}. После подтверждения учеником и при необходимости родителем взаимодействие будет разрешено.
</div>
) : (
<>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<button
type="button"
onClick={() => { setAddMode('link'); setAddCheckResult(null); setAddError(''); }}
style={{
padding: '8px 14px',
borderRadius: 8,
border: addMode === 'link' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
background: addMode === 'link' ? 'var(--md-sys-color-primary-container)' : 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
По ссылке
</button>
<button
type="button"
onClick={() => { setAddMode('email'); setAddCheckResult(null); setAddError(''); }}
style={{
padding: '8px 14px',
borderRadius: 8,
border: addMode === 'email' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
background: addMode === 'email' ? 'var(--md-sys-color-primary-container)' : 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
По email
</button>
<button
type="button"
onClick={() => { setAddMode('code'); setAddCheckResult(null); setAddError(''); }}
style={{
padding: '8px 14px',
borderRadius: 8,
border: addMode === 'code' ? '2px solid var(--md-sys-color-primary)' : '1px solid var(--md-sys-color-outline)',
background: addMode === 'code' ? 'var(--md-sys-color-primary-container)' : 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
8-символьный код
</button>
</div>
{addMode === 'email' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
Email ученика (если не зарегистрирован пришлём приглашение на почту)
</label>
<input
type="email"
value={addEmail}
onChange={(e) => { 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 && (
<p style={{ fontSize: 13, color: 'var(--md-sys-color-primary)' }}>
Ученик уже зарегистрирован. Введите его 8-символьный универсальный код (в личном кабинете ученика).
</p>
)}
</div>
) : addMode === 'code' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
Универсальный код ученика (8 символов: цифры и латинские буквы)
</label>
<input
type="text"
inputMode="text"
maxLength={8}
value={addCode}
onChange={(e) => {
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,
}}
/>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
Отправьте эту ссылку ученику. Он сможет зарегистрироваться, указав только имя и фамилию.
</p>
{invitationLink ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div
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: 14,
wordBreak: 'break-all',
fontFamily: 'monospace'
}}
>
{invitationLink}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={async () => {
await navigator.clipboard.writeText(invitationLink);
setLinkCopied(true);
showToast('Ссылка скопирована', 'success');
setTimeout(() => setLinkCopied(false), 2000);
}}
style={{
flex: 1,
padding: '10px',
borderRadius: 12,
border: 'none',
background: linkCopied ? 'var(--md-sys-color-tertiary)' : 'var(--md-sys-color-secondary)',
color: 'var(--md-sys-color-on-secondary)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
{linkCopied ? 'Скопировано!' : 'Копировать'}
</button>
<button
type="button"
disabled={addSubmitting}
onClick={async () => {
setAddSubmitting(true);
try {
const res = await generateInvitationLink();
setInvitationLink(res.invitation_link);
await navigator.clipboard.writeText(res.invitation_link);
setLinkCopied(true);
showToast('Ссылка обновлена и скопирована', 'success');
setTimeout(() => setLinkCopied(false), 2000);
} catch (err: any) {
setAddError(getErrorMessage(err, 'Ошибка обновления ссылки'));
} finally {
setAddSubmitting(false);
}
}}
style={{
flex: 1,
padding: '10px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'transparent',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
{addSubmitting ? '…' : 'Обновить'}
</button>
</div>
</div>
) : (
<button
type="button"
disabled={addSubmitting}
onClick={async () => {
setAddSubmitting(true);
try {
const res = await generateInvitationLink();
setInvitationLink(res.invitation_link);
} catch (err: any) {
setAddError(getErrorMessage(err, 'Ошибка создания ссылки'));
} finally {
setAddSubmitting(false);
}
}}
style={{
padding: '12px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 16,
fontWeight: 600,
cursor: 'pointer',
}}
>
{addSubmitting ? 'Создание…' : 'Создать ссылку'}
</button>
)}
</div>
)}
{addMode !== 'link' && (
<button
type="button"
disabled={addSubmitting || (addMode === 'email' ? !addEmail.trim() : addCode.length !== 8)}
onClick={async () => {
setAddError('');
setAddSubmitting(true);
try {
if (addMode === 'email') {
await addStudentInvitation({ email: addEmail.trim().toLowerCase() });
} else {
await addStudentInvitation({ universal_code: addCode });
}
setAddSubmitted(true);
setAddEmail('');
setAddCode('');
setAddCheckResult(null);
} catch (err: any) {
setAddError(getErrorMessage(err, 'Ошибка отправки приглашения'));
} finally {
setAddSubmitting(false);
}
}}
style={{
padding: '12px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 16,
fontWeight: 600,
cursor: addSubmitting ? 'not-allowed' : 'pointer',
opacity: (addSubmitting || (addMode === 'email' ? !addEmail.trim() : addCode.length !== 8)) ? 0.6 : 1,
}}
>
{addSubmitting ? 'Отправка…' : 'Отправить приглашение'}
</button>
)}
</>
)}
{currentUser?.universal_code && (
<div
style={{
marginTop: 24,
padding: 16,
borderRadius: 12,
background: 'var(--md-sys-color-primary)',
}}
>
<div style={{ fontSize: 13, color: '#fff', marginBottom: 8 }}>
Ваш 8-символьный код
</div>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 8 }}>
{(currentUser.universal_code || '').split('').map((char, i) => (
<span
key={i}
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '8px 10px',
border: '1px solid #fff',
borderRadius: 6,
fontSize: 18,
fontWeight: 700,
color: '#fff',
}}
>
{char}
</span>
))}
</div>
<div style={{ fontSize: 12, color: '#fff', opacity: 0.9 }}>
Поделитесь кодом с учеником он сможет отправить вам запрос на связь
</div>
</div>
)}
</div>
) : (
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16, minHeight: 0 }}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
gap: 8,
fontSize: 13,
color: 'var(--md-sys-color-on-surface-variant)',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<span style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>Предмет:</span>
<SubjectNameSelect
options={progress?.subjects ?? []}
value={selectedSubject ?? (progress?.subjects[0]?.subject ?? null)}
onChange={(v) => setSelectedSubject(v)}
disabled={!progress || !progress.subjects.length}
placeholder="Выберите предмет"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span>Период:</span>
<DateRangePicker
value={{
start_date: startDate?.format('YYYY-MM-DD') ?? '',
end_date: endDate?.format('YYYY-MM-DD') ?? '',
}}
onChange={(v) => {
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}
/>
<button
type="button"
onClick={(e) => setPeriodAnchor(e.currentTarget)}
style={{
padding: '8px 12px',
borderRadius: 12,
border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
background: 'var(--md-sys-color-surface-container-low)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
cursor: 'pointer',
outline: 'none',
}}
>
Быстрый выбор
</button>
</div>
</div>
</LocalizationProvider>
<Popover
open={Boolean(periodAnchor)}
anchorEl={periodAnchor}
onClose={() => setPeriodAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
mt: 1,
borderRadius: 12,
minWidth: { xs: 'calc(100vw - 32px)', sm: 300 },
maxWidth: { xs: 'calc(100vw - 32px)', sm: 360 },
overflow: 'hidden',
},
},
}}
>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="ru">
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 16,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--md-sys-color-primary)',
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}
>
Выберите период
</div>
<div
style={{
fontSize: 15,
color: 'var(--md-sys-color-on-surface)',
marginBottom: 2,
}}
>
{localStart && localEnd
? `${localStart.format('D MMM')}${localEnd.format('D MMM')}`
: '—'}
</div>
<div
style={{
fontSize: 12,
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 8,
}}
>
{rangeMode === 'start' ? 'Клик: начало периода' : 'Клик: конец периода'}
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
}}
>
{[
{ 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 }) => (
<button
key={label}
type="button"
onClick={fn}
style={{
padding: '6px 10px',
borderRadius: 8,
border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
background: 'var(--md-sys-color-surface-container-low)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 13,
cursor: 'pointer',
outline: 'none',
}}
>
{label}
</button>
))}
</div>
<DateCalendar
value={rangeMode === 'start' ? (localStart ?? localEnd ?? dayjs()) : (localEnd ?? localStart ?? dayjs())}
onChange={(val) => {
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 (
<PickersDay
{...rest}
day={day}
selected={selected || !!inRange}
sx={[
...(Array.isArray(sx) ? sx : sx != null ? [sx] : []),
...(inBetween ? [{
bgcolor: 'rgba(103, 80, 164, 0.16)',
borderRadius: 0,
'&:hover': { bgcolor: 'rgba(103, 80, 164, 0.24)' },
}] : []),
...(isStart ? [{
borderTopLeftRadius: '50%',
borderBottomLeftRadius: '50%',
bgcolor: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
'&:hover': { bgcolor: 'var(--md-sys-color-primary)', filter: 'brightness(1.1)' },
}] : []),
...(isEnd ? [{
borderTopRightRadius: '50%',
borderBottomRightRadius: '50%',
bgcolor: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
'&:hover': { bgcolor: 'var(--md-sys-color-primary)', filter: 'brightness(1.1)' },
}] : []),
]}
/>
);
},
}}
sx={{ width: '100%', '& .MuiPickersCalendarHeader-root': { marginBottom: 0 } }}
/>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
paddingTop: 8,
borderTop: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.08))',
}}
>
<button
type="button"
onClick={() => setPeriodAnchor(null)}
style={{
padding: '8px 14px',
borderRadius: 8,
border: 'none',
background: 'transparent',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
cursor: 'pointer',
}}
>
Отмена
</button>
<button
type="button"
onClick={() => {
setStartDate(localStart);
setEndDate(localEnd);
setPeriodAnchor(null);
}}
style={{
padding: '8px 14px',
borderRadius: 8,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
}}
>
Применить
</button>
</div>
</div>
</LocalizationProvider>
</Popover>
{(lessonsLoading && lessons.length === 0) ? (
<div style={{ minHeight: 120, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<LoadingSpinner size="medium" />
</div>
) : startStr && endStr ? (
<>
<div className="ios26-stat-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 }}>
<div className="ios26-stat-tile" style={{ padding: 12 }}>
<div className="ios26-stat-label">Занятий проведено</div>
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.completedLessons}</div>
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)' }}>из {periodStats.totalLessons}</div>
</div>
<div className="ios26-stat-tile" style={{ padding: 12 }}>
<div className="ios26-stat-label">Посещаемость</div>
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.attendanceRate}%</div>
</div>
<div className="ios26-stat-tile" style={{ padding: 12 }}>
<div className="ios26-stat-label">Средняя оценка</div>
<div className="ios26-stat-value">{periodStats.avgGrade || '—'}</div>
</div>
<div className="ios26-stat-tile" style={{ padding: 12 }}>
<div className="ios26-stat-label">ДЗ с оценкой</div>
<div className="ios26-stat-value">{periodStats.hwGraded}</div>
</div>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--md-sys-color-on-surface)', marginBottom: 8 }}>Успеваемость (репетитор и школа)</div>
{gradesChart.categories.length === 0 ? (
<div style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 13 }}>Нет оценок за период</div>
) : (
<ApexChart
type="line"
height={180}
series={gradesChart.series}
options={({
...chartOptionsBase,
xaxis: { ...chartOptionsBase.xaxis, categories: gradesChart.categories },
yaxis: { ...chartOptionsBase.yaxis, min: 1, max: 5 },
} as unknown) as ApexOptions}
/>
)}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--md-sys-color-on-surface)', marginBottom: 8 }}>Успеваемость по ДЗ</div>
{homeworkChart.categories.length === 0 ? (
<div style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 13 }}>Нет оценок за ДЗ за период</div>
) : (
<ApexChart
type="line"
height={180}
series={homeworkChart.series}
options={({
...chartOptionsBase,
colors: ['#6750A4'],
xaxis: { ...chartOptionsBase.xaxis, categories: homeworkChart.categories },
yaxis: { ...chartOptionsBase.yaxis, min: 0, max: 100 },
} as unknown) as ApexOptions}
/>
)}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--md-sys-color-on-surface)', marginBottom: 8 }}>Посещаемость</div>
{attendanceChart.categories.length === 0 ? (
<div style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 13 }}>Нет проведённых занятий за период</div>
) : (
<ApexChart
type="line"
height={180}
series={attendanceChart.series}
options={({
...chartOptionsBase,
colors: ['#6750A4'],
xaxis: { ...chartOptionsBase.xaxis, categories: attendanceChart.categories },
yaxis: { ...chartOptionsBase.yaxis, min: 0 },
} as unknown) as ApexOptions}
/>
)}
</div>
</>
) : (
<div style={{ padding: 24, textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
Выберите период
</div>
)}
</div>
)}
</div>
)}
</div>
);
}