From d1ae996386819409a8410178e55d55598769a013 Mon Sep 17 00:00:00 2001 From: primakov <primakovpro@gmail.com> 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 ( - <Card key={course._id} align="left"> - <CardHeader> - <Heading as="h2" mt="0"> - {course.name} - </Heading> - </CardHeader> - {isOpened && ( - <CardBody mt="16px"> - <Stack divider={<StackDivider />} spacing="8px"> - <Box as="span" textAlign="left"> - {`${t('journal.pl.course.startDate')} - ${dayjs(course.startDt).format(t('journal.pl.lesson.dateFormat'))}`} - </Box> - <Box as="span" textAlign="left"> - {t('journal.pl.course.lessonCount')} - {course.lessons.length} - </Box> - - {populatedCourse.isFetching && <Spinner />} - {!populatedCourse.isFetching && populatedCourse.isSuccess && ( - <CourseDetails populatedCourse={populatedCourse.data} /> - )} - - {getNavigationValue('link.journal.attendance') && ( - <Tooltip - label={t('journal.pl.course.attendancePage')} - fontSize="12px" - top="16px" - > - <Button - leftIcon={<LinkIcon />} - as={ConnectedLink} - variant="outline" - colorScheme="blue" - to={generatePath( - `${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`, - { courseId: course.id }, - )} - > - <Box mt={3}></Box> - {t('journal.pl.course.attendance')} - </Button> - </Tooltip> - )} - </Stack> - </CardBody> - )} - <CardFooter> - <ButtonGroup - spacing={[0, 4]} - mt="16px" - flexDirection={['column', 'row']} - > - <Tooltip label={t('journal.pl.course.attendancePage')} fontSize="12px" top="16px"> - <Button - leftIcon={<LinkIcon />} - as={ConnectedLink} + <Card + key={course._id} + overflow="hidden" + variant="outline" + borderRadius="lg" + boxShadow="md" + bg={colorMode === 'dark' ? 'gray.700' : 'white'} + borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'} + > + <CardHeader pb={2}> + <Flex justify="space-between" align="center"> + <Heading as="h2" size="md"> + {course.name} + </Heading> + <Tooltip label={isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')}> + <IconButton + aria-label={isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')} + icon={<ArrowUpIcon transform={isOpened ? 'rotate(0)' : 'rotate(180deg)'} />} + size="sm" colorScheme="blue" - to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`} - > - {t('journal.pl.common.open')} - </Button> - </Tooltip> - <Tooltip label={t('journal.pl.course.details')} fontSize="12px" top="16px"> - <Button - colorScheme="blue" - mt={['16px', 0]} - variant="outline" - leftIcon={ - <ArrowUpIcon - transform={isOpened ? 'rotate(0)' : 'rotate(180deg)'} - /> - } - loadingText={t('journal.pl.common.loading')} + variant="ghost" isLoading={populatedCourse.isFetching} onClick={handleToggleOpene} + /> + </Tooltip> + </Flex> + <HStack spacing={2} mt={2}> + <Badge colorScheme="blue"> + <HStack spacing={1}> + <CalendarIcon boxSize="3" /> + <Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text> + </HStack> + </Badge> + <Badge colorScheme="purple"> + {stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()} + </Badge> + </HStack> + </CardHeader> + + {!isOpened && ( + <CardBody pt={2} pb={3}> + {lessonListLoading ? ( + <Flex justify="center" py={3}> + <Spinner size="sm" /> + </Flex> + ) : attendanceStats.topStudents.length > 0 ? ( + <Box> + <Text fontSize="sm" fontWeight="medium" mb={2}> + {t('journal.pl.attendance.stats.topStudents')}: + </Text> + <AvatarGroup size="sm" max={3} mb={1}> + {attendanceStats.topStudents.map(student => ( + <Avatar + key={student.id} + name={student.name} + src={student.avatarUrl} + /> + ))} + </AvatarGroup> + </Box> + ) : ( + <Text fontSize="sm" color="gray.500" textAlign="center"> + {t('journal.pl.attendance.stats.noData')} + </Text> + )} + </CardBody> + )} + + {isOpened && ( + <CardBody pt={2}> + <SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mb={4}> + <Stat> + <StatLabel>{t('journal.pl.course.completedLessons')}</StatLabel> + <HStack align="baseline"> + <StatNumber>{stats.completedLessons}</StatNumber> + <Text color="gray.500">/ {stats.totalLessons}</Text> + </HStack> + <Progress + value={stats.progress} + colorScheme={getProgressColor(stats.progress)} + size="sm" + mt={2} + borderRadius="full" + hasStripe + /> + </Stat> + + <Stat> + <StatLabel>{t('journal.pl.course.upcomingLessons')}</StatLabel> + <HStack align="baseline"> + <StatNumber>{stats.upcomingLessons}</StatNumber> + <Text color="gray.500"> + <TimeIcon ml={1} mr={1} /> + {populatedCourse.data?.lessons + .filter(lesson => dayjs(lesson.date).isAfter(dayjs())) + .sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf())[0]?.date + ? dayjs(populatedCourse.data?.lessons + .filter(lesson => dayjs(lesson.date).isAfter(dayjs())) + .sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf())[0]?.date) + .format('DD.MM.YYYY') + : t('journal.pl.common.noData') + } + </Text> + </HStack> + </Stat> + </SimpleGrid> + + <Divider my={3} /> + + {lessonListLoading ? ( + <Flex justify="center" py={4}> + <Spinner /> + </Flex> + ) : ( + <Box> + <Heading size="sm" mb={3}>{t('journal.pl.attendance.stats.title')}</Heading> + + {attendanceStats.topStudents.length > 0 && ( + <Box mb={4}> + <Text fontSize="sm" fontWeight="medium" mb={2}> + <StarIcon color="yellow.400" mr={1} /> + {t('journal.pl.attendance.stats.topStudents')}: + </Text> + <VStack align="stretch" spacing={2}> + {attendanceStats.topStudents.map((student, index) => ( + <HStack key={student.id} spacing={2}> + <Avatar size="sm" name={student.name} src={student.avatarUrl} /> + <Box flex="1"> + <Text fontSize="sm" fontWeight="medium">{student.name}</Text> + <Progress + value={student.percent} + size="xs" + colorScheme={getAttendanceColor(student.percent)} + borderRadius="full" + /> + </Box> + <Badge colorScheme={getAttendanceColor(student.percent)}> + {student.attended} / {student.total} + </Badge> + </HStack> + ))} + </VStack> + </Box> + )} + + {attendanceStats.lowAttendanceStudents.length > 0 && ( + <Box> + <Text fontSize="sm" fontWeight="medium" mb={2}> + <WarningIcon color="red.400" mr={1} /> + {t('journal.pl.attendance.stats.lowAttendance')}: + </Text> + <VStack align="stretch" spacing={2}> + {attendanceStats.lowAttendanceStudents.map((student) => ( + <HStack key={student.id} spacing={2}> + <Avatar size="sm" name={student.name} src={student.avatarUrl} /> + <Box flex="1"> + <Text fontSize="sm" fontWeight="medium">{student.name}</Text> + <Progress + value={student.percent} + size="xs" + colorScheme={getAttendanceColor(student.percent)} + borderRadius="full" + /> + </Box> + <Badge colorScheme={getAttendanceColor(student.percent)}> + {student.attended} / {student.total} + </Badge> + </HStack> + ))} + </VStack> + </Box> + )} + </Box> + )} + + <Divider my={3} /> + + {populatedCourse.isFetching && ( + <Flex justify="center" py={4}> + <Spinner /> + </Flex> + )} + + {!populatedCourse.isFetching && populatedCourse.isSuccess && populatedCourse.data && ( + <> + <Heading size="sm" mb={3}>{t('journal.pl.lesson.list')}</Heading> + <VStack align="stretch" spacing={2} maxH="300px" overflowY="auto" pr={2}> + {[...populatedCourse.data.lessons] + .sort((a, b) => dayjs(b.date).valueOf() - dayjs(a.date).valueOf()) + .map(lesson => { + const isPast = dayjs(lesson.date).isBefore(dayjs()) + const lessonAttendance = lessonList?.find(l => l._id === lesson._id) + const attendanceCount = lessonAttendance?.students?.length || 0 + + // Безопасный расчёт общего количества студентов + const totalStudentsCount = lessonList && lessonList.length > 0 + ? new Set(lessonList.flatMap(l => (l.students || []).map(s => s.sub))).size + : 1 // Избегаем деления на ноль + + const attendancePercent = totalStudentsCount > 0 + ? (attendanceCount / totalStudentsCount) * 100 + : 0 + + return ( + <Box + key={lesson._id} + p={2} + borderRadius="md" + bg={colorMode === 'dark' ? 'gray.600' : 'gray.50'} + borderLeft="4px solid" + borderLeftColor={isPast ? + (colorMode === 'dark' ? 'green.500' : 'green.400') : + (colorMode === 'dark' ? 'blue.400' : 'blue.500') + } + > + <Flex justify="space-between" align="center"> + <Box> + <Text fontWeight="medium">{lesson.name}</Text> + <HStack spacing={2} mt={1}> + <Tag size="sm" colorScheme={isPast ? "green" : "blue"} borderRadius="full"> + <TagLeftIcon as={CalendarIcon} boxSize='10px' /> + <TagLabel>{dayjs(lesson.date).format('DD.MM.YYYY')}</TagLabel> + </Tag> + {isPast && lessonAttendance && ( + <Tag + size="sm" + colorScheme={getAttendanceColor(attendancePercent)} + borderRadius="full" + > + {attendanceCount} {t('journal.pl.common.students')} + </Tag> + )} + </HStack> + </Box> + <Button + as={ConnectedLink} + to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}/lessons/${lesson._id}`} + size="xs" + variant="ghost" + colorScheme="blue" + leftIcon={<ViewIcon />} + > + {t('journal.pl.common.open')} + </Button> + </Flex> + </Box> + ) + })} + </VStack> + </> + )} + </CardBody> + )} + + <CardFooter pt={2}> + <ButtonGroup spacing={2} width="100%"> + <Tooltip label={t('journal.pl.lesson.list')}> + <Button + leftIcon={<ViewIcon />} + as={ConnectedLink} + colorScheme="blue" + size="md" + flexGrow={1} + to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`} > - {isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')} + {t('journal.pl.lesson.list')} </Button> </Tooltip> + + {getNavigationValue('link.journal.attendance') && ( + <Tooltip label={t('journal.pl.course.attendance')}> + <Button + leftIcon={<LinkIcon />} + as={ConnectedLink} + variant="outline" + colorScheme="blue" + size="md" + flexGrow={1} + to={generatePath( + `${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`, + { courseId: course.id }, + )} + > + {t('journal.pl.course.attendance')} + </Button> + </Tooltip> + )} </ButtonGroup> </CardFooter> </Card>