From e50fb4fd8215bd225c03b812a04ea65dbee220b2 Mon Sep 17 00:00:00 2001 From: primakov <primakovpro@gmail.com> Date: Sun, 23 Mar 2025 22:19:43 +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=D0=BE=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=D1=81=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B2=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B5=20LessonDetail?= =?UTF-8?q?=20=D0=BF=D1=80=D0=B8=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=BA=D0=BE=D0=BB=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B0=20=D1=81=D1=82=D1=83=D0=B4=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=86=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B0=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D0=B5=D1=89=D0=B0=D0=B5=D0=BC=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=20LessonList=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=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=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=D1=81?= =?UTF-8?q?=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BB=D0=B5=D0=B9=20=D0=B8=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/lesson-details.tsx | 79 +++++++++- src/pages/lesson-list/lesson-list.tsx | 210 ++++++++++++++++++++++---- 2 files changed, 251 insertions(+), 38 deletions(-) diff --git a/src/pages/lesson-details.tsx b/src/pages/lesson-details.tsx index 25196c2..3bfd693 100644 --- a/src/pages/lesson-details.tsx +++ b/src/pages/lesson-details.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useMemo } from 'react' +import React, { useEffect, useRef, useMemo, useState } from 'react' import { useParams, Link } from 'react-router-dom' import QRCode from 'qrcode' import { sha256 } from 'js-sha256' @@ -49,6 +49,11 @@ const LessonDetail = () => { // Создаем ref для отслеживания ранее присутствовавших студентов const prevPresentStudentsRef = useRef(new Set<string>()) + // Добавляем состояние для отслеживания пульсации + const [isPulsing, setIsPulsing] = useState(false) + // Отслеживаем предыдущее количество студентов + const prevStudentCountRef = useRef(0) + const { isFetching, data: accessCode, @@ -75,6 +80,20 @@ const LessonDetail = () => { if (accessCode?.body) { const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub)) + // Проверяем, изменилось ли количество студентов + const currentCount = accessCode.body.lesson.students.length; + if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) { + // Запускаем эффект пульсации + setIsPulsing(true); + // Сбрасываем эффект через 1.5 секунды + setTimeout(() => { + setIsPulsing(false); + }, 1500); + } + + // Обновляем предыдущее количество + prevStudentCountRef.current = currentCount; + // Очищаем флаги предыдущего состояния после задержки const timeoutId = setTimeout(() => { prevPresentStudentsRef.current = currentPresent @@ -169,6 +188,17 @@ const LessonDetail = () => { return allStudents.sort((a, b) => (a.present ? -1 : 1)) }, [accessCode?.body, AllStudents.data, prevPresentStudentsRef.current]) + // Функция для определения цвета на основе посещаемости + const getAttendanceColor = (attendance: number, total: number) => { + const percentage = total > 0 ? (attendance / total) * 100 : 0 + + if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } } + if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } } + if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } } + if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } } + return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } } + } + return ( <> <BreadcrumbsWrapper> @@ -211,10 +241,49 @@ const LessonDetail = () => { boxShadow="md" ><Box pb={3}> {formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '} - {t('journal.pl.common.marked')} - {accessCode?.body?.lesson?.students?.length}{' '} - {AllStudents.isSuccess - ? `/ ${AllStudents?.data?.body?.length}` - : ''}{' '} + {t('journal.pl.common.marked')} - + {AllStudents.isSuccess && ( + <Box + as="span" + px={2} + py={1} + ml={2} + borderRadius="md" + fontWeight="bold" + bg={getAttendanceColor( + accessCode?.body?.lesson?.students?.length || 0, + AllStudents?.data?.body?.length || 1 + ).bg} + color={getAttendanceColor( + accessCode?.body?.lesson?.students?.length || 0, + AllStudents?.data?.body?.length || 1 + ).color} + _dark={{ + bg: getAttendanceColor( + accessCode?.body?.lesson?.students?.length || 0, + AllStudents?.data?.body?.length || 1 + ).dark.bg, + color: getAttendanceColor( + accessCode?.body?.lesson?.students?.length || 0, + AllStudents?.data?.body?.length || 1 + ).dark.color + }} + position="relative" + animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"} + sx={{ + '@keyframes pulse': { + '0%': { transform: 'scale(1)' }, + '50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' }, + '100%': { transform: 'scale(1)' } + } + }} + > + {accessCode?.body?.lesson?.students?.length} / {AllStudents?.data?.body?.length} + </Box> + )} + {!AllStudents.isSuccess && ( + <span> {accessCode?.body?.lesson?.students?.length}</span> + )}{' '} {t('journal.pl.common.people')} </Box> <a href={userUrl}> diff --git a/src/pages/lesson-list/lesson-list.tsx b/src/pages/lesson-list/lesson-list.tsx index 6b69c85..94e02ea 100644 --- a/src/pages/lesson-list/lesson-list.tsx +++ b/src/pages/lesson-list/lesson-list.tsx @@ -26,8 +26,14 @@ import { AlertDialogHeader, AlertDialogOverlay, useBreakpointValue, + Flex, + Menu, + MenuButton, + MenuList, + MenuItem, + useColorMode, } from '@chakra-ui/react' -import { AddIcon } from '@chakra-ui/icons' +import { AddIcon, EditIcon } from '@chakra-ui/icons' import { useTranslation } from 'react-i18next' import { useAppSelector } from '../../__data__/store' @@ -35,6 +41,7 @@ import { api } from '../../__data__/api/api' import { isTeacher } from '../../utils/user' import { Lesson } from '../../__data__/model' import { XlSpinner } from '../../components/xl-spinner' +import { qrCode } from '../../assets' import { LessonForm } from './components/lessons-form' import { Bar } from './components/bar' @@ -58,6 +65,7 @@ const LessonList = () => { error: errorGenerateLessons, isSuccess: isSuccessGenerateLessons }, ] = api.useGenerateLessonsMutation() + const { colorMode } = useColorMode() const [createLesson, crLQuery] = api.useCreateLessonMutation() const [deleteLesson, deletingRqst] = api.useDeleteLessonMutation() @@ -76,6 +84,23 @@ const LessonList = () => { [data, data?.body], ) + // Найдем максимальное количество студентов среди всех уроков + const maxStudents = useMemo(() => { + if (!sorted || sorted.length === 0) return 1 + const max = Math.max(...sorted.map(lesson => lesson.students?.length || 0)) + return max > 0 ? max : 1 // Избегаем деления на ноль + }, [sorted]) + + // Функция для определения цвета на основе посещаемости + const getAttendanceColor = (attendance: number) => { + const percentage = (attendance / maxStudents) * 100 + if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } } + if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } } + if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } } + if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } } + return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } } + } + const lessonCalc = useMemo(() => { if (!isSuccess) { return [] @@ -379,38 +404,157 @@ const LessonList = () => { ))} </Box> ) : ( - <TableContainer whiteSpace="wrap" pb={13}> - <Table variant="striped" colorScheme="cyan"> - <Thead> - <Tr> - {isTeacher(user) && ( - <Th align="center" width={1}> - {t('journal.pl.lesson.link')} - </Th> - )} - <Th textAlign="center" width={1}> - {groupByDate ? t('journal.pl.lesson.time') : t('journal.pl.common.date')} - </Th> - <Th width="100%">{t('journal.pl.common.name')}</Th> - {isTeacher(user) && <Th>{t('journal.pl.lesson.action')}</Th>} - <Th isNumeric>{t('journal.pl.common.marked')}</Th> - </Tr> - </Thead> - <Tbody> - {lessonCalc?.map(({ data: lessons, date }) => ( - <LessonItems - courseId={courseId} - date={date} - isTeacher={isTeacher(user)} - lessons={lessons} - setlessonToDelete={setlessonToDelete} - setEditLesson={handleEditLesson} - key={date} - /> - ))} - </Tbody> - </Table> - </TableContainer> + <Box pb={13}> + {lessonCalc?.map(({ data: lessons, date }) => ( + <Box key={date} mb={6}> + {date && ( + <Box + p={3} + mb={4} + bg="cyan.50" + borderRadius="md" + _dark={{ bg: "cyan.900" }} + boxShadow="sm" + > + <Text fontWeight="bold" fontSize="lg"> + {formatDate(date, 'DD MMMM YYYY')} + </Text> + </Box> + )} + <Box> + {lessons.map((lesson, index) => ( + <Box + key={lesson.id} + borderRadius="lg" + boxShadow="md" + bg="white" + _dark={{ bg: "gray.700" }} + transition="all 0.3s" + _hover={{ + transform: "translateX(5px)", + boxShadow: "lg" + }} + overflow="hidden" + position="relative" + mb={4} + animation={`slideIn 0.6s ease-out ${index * 0.15}s both`} + sx={{ + '@keyframes slideIn': { + '0%': { + opacity: 0, + transform: 'translateX(-30px)' + }, + '100%': { + opacity: 1, + transform: 'translateX(0)' + } + } + }} + > + <Flex direction={{ base: "column", sm: "row" }}> + {/* QR код и ссылка - левая часть карточки */} + {isTeacher(user) && ( + <Link + to={`${getNavigationValue('journal.main')}/lesson/${courseId}/${lesson.id}`} + > + <Box + p={4} + bg="cyan.500" + _dark={{ bg: "cyan.600" }} + color="white" + display="flex" + alignItems="center" + justifyContent="center" + transition="all 0.2s" + _hover={{ bg: "cyan.600", _dark: { bg: "cyan.700" } }} + height="100%" + minW="150px" + > + <Box + mr={0} + bg="white" + borderRadius="md" + p={2} + display="flex" + > + <img width={32} src={qrCode} alt="QR код" /> + </Box> + </Box> + </Link> + )} + + {/* Содержимое карточки */} + <Box p={5} w="100%" display="flex" flexDirection="column" justifyContent="space-between"> + <Flex mb={3} justify="space-between" align="center"> + {/* Название урока */} + <Text fontWeight="bold" fontSize="xl" lineHeight="1.4" flex="1"> + {lesson.name} + </Text> + + <Text fontSize="sm" color="gray.500" _dark={{ color: "gray.300" }} ml={3} whiteSpace="nowrap"> + {formatDate(lesson.date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')} + </Text> + </Flex> + + {/* Нижняя часть с метками и действиями */} + <Flex justifyContent="space-between" alignItems="center" mt={1}> + <Flex align="center"> + <Text fontSize="sm" mr={2}> + {t('journal.pl.common.marked')}: + </Text> + <Text + px={2} + py={1} + bg={getAttendanceColor(lesson.students.length).bg} + color={getAttendanceColor(lesson.students.length).color} + _dark={{ + bg: getAttendanceColor(lesson.students.length).dark.bg, + color: getAttendanceColor(lesson.students.length).dark.color + }} + borderRadius="md" + fontWeight="bold" + fontSize="sm" + > + {lesson.students.length} + </Text> + </Flex> + + {isTeacher(user) && ( + <Menu> + <MenuButton + as={Button} + size="sm" + colorScheme="cyan" + variant="ghost" + rightIcon={<EditIcon />} + > + {t('journal.pl.edit')} + </MenuButton> + <MenuList> + <MenuItem + onClick={() => handleEditLesson(lesson)} + icon={<EditIcon />} + > + {t('journal.pl.edit')} + </MenuItem> + <MenuItem + onClick={() => setlessonToDelete(lesson)} + color="red.500" + > + {t('journal.pl.delete')} + </MenuItem> + </MenuList> + </Menu> + )} + </Flex> + </Box> + </Flex> + </Box> + ))} + </Box> + </Box> + ))} + </Box> )} </Container> </>