From d1ae996386819409a8410178e55d55598769a013 Mon Sep 17 00:00:00 2001 From: primakov Date: Sun, 23 Mar 2025 11:54:39 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=D1=8B=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BA=D1=83=D1=80=D1=81=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=B5=D1=89=D0=B0=D0=B5=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=B2?= =?UTF-8?q?=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20(en.json=20=D0=B8?= =?UTF-8?q?=20ru.json).=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20Cou?= =?UTF-8?q?rseCard:=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=87=D0=B5=D1=82=D0=B0=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20=D0=BA=D1=83=D1=80?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D1=81=D0=B5=D1=89=D0=B0?= =?UTF-8?q?=D0=B5=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D1=81=D1=82=D1=83=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=8D=D0=BB=D0=B5=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D1=80=D0=B5=D1=81=D1=81=D0=B0=20=D0=B8=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8.=20?= =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=BE=20=D0=B2=D0=B7?= =?UTF-8?q?=D0=B0=D0=B8=D0=BC=D0=BE=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BC=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D1=81.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- locales/en.json | 4 + locales/ru.json | 4 + src/pages/course-list/course-card.tsx | 484 ++++++++++++++++++++++---- 3 files changed, 417 insertions(+), 75 deletions(-) diff --git a/locales/en.json b/locales/en.json index 5b758a7..e0ffdc3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -52,6 +52,9 @@ "journal.pl.course.attendance": "Attendance", "journal.pl.course.details": "Details", "journal.pl.course.viewDetails": "View details", + "journal.pl.course.progress": "Course progress", + "journal.pl.course.completedLessons": "Completed lessons", + "journal.pl.course.upcomingLessons": "Upcoming lessons", "journal.pl.lesson.created": "Lesson created", "journal.pl.lesson.successMessage": "Lesson {{name}} successfully created", @@ -84,6 +87,7 @@ "journal.pl.attendance.stats.totalLessons": "Total Lessons", "journal.pl.attendance.stats.averageAttendance": "Average Attendance", "journal.pl.attendance.stats.topStudents": "Top 3 Students by Attendance", + "journal.pl.attendance.stats.lowAttendance": "Students with Low Attendance", "journal.pl.attendance.stats.noData": "No data", "journal.pl.attendance.emojis.excellent": "Excellent attendance", diff --git a/locales/ru.json b/locales/ru.json index 65cd08a..99a0662 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -48,6 +48,9 @@ "journal.pl.course.attendance": "Посещаемость", "journal.pl.course.details": "Детали", "journal.pl.course.viewDetails": "Просмотреть детали", + "journal.pl.course.progress": "Прогресс курса", + "journal.pl.course.completedLessons": "Завершено занятий", + "journal.pl.course.upcomingLessons": "Предстоящие занятия", "journal.pl.lesson.created": "Лекция создана", "journal.pl.lesson.successMessage": "Лекция {{name}} успешно создана", @@ -80,6 +83,7 @@ "journal.pl.attendance.stats.totalLessons": "Всего занятий", "journal.pl.attendance.stats.averageAttendance": "Средняя посещаемость", "journal.pl.attendance.stats.topStudents": "Топ-3 студента по посещаемости", + "journal.pl.attendance.stats.lowAttendance": "Студенты с низкой посещаемостью", "journal.pl.attendance.stats.noData": "Нет данных", "journal.pl.attendance.emojis.excellent": "Отличная посещаемость", diff --git a/src/pages/course-list/course-card.tsx b/src/pages/course-list/course-card.tsx index cd07001..f43ab40 100644 --- a/src/pages/course-list/course-card.tsx +++ b/src/pages/course-list/course-card.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState, useMemo } from 'react' import dayjs from 'dayjs' import { Link as ConnectedLink, generatePath } from 'react-router-dom' import { getNavigationValue } from '@brojs/cli' @@ -9,24 +9,50 @@ import { CardFooter, ButtonGroup, Stack, - StackDivider, Button, Card, Heading, Tooltip, Spinner, + Flex, + IconButton, + Badge, + Progress, + SimpleGrid, + Stat, + StatLabel, + StatNumber, + HStack, + Text, + VStack, + Divider, + useColorMode, + Avatar, + AvatarGroup, + Tag, + TagLabel, + TagLeftIcon, + Wrap, + WrapItem, } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import { api } from '../../__data__/api/api' -import { ArrowUpIcon, LinkIcon } from '@chakra-ui/icons' +import { ArrowUpIcon, LinkIcon, CalendarIcon, ViewIcon, WarningIcon, StarIcon, TimeIcon } from '@chakra-ui/icons' import { Course } from '../../__data__/model' import { CourseDetails } from './course-details' export const CourseCard = ({ course }: { course: Course }) => { const [getLessonList, populatedCourse] = api.useLazyGetCourseByIdQuery() + const { data: lessonList, isLoading: lessonListLoading } = api.useLessonListQuery(course.id, { + selectFromResult: ({ data, isLoading }) => ({ + data: data?.body, + isLoading, + }), + }) const [isOpened, setIsOpened] = useState(false) const { t } = useTranslation() + const { colorMode } = useColorMode() useEffect(() => { if (isOpened) { @@ -38,85 +64,393 @@ export const CourseCard = ({ course }: { course: Course }) => { setIsOpened((opened) => !opened) }, [setIsOpened]) + // Рассчитываем статистику курса и посещаемости + const stats = useMemo(() => { + if (!populatedCourse.data) { + return { + totalLessons: course.lessons.length, + upcomingLessons: 0, + completedLessons: 0, + progress: 0, + topStudents: [], + lowAttendanceStudents: [] + } + } + + const now = dayjs() + const total = populatedCourse.data.lessons.length + const completed = populatedCourse.data.lessons.filter(lesson => + dayjs(lesson.date).isBefore(now) + ).length + + return { + totalLessons: total, + upcomingLessons: total - completed, + completedLessons: completed, + progress: total > 0 ? (completed / total) * 100 : 0, + topStudents: [], + lowAttendanceStudents: [] + } + }, [populatedCourse.data, course.lessons.length]) + + // Рассчитываем статистику посещаемости студентов + const attendanceStats = useMemo(() => { + if (!lessonList || lessonList.length === 0) { + return { + topStudents: [], + lowAttendanceStudents: [] + } + } + + const studentsMap = new Map() + + // Собираем данные о всех студентах + lessonList.forEach(lesson => { + lesson.students?.forEach(student => { + const studentId = student.sub + const current = studentsMap.get(studentId) || { + id: studentId, + name: (student.family_name && student.given_name + ? `${student.family_name} ${student.given_name}` + : student.name || student.email || student.preferred_username || student.family_name || student.given_name), + attended: 0, + total: 0, + avatarUrl: student.picture, + email: student.email + } + + current.attended += 1 + studentsMap.set(studentId, current) + }) + }) + + // Для каждого студента установить общее количество лекций + studentsMap.forEach(student => { + student.total = lessonList.length + student.percent = (student.attended / student.total) * 100 + }) + + // Преобразуем Map в массив и сортируем + const students = Array.from(studentsMap.values()) + + // Топ-3 студента по посещаемости + const topStudents = [...students] + .sort((a, b) => b.percent - a.percent) + .slice(0, 3) + + // Студенты с низкой посещаемостью (менее 50%) + const lowAttendanceStudents = students + .filter(student => student.percent < 50 && student.total > 0) + .sort((a, b) => a.percent - b.percent) + .slice(0, 3) + + return { + topStudents, + lowAttendanceStudents + } + }, [lessonList]) + + const getProgressColor = (value: number) => { + if (value > 80) return 'green' + if (value > 50) return 'yellow' + return 'blue' + } + + const getAttendanceColor = (value: number) => { + if (value > 80) return 'green' + if (value > 50) return 'yellow' + if (value > 30) return 'orange' + return 'red' + } + return ( - - - - {course.name} - - - {isOpened && ( - - } spacing="8px"> - - {`${t('journal.pl.course.startDate')} - ${dayjs(course.startDt).format(t('journal.pl.lesson.dateFormat'))}`} - - - {t('journal.pl.course.lessonCount')} - {course.lessons.length} - - - {populatedCourse.isFetching && } - {!populatedCourse.isFetching && populatedCourse.isSuccess && ( - - )} - - {getNavigationValue('link.journal.attendance') && ( - - - - )} - - - )} - - - - - - - + + + ) + })} + + + )} + + )} + + + + + + + {getNavigationValue('link.journal.attendance') && ( + + + + )}