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' import { Box, CardHeader, CardBody, CardFooter, ButtonGroup, Stack, 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, useBreakpointValue, useMediaQuery, Icon } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import { FaExpand, FaCompress } from 'react-icons/fa' import { api } from '../../__data__/api/api' import { ArrowUpIcon, LinkIcon, CalendarIcon, ViewIcon, WarningIcon, StarIcon, TimeIcon } from '@chakra-ui/icons' import { Course } from '../../__data__/model' 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 [isLessonsExpanded, setIsLessonsExpanded] = useState(false) const { t } = useTranslation() const { colorMode } = useColorMode() // Адаптивные размеры и компоновка для различных размеров экрана const headingSize = useBreakpointValue({ base: 'sm', md: 'md' }) const buttonSize = useBreakpointValue({ base: 'xs', md: 'md' }) const tagSize = useBreakpointValue({ base: 'sm', md: 'md' }) const avatarSize = useBreakpointValue({ base: 'xs', md: 'sm' }) const cardPadding = useBreakpointValue({ base: 2, md: 4 }) // Используем медиа-запросы для определения направления бейджей const [isLargerThanSm] = useMediaQuery("(min-width: 480px)") const [badgeDirection, setBadgeDirection] = useState<'column' | 'row'>('row') useEffect(() => { setBadgeDirection(isLargerThanSm ? 'row' : 'column') }, [isLargerThanSm]) useEffect(() => { if (isOpened) { getLessonList(course.id, true) } }, [isOpened]) const handleToggleOpene = useCallback(() => { setIsOpened((opened) => !opened) }, [setIsOpened]) const handleToggleExpand = useCallback(() => { setIsLessonsExpanded((expanded) => !expanded) }, [setIsLessonsExpanded]) // Рассчитываем статистику курса и посещаемости 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} overflow="hidden" variant="outline" borderRadius="lg" boxShadow="md" bg={colorMode === 'dark' ? 'gray.700' : 'white'} borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'} > <CardHeader pb={2} px={{ base: 3, md: 5 }}> <Flex justify="space-between" align="center" flexWrap="wrap"> <Heading as="h2" size={headingSize} mb={{ base: 2, md: 0 }}> {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" variant="ghost" isLoading={populatedCourse.isFetching} onClick={handleToggleOpene} /> </Tooltip> </Flex> <Flex gap={2} mt={2} flexWrap="wrap"> {badgeDirection === 'column' ? ( <VStack align="start" spacing={2} width="100%"> <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> </VStack> ) : ( <HStack spacing={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> )} </Flex> </CardHeader> {!isOpened && ( <CardBody pt={2} pb={3} px={{ base: 3, md: 5 }}> {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={avatarSize} 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} px={{ base: 3, md: 5 }}> <SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 3, md: 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" flexWrap="wrap"> <StatNumber>{stats.upcomingLessons}</StatNumber> <Text color="gray.500" fontSize={{ base: 'xs', md: 'sm' }}> <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={avatarSize} name={student.name} src={student.avatarUrl} /> <Box flex="1"> <Text fontSize="sm" fontWeight="medium" isTruncated maxWidth={{ base: '120px', sm: '100%' }}>{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={avatarSize} name={student.name} src={student.avatarUrl} /> <Box flex="1"> <Text fontSize="sm" fontWeight="medium" isTruncated maxWidth={{ base: '120px', sm: '100%' }}>{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 && ( <> <Flex justify="space-between" align="center" mb={3}> <Heading size="sm">{t('journal.pl.lesson.list')}</Heading> <Tooltip label={isLessonsExpanded ? t('journal.pl.lesson.collapse') : t('journal.pl.lesson.expand')}> <IconButton aria-label={isLessonsExpanded ? t('journal.pl.lesson.collapse') : t('journal.pl.lesson.expand')} icon={isLessonsExpanded ? <Icon as={FaCompress} /> : <Icon as={FaExpand} />} size="xs" onClick={handleToggleExpand} /> </Tooltip> </Flex> <VStack align="stretch" spacing={2} maxH={isLessonsExpanded ? "none" : "300px"} overflowY={isLessonsExpanded ? "visible" : "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={{ base: 'flex-start', sm: 'center' }} flexDirection={{ base: 'column', sm: 'row' }} gap={{ base: 2, sm: 0 }}> <Box> <Text fontWeight="medium" fontSize={{ base: 'sm', md: 'md' }} noOfLines={2} wordBreak="break-word" maxWidth={{ base: '100%', sm: '200px', md: '300px' }} > {lesson.name} </Text> <HStack spacing={2} mt={1} flexWrap="wrap"> <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 />} ml={{ base: 0, sm: 'auto' }} alignSelf={{ base: 'flex-end', sm: 'center' }} > {t('journal.pl.common.open')} </Button> </Flex> </Box> ) })} </VStack> </> )} </CardBody> )} <CardFooter pt={2} px={{ base: 3, md: 5 }}> <ButtonGroup spacing={{ base: 1, md: 2 }} width="100%" flexDirection={{ base: 'column', sm: 'row' }}> <Tooltip label={t('journal.pl.lesson.list')}> <Button leftIcon={<ViewIcon />} as={ConnectedLink} colorScheme="blue" size={buttonSize} flexGrow={1} to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`} mb={{ base: 2, sm: 0 }} > {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={buttonSize} flexGrow={1} to={generatePath( `${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`, { courseId: course.id }, )} > {t('journal.pl.course.attendance')} </Button> </Tooltip> )} </ButtonGroup> </CardFooter> </Card> ) }