Добавлено новое зависимость "react-icons" версии 5.5.0. Обновлен компонент AttendanceTable: добавлены эмоджи для отображения посещаемости студентов, возможность скрытия/показа таблицы, а также улучшена логика расчета статистики посещаемости.
This commit is contained in:
		
							parent
							
								
									49a26edabf
								
							
						
					
					
						commit
						d5b5838e51
					
				
							
								
								
									
										10
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										10
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -28,6 +28,7 @@ | |||||||
|                 "react-dom": "^18.3.1", |                 "react-dom": "^18.3.1", | ||||||
|                 "react-helmet": "^6.1.0", |                 "react-helmet": "^6.1.0", | ||||||
|                 "react-hook-form": "^7.51.2", |                 "react-hook-form": "^7.51.2", | ||||||
|  |                 "react-icons": "^5.5.0", | ||||||
|                 "react-redux": "^9.1.0", |                 "react-redux": "^9.1.0", | ||||||
|                 "react-router-dom": "^6.22.1", |                 "react-router-dom": "^6.22.1", | ||||||
|                 "redux": "^5.0.1", |                 "redux": "^5.0.1", | ||||||
| @ -8894,6 +8895,15 @@ | |||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         "node_modules/react-icons": { | ||||||
|  |             "version": "5.5.0", | ||||||
|  |             "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", | ||||||
|  |             "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", | ||||||
|  |             "license": "MIT", | ||||||
|  |             "peerDependencies": { | ||||||
|  |                 "react": "*" | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         "node_modules/react-is": { |         "node_modules/react-is": { | ||||||
|             "version": "16.13.1", |             "version": "16.13.1", | ||||||
|             "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", |             "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", | ||||||
|  | |||||||
| @ -44,6 +44,7 @@ | |||||||
|         "react-dom": "^18.3.1", |         "react-dom": "^18.3.1", | ||||||
|         "react-helmet": "^6.1.0", |         "react-helmet": "^6.1.0", | ||||||
|         "react-hook-form": "^7.51.2", |         "react-hook-form": "^7.51.2", | ||||||
|  |         "react-icons": "^5.5.0", | ||||||
|         "react-redux": "^9.1.0", |         "react-redux": "^9.1.0", | ||||||
|         "react-router-dom": "^6.22.1", |         "react-router-dom": "^6.22.1", | ||||||
|         "redux": "^5.0.1", |         "redux": "^5.0.1", | ||||||
|  | |||||||
| @ -5,12 +5,10 @@ import { | |||||||
|   Heading, |   Heading, | ||||||
|   Container, |   Container, | ||||||
|   useColorMode, |   useColorMode, | ||||||
|   IconButton, |  | ||||||
|   Flex, |   Flex, | ||||||
|   Spacer, |   Spacer, | ||||||
|   Badge |   Badge | ||||||
| } from '@chakra-ui/react' | } from '@chakra-ui/react' | ||||||
| import { MoonIcon, SunIcon } from '@chakra-ui/icons' |  | ||||||
| 
 | 
 | ||||||
| import { PageLoader } from '../../components/page-loader/page-loader' | import { PageLoader } from '../../components/page-loader/page-loader' | ||||||
| import { useAttendanceData, useAttendanceStats } from './hooks' | import { useAttendanceData, useAttendanceStats } from './hooks' | ||||||
| @ -18,7 +16,7 @@ import { AttendanceTable, StatsCard } from './components' | |||||||
| 
 | 
 | ||||||
| export const Attendance = () => { | export const Attendance = () => { | ||||||
|   const { courseId } = useParams() |   const { courseId } = useParams() | ||||||
|   const { colorMode, toggleColorMode } = useColorMode() |   const { colorMode } = useColorMode() | ||||||
|   const data = useAttendanceData(courseId) |   const data = useAttendanceData(courseId) | ||||||
|   const stats = useAttendanceStats(data) |   const stats = useAttendanceStats(data) | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,4 +1,4 @@ | |||||||
| import React from 'react' | import React, { useState } from 'react' | ||||||
| import { | import { | ||||||
|   Table, |   Table, | ||||||
|   Thead, |   Thead, | ||||||
| @ -8,24 +8,41 @@ import { | |||||||
|   Td, |   Td, | ||||||
|   Box, |   Box, | ||||||
|   useColorMode, |   useColorMode, | ||||||
|   useTheme, |  | ||||||
|   Button, |   Button, | ||||||
|   useToast, |   useToast, | ||||||
|   Flex |   Flex, | ||||||
|  |   Collapse, | ||||||
|  |   HStack, | ||||||
|  |   Text, | ||||||
|  |   Icon, | ||||||
|  |   Tooltip, | ||||||
|  |   Avatar, | ||||||
|  |   AvatarBadge | ||||||
| } from '@chakra-ui/react' | } from '@chakra-ui/react' | ||||||
| import { CopyIcon } from '@chakra-ui/icons' | import { CopyIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons' | ||||||
|  | import { FaSmile, FaMeh, FaFrown, FaSadTear } from 'react-icons/fa' | ||||||
| import dayjs from 'dayjs' | import dayjs from 'dayjs' | ||||||
|  | import { sha256 } from 'js-sha256' | ||||||
| import { ShortText } from './ShortText' | import { ShortText } from './ShortText' | ||||||
| import { AttendanceData } from '../hooks' | import { AttendanceData } from '../hooks' | ||||||
| 
 | 
 | ||||||
|  | // Функция для получения URL аватарки через Gravatar
 | ||||||
|  | function getGravatarURL(email) { | ||||||
|  |   if (!email) return undefined | ||||||
|  |   const address = String(email).trim().toLowerCase() | ||||||
|  |   const hash = sha256(address) | ||||||
|  | 
 | ||||||
|  |   return `https://www.gravatar.com/avatar/${hash}?d=robohash` | ||||||
|  | } | ||||||
|  | 
 | ||||||
| interface AttendanceTableProps { | interface AttendanceTableProps { | ||||||
|   data: AttendanceData |   data: AttendanceData | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => { | export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => { | ||||||
|   const { colorMode } = useColorMode() |   const { colorMode } = useColorMode() | ||||||
|   const theme = useTheme() |  | ||||||
|   const toast = useToast() |   const toast = useToast() | ||||||
|  |   const [showTable, setShowTable] = useState(false) | ||||||
| 
 | 
 | ||||||
|   const getPresentColor = () => { |   const getPresentColor = () => { | ||||||
|     return colorMode === 'dark' ? 'green.600' : 'green.100' |     return colorMode === 'dark' ? 'green.600' : 'green.100' | ||||||
| @ -35,6 +52,37 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => { | |||||||
|     return colorMode === 'dark' ? 'red.800' : 'red.100' |     return colorMode === 'dark' ? 'red.800' : 'red.100' | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   // Получаем эмоджи на основе посещаемости
 | ||||||
|  |   const getAttendanceEmoji = (attendedCount: number, totalLessons: number) => { | ||||||
|  |     const attendanceRate = totalLessons > 0 ? attendedCount / totalLessons : 0 | ||||||
|  |      | ||||||
|  |     if (attendanceRate >= 0.9) { | ||||||
|  |       return {  | ||||||
|  |         icon: FaSmile,  | ||||||
|  |         color: 'green.500',  | ||||||
|  |         label: 'Отличная посещаемость'  | ||||||
|  |       } | ||||||
|  |     } else if (attendanceRate >= 0.75) { | ||||||
|  |       return {  | ||||||
|  |         icon: FaMeh,  | ||||||
|  |         color: 'blue.400',  | ||||||
|  |         label: 'Хорошая посещаемость'  | ||||||
|  |       } | ||||||
|  |     } else if (attendanceRate >= 0.5) { | ||||||
|  |       return {  | ||||||
|  |         icon: FaFrown,  | ||||||
|  |         color: 'orange.400',  | ||||||
|  |         label: 'Низкая посещаемость'  | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       return {  | ||||||
|  |         icon: FaSadTear,  | ||||||
|  |         color: 'red.500',  | ||||||
|  |         label: 'Критически низкая посещаемость'  | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   // Функция для копирования данных таблицы без сокращений
 |   // Функция для копирования данных таблицы без сокращений
 | ||||||
|   const copyTableData = () => { |   const copyTableData = () => { | ||||||
|     if (!data.attendance?.length) return |     if (!data.attendance?.length) return | ||||||
| @ -110,79 +158,179 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => { | |||||||
|       }) |       }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   // Расчет статистики посещаемости для каждого студента
 | ||||||
|  |   const getStudentAttendance = () => { | ||||||
|  |     const totalLessons = data.attendance.length | ||||||
|  |     return data.students.map(student => { | ||||||
|  |       let attendedCount = 0 | ||||||
|  |       data.attendance.forEach(lesson => { | ||||||
|  |         if (lesson.students.findIndex(s => s.sub === student.sub) !== -1) { | ||||||
|  |           attendedCount++ | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |        | ||||||
|  |       return { | ||||||
|  |         student, | ||||||
|  |         name: student.name || student.value || 'Имя не определено', | ||||||
|  |         email: student.email, | ||||||
|  |         picture: student.picture || getGravatarURL(student.email), | ||||||
|  |         attendedCount, | ||||||
|  |         totalLessons, | ||||||
|  |         attendance: totalLessons > 0 ? (attendedCount / totalLessons) * 100 : 0 | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   if (!data.attendance?.length || !data.students?.length) { |   if (!data.attendance?.length || !data.students?.length) { | ||||||
|     return <Box>Нет данных для отображения</Box> |     return <Box>Нет данных для отображения</Box> | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Box  |     <Box  | ||||||
|       overflowX="auto"  |  | ||||||
|       boxShadow="md"  |       boxShadow="md"  | ||||||
|       borderRadius="lg" |       borderRadius="lg" | ||||||
|       bg={colorMode === 'dark' ? 'gray.700' : 'white'} |       bg={colorMode === 'dark' ? 'gray.700' : 'white'} | ||||||
|     > |     > | ||||||
|       <Flex justifyContent="flex-end" p={2}> |       <Flex justifyContent="space-between" p={3} alignItems="center"> | ||||||
|         <Button  |         <Button  | ||||||
|           leftIcon={<CopyIcon />}  |           leftIcon={<CopyIcon />}  | ||||||
|           size="sm"  |           size="sm"  | ||||||
|           colorScheme="blue"  |           colorScheme="blue"  | ||||||
|           onClick={copyTableData} |           onClick={copyTableData} | ||||||
|           mb={2} |           mr={2} | ||||||
|         > |         > | ||||||
|           Копировать таблицу |           Копировать таблицу | ||||||
|         </Button> |         </Button> | ||||||
|  |          | ||||||
|  |         <Button | ||||||
|  |           rightIcon={showTable ? <ChevronUpIcon /> : <ChevronDownIcon />} | ||||||
|  |           size="sm" | ||||||
|  |           variant="outline" | ||||||
|  |           onClick={() => setShowTable(!showTable)} | ||||||
|  |         > | ||||||
|  |           {showTable ? 'Скрыть таблицу' : 'Показать таблицу'} | ||||||
|  |         </Button> | ||||||
|       </Flex> |       </Flex> | ||||||
|       <Table variant="simple" size="sm"> |        | ||||||
|         <Thead> |       {/* Краткая статистика по каждому студенту с эмоджи */} | ||||||
|           <Tr> |       <Box p={4} borderTop="1px" borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}> | ||||||
|             {data.teachers?.map(teacher => ( |         <Flex flexWrap="wrap" gap={3}> | ||||||
|               <Th key={teacher.id}>{teacher.value}</Th> |           {getStudentAttendance().map(({ student, name, attendedCount, totalLessons, attendance, picture }) => { | ||||||
|             ))} |             const emoji = getAttendanceEmoji(attendedCount, totalLessons) | ||||||
|             <Th>Дата</Th> |              | ||||||
|             <Th>Название занятия</Th> |             return ( | ||||||
|             {data.students.map((student) => ( |               <Tooltip  | ||||||
|               <Th key={student.sub}> |                 key={student.sub} | ||||||
|                 {student.name || student.value || 'Имя не определено'} |                 label={`${emoji.label}: ${attendedCount} из ${totalLessons} занятий (${attendance.toFixed(0)}%)`}  | ||||||
|               </Th> |                 hasArrow | ||||||
|             ))} |               > | ||||||
|           </Tr> |                 <Box  | ||||||
|         </Thead> |                   p={3}  | ||||||
|         <Tbody> |                   borderRadius="md" | ||||||
|           {data.attendance.map((lesson) => ( |                   bg={colorMode === 'dark' ? 'gray.800' : 'gray.50'} | ||||||
|             <Tr key={lesson.name}> |                   boxShadow="sm" | ||||||
|               {data.teachers?.map((teacher) => { |                   minWidth="180px" | ||||||
|                 const wasThere = Boolean(lesson.teachers) && |                 > | ||||||
|                   lesson.teachers.findIndex((u) => u.sub === teacher.sub) !== -1 |                   <HStack spacing={3}> | ||||||
|                 return ( |                     <Avatar  | ||||||
|                   <Td |                       size="md"  | ||||||
|                     key={teacher.sub} |                       src={picture}  | ||||||
|                     textAlign="center" |                       name={name} | ||||||
|                     bg={wasThere ? getPresentColor() : getAbsentColor()} |                     > | ||||||
|                   > |                       <AvatarBadge boxSize='2em' bg={emoji.color}> | ||||||
|                     {wasThere ? '+' : '-'} |                         <Icon as={emoji.icon} color="white" boxSize={6} /> | ||||||
|                   </Td> |                       </AvatarBadge> | ||||||
|                 ) |                     </Avatar> | ||||||
|               })} |                     <Box> | ||||||
|               <Td>{dayjs(lesson.date).format('DD.MM.YYYY')}</Td> |                       <Text fontSize="sm" fontWeight="medium" isTruncated maxW="110px">{name}</Text> | ||||||
|               <Td><ShortText text={lesson.name} /></Td> |                       <Text fontSize="xs" mt={1} color={colorMode === 'dark' ? 'gray.400' : 'gray.600'}> | ||||||
|  |                         {attendedCount} из {totalLessons} ({attendance.toFixed(0)}%) | ||||||
|  |                       </Text> | ||||||
|  |                     </Box> | ||||||
|  |                   </HStack> | ||||||
|  |                 </Box> | ||||||
|  |               </Tooltip> | ||||||
|  |             ) | ||||||
|  |           })} | ||||||
|  |         </Flex> | ||||||
|  |       </Box> | ||||||
|  |        | ||||||
|  |       {/* Полная таблица с возможностью скрытия/показа */} | ||||||
|  |       <Collapse in={showTable} animateOpacity> | ||||||
|  |         <Box  | ||||||
|  |           overflowX="auto"  | ||||||
|  |           p={3} | ||||||
|  |           borderTop="1px"  | ||||||
|  |           borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'} | ||||||
|  |         > | ||||||
|  |           <Table variant="simple" size="sm"> | ||||||
|  |             <Thead> | ||||||
|  |               <Tr> | ||||||
|  |                 {data.teachers?.map(teacher => ( | ||||||
|  |                   <Th key={teacher.id}>{teacher.value}</Th> | ||||||
|  |                 ))} | ||||||
|  |                 <Th>Дата</Th> | ||||||
|  |                 <Th>Название занятия</Th> | ||||||
|  |                 {data.students.map((student) => ( | ||||||
|  |                   <Th key={student.sub}> | ||||||
|  |                     <HStack> | ||||||
|  |                       <Avatar  | ||||||
|  |                         size="xs"  | ||||||
|  |                         src={student.picture || getGravatarURL(student.email)}  | ||||||
|  |                         name={student.name || student.value || 'Имя не определено'}  | ||||||
|  |                       /> | ||||||
|  |                       <Text>{student.name || student.value || 'Имя не определено'}</Text> | ||||||
|  |                     </HStack> | ||||||
|  |                   </Th> | ||||||
|  |                 ))} | ||||||
|  |               </Tr> | ||||||
|  |             </Thead> | ||||||
|  |             <Tbody> | ||||||
|  |               {data.attendance.map((lesson) => ( | ||||||
|  |                 <Tr key={lesson.name}> | ||||||
|  |                   {data.teachers?.map((teacher) => { | ||||||
|  |                     const wasThere = Boolean(lesson.teachers) && | ||||||
|  |                       lesson.teachers.findIndex((u) => u.sub === teacher.sub) !== -1 | ||||||
|  |                     return ( | ||||||
|  |                       <Td | ||||||
|  |                         key={teacher.sub} | ||||||
|  |                         textAlign="center" | ||||||
|  |                         bg={wasThere ? getPresentColor() : getAbsentColor()} | ||||||
|  |                       > | ||||||
|  |                         {wasThere ? ( | ||||||
|  |                           <Icon as={FaSmile} color="green.500" /> | ||||||
|  |                         ) : ( | ||||||
|  |                           <Icon as={FaFrown} color="red.500" /> | ||||||
|  |                         )} | ||||||
|  |                       </Td> | ||||||
|  |                     ) | ||||||
|  |                   })} | ||||||
|  |                   <Td>{dayjs(lesson.date).format('DD.MM.YYYY')}</Td> | ||||||
|  |                   <Td><ShortText text={lesson.name} /></Td> | ||||||
| 
 | 
 | ||||||
|               {data.students.map((st) => { |                   {data.students.map((st) => { | ||||||
|                 const wasThere = |                     const wasThere = | ||||||
|                   lesson.students.findIndex((u) => u.sub === st.sub) !== -1 |                       lesson.students.findIndex((u) => u.sub === st.sub) !== -1 | ||||||
|                 return ( |                     return ( | ||||||
|                   <Td |                       <Td | ||||||
|                     key={st.sub} |                         key={st.sub} | ||||||
|                     textAlign="center" |                         textAlign="center" | ||||||
|                     bg={wasThere ? getPresentColor() : getAbsentColor()} |                         bg={wasThere ? getPresentColor() : getAbsentColor()} | ||||||
|                   > |                       > | ||||||
|                     {wasThere ? '+' : '-'} |                         {wasThere ? ( | ||||||
|                   </Td> |                           <Icon as={FaSmile} color="green.500" /> | ||||||
|                 ) |                         ) : ( | ||||||
|               })} |                           <Icon as={FaFrown} color="red.500" /> | ||||||
|             </Tr> |                         )} | ||||||
|           ))} |                       </Td> | ||||||
|         </Tbody> |                     ) | ||||||
|       </Table> |                   })} | ||||||
|  |                 </Tr> | ||||||
|  |               ))} | ||||||
|  |             </Tbody> | ||||||
|  |           </Table> | ||||||
|  |         </Box> | ||||||
|  |       </Collapse> | ||||||
|     </Box> |     </Box> | ||||||
|   ) |   ) | ||||||
| }  | }  | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user