1728 lines
74 KiB
TypeScript
1728 lines
74 KiB
TypeScript
'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
|
||
style={{
|
||
padding: '24px',
|
||
minHeight: '100vh',
|
||
}}
|
||
>
|
||
{/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
|
||
{(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
gap: 4,
|
||
marginBottom: 24,
|
||
borderBottom: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
|
||
paddingBottom: 0,
|
||
}}
|
||
>
|
||
<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
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
|
||
gap: '16px',
|
||
alignItems: 'start',
|
||
}}
|
||
>
|
||
{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
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
|
||
gap: '16px',
|
||
alignItems: 'start',
|
||
}}
|
||
>
|
||
{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>
|
||
) : filteredStudents.length === 0 ? (
|
||
<md-elevated-card style={{
|
||
padding: '40px',
|
||
borderRadius: '20px',
|
||
textAlign: 'center'
|
||
}}>
|
||
<p style={{
|
||
fontSize: '16px',
|
||
color: 'var(--md-sys-color-on-surface-variant)'
|
||
}}>
|
||
Нет студентов
|
||
</p>
|
||
</md-elevated-card>
|
||
) : (
|
||
<div
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
|
||
gap: '16px',
|
||
alignItems: 'start',
|
||
}}
|
||
>
|
||
{(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
|
||
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: 300,
|
||
maxWidth: 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>
|
||
);
|
||
}
|