427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState, useMemo } from 'react';
|
||
import { getLessons, type Lesson } from '@/api/schedule';
|
||
import { getHomeworkSubmissionsBySubject, type HomeworkSubmission } from '@/api/homework';
|
||
import { format, subMonths, startOfDay, endOfDay, addDays } from 'date-fns';
|
||
import dayjs from 'dayjs';
|
||
import dynamic from 'next/dynamic';
|
||
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
||
import { DashboardLayout, Panel, SectionHeader } from '@/components/dashboard/ui';
|
||
import { DateRangePicker } from '@/components/common/DateRangePicker';
|
||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||
|
||
const Chart = dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
|
||
ssr: false,
|
||
loading: () => (
|
||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||
<LoadingSpinner size="medium" />
|
||
</div>
|
||
),
|
||
});
|
||
|
||
const CHART_COLORS = ['#6750A4', '#7D5260'];
|
||
|
||
const defaultRange = {
|
||
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
|
||
end_date: dayjs().format('YYYY-MM-DD'),
|
||
};
|
||
|
||
function getSubjectFromLesson(lesson: Lesson): string {
|
||
if (typeof lesson.subject === 'string' && lesson.subject?.trim()) return lesson.subject.trim();
|
||
return '';
|
||
}
|
||
|
||
/** Все даты в диапазоне [startStr, endStr] включительно (формат YYYY-MM-DD) */
|
||
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;
|
||
}
|
||
|
||
export default function MyProgressPage() {
|
||
const { selectedChild } = useSelectedChild();
|
||
const [dateRangeValue, setDateRangeValue] = useState<{ start_date: string; end_date: string }>(() => defaultRange);
|
||
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
||
|
||
const dateRange = useMemo(() => ({
|
||
start: startOfDay(new Date(dateRangeValue.start_date)),
|
||
end: endOfDay(new Date(dateRangeValue.end_date)),
|
||
}), [dateRangeValue.start_date, dateRangeValue.end_date]);
|
||
|
||
const startStr = format(dateRange.start, 'yyyy-MM-dd');
|
||
const endStr = format(dateRange.end, 'yyyy-MM-dd');
|
||
|
||
const [subjectsFromLessons, setSubjectsFromLessons] = useState<string[]>([]);
|
||
const [subjectsLoading, setSubjectsLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setSubjectsLoading(true);
|
||
getLessons({
|
||
start_date: format(subMonths(new Date(), 24), 'yyyy-MM-dd'),
|
||
end_date: format(new Date(), 'yyyy-MM-dd'),
|
||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||
})
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
const set = new Set<string>();
|
||
(res.results || []).forEach((l: Lesson) => {
|
||
const sub = getSubjectFromLesson(l);
|
||
if (sub) set.add(sub);
|
||
});
|
||
const list = Array.from(set).sort();
|
||
setSubjectsFromLessons(list);
|
||
if (list.length > 0 && !selectedSubject) setSelectedSubject(list[0]);
|
||
})
|
||
.catch(() => { if (!cancelled) setSubjectsFromLessons([]); })
|
||
.finally(() => { if (!cancelled) setSubjectsLoading(false); });
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (subjectsFromLessons.length > 0 && !selectedSubject) setSelectedSubject(subjectsFromLessons[0]);
|
||
}, [subjectsFromLessons, selectedSubject]);
|
||
|
||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||
const [lessonsLoading, setLessonsLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!startStr || !endStr) return;
|
||
let cancelled = false;
|
||
setLessonsLoading(true);
|
||
getLessons({
|
||
start_date: startStr,
|
||
end_date: endStr,
|
||
...(selectedChild?.id && { child_id: selectedChild.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; };
|
||
}, [startStr, endStr, selectedSubject, selectedChild?.id]);
|
||
|
||
const [homeworkSubmissions, setHomeworkSubmissions] = useState<HomeworkSubmission[]>([]);
|
||
const [homeworkLoading, setHomeworkLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!selectedSubject) {
|
||
setHomeworkSubmissions([]);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setHomeworkLoading(true);
|
||
getHomeworkSubmissionsBySubject({
|
||
subject: selectedSubject,
|
||
start_date: startStr,
|
||
end_date: endStr,
|
||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||
})
|
||
.then((res) => {
|
||
if (cancelled) return;
|
||
setHomeworkSubmissions(res.results || []);
|
||
})
|
||
.catch(() => { if (!cancelled) setHomeworkSubmissions([]); })
|
||
.finally(() => { if (!cancelled) setHomeworkLoading(false); });
|
||
return () => { cancelled = true; };
|
||
}, [selectedSubject, startStr, endStr, selectedChild?.id]);
|
||
|
||
const periodStats = useMemo(() => {
|
||
const completed = lessons.filter((l) => l.status === 'completed').length;
|
||
const total = lessons.length;
|
||
const cancelled = lessons.filter((l) => l.status === 'cancelled').length;
|
||
const attendanceRate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||
|
||
const withGrades = lessons.filter((l) => l.status === 'completed' && (l.mentor_grade != null || l.school_grade != null));
|
||
let sum = 0;
|
||
let count = 0;
|
||
withGrades.forEach((l) => {
|
||
if (l.mentor_grade != null) { sum += l.mentor_grade; count++; }
|
||
if (l.school_grade != null) { sum += l.school_grade; 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,
|
||
cancelled,
|
||
avgGrade,
|
||
hwGraded,
|
||
};
|
||
}, [lessons, homeworkSubmissions]);
|
||
|
||
const gradesChart = useMemo(() => {
|
||
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.mentor_grade != null || l.school_grade != null))
|
||
.forEach((l) => {
|
||
const key = l.start_time?.slice(0, 10) || '';
|
||
if (!key) return;
|
||
byDate[key] = { mentor: l.mentor_grade ?? null, school: l.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(() => {
|
||
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]);
|
||
|
||
// Посещаемость: по датам — сколько занятий было проведено; ось X = все даты периода
|
||
const attendanceChart = useMemo(() => {
|
||
const allDates = getDatesInRange(startStr, endStr);
|
||
const categories = allDates.map((d) => {
|
||
const [, m, day] = d.split('-');
|
||
return `${day}.${m}`;
|
||
});
|
||
const byDate: Record<string, number> = {};
|
||
lessons.forEach((l) => {
|
||
if (l.status !== 'completed') return;
|
||
const key = l.start_time?.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 subjects = subjectsFromLessons;
|
||
const loading = lessonsLoading && lessons.length === 0;
|
||
|
||
const chartOptionsBase = useMemo(
|
||
() => ({
|
||
chart: {
|
||
toolbar: {
|
||
show: true,
|
||
tools: {
|
||
download: true,
|
||
zoom: true,
|
||
zoomin: true,
|
||
zoomout: true,
|
||
pan: true,
|
||
reset: true,
|
||
},
|
||
},
|
||
zoom: { enabled: true, type: 'x' as const, allowMouseWheelZoom: true },
|
||
pan: { enabled: true, type: 'x' as const },
|
||
selection: { enabled: true, type: 'x' as const },
|
||
},
|
||
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: '12px' } },
|
||
},
|
||
yaxis: {
|
||
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
|
||
},
|
||
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 },
|
||
}),
|
||
[],
|
||
);
|
||
|
||
const selectStyle = {
|
||
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)',
|
||
minWidth: 180,
|
||
fontSize: 14,
|
||
cursor: 'pointer' as const,
|
||
outline: 'none' as const,
|
||
};
|
||
|
||
return (
|
||
<div style={{ width: '100%' }}>
|
||
<DashboardLayout className="ios26-dashboard-grid">
|
||
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
|
||
<Panel padding="md">
|
||
<SectionHeader
|
||
title="Прогресс за период"
|
||
trailing={
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center' }}>
|
||
<select
|
||
value={selectedSubject}
|
||
onChange={(e) => setSelectedSubject(e.target.value)}
|
||
disabled={!subjects.length}
|
||
style={{
|
||
...selectStyle,
|
||
opacity: subjects.length ? 1 : 0.7,
|
||
}}
|
||
>
|
||
{subjects.length === 0 ? (
|
||
<option value="">Нет предметов</option>
|
||
) : (
|
||
subjects.map((s) => (
|
||
<option key={s} value={s}>{s}</option>
|
||
))
|
||
)}
|
||
</select>
|
||
<DateRangePicker
|
||
value={dateRangeValue}
|
||
onChange={(v) => setDateRangeValue({ start_date: v.start_date, end_date: v.end_date })}
|
||
disabled={subjectsLoading}
|
||
/>
|
||
</div>
|
||
}
|
||
/>
|
||
{loading ? (
|
||
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<LoadingSpinner size="medium" />
|
||
</div>
|
||
) : (
|
||
<div className="ios26-stat-grid my-progress-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Занятий проведено</div>
|
||
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.completedLessons}</div>
|
||
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>из {periodStats.totalLessons}</div>
|
||
</div>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Посещаемость</div>
|
||
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.attendanceRate}%</div>
|
||
</div>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">Средняя оценка</div>
|
||
<div className="ios26-stat-value">{periodStats.avgGrade || '—'}</div>
|
||
</div>
|
||
<div className="ios26-stat-tile">
|
||
<div className="ios26-stat-label">ДЗ с оценкой</div>
|
||
<div className="ios26-stat-value">{periodStats.hwGraded}</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Panel>
|
||
|
||
{/* Ячейка 2: Успеваемость (оценки репетитора и школы) */}
|
||
<Panel padding="md">
|
||
<SectionHeader title="Успеваемость (репетитор и школа)" />
|
||
{gradesChart.categories.length === 0 ? (
|
||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||
Нет оценок за период
|
||
</div>
|
||
) : (
|
||
<Chart
|
||
type="line"
|
||
height={260}
|
||
series={gradesChart.series}
|
||
options={{
|
||
...chartOptionsBase,
|
||
xaxis: { ...chartOptionsBase.xaxis, categories: gradesChart.categories },
|
||
yaxis: { ...chartOptionsBase.yaxis, min: 1, max: 5, title: { text: 'Оценка (1–5)' } },
|
||
}}
|
||
/>
|
||
)}
|
||
</Panel>
|
||
|
||
{/* Ячейка 3: Успеваемость по ДЗ */}
|
||
<Panel padding="md">
|
||
<SectionHeader title="Успеваемость по ДЗ" />
|
||
{homeworkChart.categories.length === 0 ? (
|
||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||
Нет оценок за ДЗ за период
|
||
</div>
|
||
) : (
|
||
<Chart
|
||
type="line"
|
||
height={260}
|
||
series={homeworkChart.series}
|
||
options={{
|
||
...chartOptionsBase,
|
||
colors: ['#6750A4'],
|
||
xaxis: { ...chartOptionsBase.xaxis, categories: homeworkChart.categories },
|
||
yaxis: { ...chartOptionsBase.yaxis, min: 0, max: 100, title: { text: 'Оценка' } },
|
||
}}
|
||
/>
|
||
)}
|
||
</Panel>
|
||
|
||
{/* Ячейка 4: Посещаемость — проведённые занятия по датам */}
|
||
<Panel padding="md">
|
||
<SectionHeader title="Посещаемость" />
|
||
{attendanceChart.categories.length === 0 ? (
|
||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||
Нет проведённых занятий за период
|
||
</div>
|
||
) : (
|
||
<Chart
|
||
type="line"
|
||
height={260}
|
||
series={attendanceChart.series}
|
||
options={{
|
||
...chartOptionsBase,
|
||
colors: ['#6750A4'],
|
||
xaxis: { ...chartOptionsBase.xaxis, categories: attendanceChart.categories },
|
||
yaxis: {
|
||
...chartOptionsBase.yaxis,
|
||
title: { text: 'Занятий' },
|
||
min: 0,
|
||
tickAmount: 4,
|
||
labels: {
|
||
...(chartOptionsBase.yaxis?.labels ?? {}),
|
||
formatter: (val: number) => String(Math.round(val)),
|
||
},
|
||
},
|
||
}}
|
||
/>
|
||
)}
|
||
</Panel>
|
||
</DashboardLayout>
|
||
</div>
|
||
);
|
||
}
|