Добавлены новые переводы для статистики курса и посещаемости в файлы локализации (en.json и ru.json). Обновлен компонент CourseCard: реализована логика расчета статистики курса и посещаемости студентов, добавлены визуальные элементы для отображения прогресса и статистики. Улучшено взаимодействие с пользователем через обновленный интерфейс.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-23 11:54:39 +03:00
parent d3a7f70d12
commit d1ae996386
3 changed files with 417 additions and 75 deletions

View File

@ -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",

View File

@ -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": "Отличная посещаемость",

View File

@ -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>