535 lines
20 KiB
TypeScript
535 lines
20 KiB
TypeScript
import React, { useCallback, useEffect, useState, useMemo } from 'react'
|
||
import { formatDate } from '../../utils/dayjs-config'
|
||
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()
|
||
const now = dayjs()
|
||
|
||
// Фильтруем только прошедшие лекции
|
||
const pastLessons = lessonList.filter(lesson => dayjs(lesson.date).isBefore(now))
|
||
|
||
// Если прошедших лекций нет, возвращаем пустую статистику
|
||
if (pastLessons.length === 0) {
|
||
return {
|
||
topStudents: [],
|
||
lowAttendanceStudents: []
|
||
}
|
||
}
|
||
|
||
// Собираем данные о всех студентах (только для прошедших лекций)
|
||
pastLessons.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 = pastLessons.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>{formatDate(course.startDt, '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>{formatDate(course.startDt, '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
|
||
? formatDate(
|
||
populatedCourse.data?.lessons
|
||
.filter(lesson => dayjs(lesson.date).isAfter(dayjs()))
|
||
.sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf())[0]?.date,
|
||
'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>{formatDate(lesson.date, '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')}/lesson/${course._id}/${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>
|
||
)
|
||
}
|