uchill/front_material/app/(protected)/my-progress/page.tsx

427 lines
16 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 { 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" data-tour="my-progress-root">
{/* Ячейка 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: 'Оценка (15)' } },
}}
/>
)}
</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>
);
}