Files
journal.pl/src/pages/attendance/components/AttendanceTable.tsx
T

372 lines
12 KiB
TypeScript

import React, { useState } from 'react'
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Box,
useColorMode,
Button,
useToast,
Flex,
Collapse,
HStack,
Text,
Icon,
Tooltip,
Avatar,
AvatarBadge,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react'
import { CopyIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
import { FaSmile, FaMeh, FaFrown, FaSadTear, FaExpand, FaCompress } from 'react-icons/fa'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { getGravatarURL } from '../../../utils/gravatar'
import { ShortText } from './ShortText'
import { AttendanceData } from '../hooks'
interface AttendanceTableProps {
data: AttendanceData
}
export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
const { colorMode } = useColorMode()
const toast = useToast()
const { t } = useTranslation()
const [showTable, setShowTable] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const getPresentColor = () => {
return colorMode === 'dark' ? 'green.600' : 'green.100'
}
const getAbsentColor = () => {
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: t('journal.pl.attendance.emojis.excellent')
}
} else if (attendanceRate >= 0.75) {
return {
icon: FaMeh,
color: 'blue.400',
label: t('journal.pl.attendance.emojis.good')
}
} else if (attendanceRate >= 0.5) {
return {
icon: FaFrown,
color: 'orange.400',
label: t('journal.pl.attendance.emojis.poor')
}
} else {
return {
icon: FaSadTear,
color: 'red.500',
label: t('journal.pl.attendance.emojis.none')
}
}
}
// Функция для копирования данных таблицы без сокращений
const copyTableData = () => {
if (!data.attendance?.length) return
// Строим заголовок таблицы
let tableContent = []
// Добавляем заголовки с именами преподавателей
let headerRow = []
data.teachers?.forEach(teacher => {
headerRow.push(teacher.value)
})
// Добавляем столбцы даты и названия занятия
headerRow.push(t('journal.pl.common.date'), t('journal.pl.common.lessonName'))
// Добавляем студентов
data.students.forEach(student => {
headerRow.push(student.name || student.value || t('journal.pl.common.name'))
})
// Добавляем заголовок в таблицу
tableContent.push(headerRow.join('\t'))
// Формируем данные для каждой строки
data.attendance.forEach(lesson => {
let row = []
// Добавляем данные о присутствии преподавателей
data.teachers?.forEach(teacher => {
const wasThere = Boolean(lesson.teachers) &&
lesson.teachers.findIndex(u => u.sub === teacher.sub) !== -1
row.push(wasThere ? '+' : '-')
})
// Добавляем дату
row.push(dayjs(lesson.date).format('DD.MM.YYYY'))
// Добавляем полное название занятия (без сокращений)
row.push(lesson.name)
// Добавляем данные о присутствии студентов
data.students.forEach(student => {
const wasThere = lesson.students.findIndex(u => u.sub === student.sub) !== -1
row.push(wasThere ? '+' : '-')
})
// Добавляем строку в таблицу
tableContent.push(row.join('\t'))
})
// Копируем в буфер обмена
const finalContent = tableContent.join('\n')
navigator.clipboard.writeText(finalContent)
.then(() => {
toast({
title: t('journal.pl.attendance.table.copySuccess'),
description: t('journal.pl.attendance.table.copySuccessDescription'),
status: 'success',
duration: 3000,
isClosable: true,
})
})
.catch(err => {
toast({
title: t('journal.pl.attendance.table.copyError'),
description: t('journal.pl.attendance.table.copyErrorDescription'),
status: 'error',
duration: 3000,
isClosable: true,
})
console.error('Ошибка копирования', err)
})
}
// Расчет статистики посещаемости для каждого студента
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 || t('journal.pl.common.name'),
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) {
return <Box>{t('journal.pl.common.noData')}</Box>
}
// Создаем компонент таблицы для переиспользования
const AttendanceTableContent = () => (
<Table variant="simple" size="sm">
<Thead>
<Tr>
{data.teachers?.map(teacher => (
<Th key={teacher.id}>{teacher.value}</Th>
))}
<Th>{t('journal.pl.common.date')}</Th>
<Th>{t('journal.pl.common.lessonName')}</Th>
{data.students.map((student) => (
<Th key={student.sub}>
<HStack>
<Avatar
size="xs"
src={student.picture || getGravatarURL(student.email)}
name={student.name || student.value || t('journal.pl.common.name')}
/>
<Text>{student.name || student.value || t('journal.pl.common.name')}</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) => {
const wasThere =
lesson.students.findIndex((u) => u.sub === st.sub) !== -1
return (
<Td
key={st.sub}
textAlign="center"
bg={wasThere ? getPresentColor() : getAbsentColor()}
>
{wasThere ? (
<Icon as={FaSmile} color="green.500" />
) : (
<Icon as={FaFrown} color="red.500" />
)}
</Td>
)
})}
</Tr>
))}
</Tbody>
</Table>
)
return (
<Box
boxShadow="md"
borderRadius="lg"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
>
<Flex justifyContent="space-between" p={3} alignItems="center">
<Flex>
<Button
leftIcon={<CopyIcon />}
size="sm"
colorScheme="blue"
onClick={copyTableData}
mr={2}
>
{t('journal.pl.attendance.table.copy')}
</Button>
<Button
leftIcon={<Icon as={FaExpand} />}
size="sm"
colorScheme="teal"
onClick={onOpen}
mr={2}
>
{t('journal.pl.attendance.table.fullscreen')}
</Button>
</Flex>
<Button
rightIcon={showTable ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="outline"
onClick={() => setShowTable(!showTable)}
>
{showTable ? t('journal.pl.attendance.table.hide') : t('journal.pl.attendance.table.show')}
</Button>
</Flex>
{/* Краткая статистика по каждому студенту с эмоджи */}
<Box p={4} borderTop="1px" borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}>
<Flex flexWrap="wrap" gap={3}>
{getStudentAttendance().map(({ student, name, attendedCount, totalLessons, attendance, picture }) => {
const emoji = getAttendanceEmoji(attendedCount, totalLessons)
return (
<Tooltip
key={student.sub}
label={`${emoji.label}: ${attendedCount} ${t('journal.pl.common.of')} ${totalLessons} ${t('journal.pl.common.students')} (${attendance.toFixed(0)}%)`}
hasArrow
>
<Box
p={3}
borderRadius="md"
bg={colorMode === 'dark' ? 'gray.800' : 'gray.50'}
boxShadow="sm"
minWidth="180px"
>
<HStack spacing={3}>
<Avatar
size="md"
src={picture}
name={name}
>
<AvatarBadge boxSize='2em' bg={emoji.color}>
<Icon as={emoji.icon} color="white" boxSize={7} />
</AvatarBadge>
</Avatar>
<Box>
<Text fontSize="sm" fontWeight="medium" isTruncated maxW="110px">{name}</Text>
<Text fontSize="xs" mt={1} color={colorMode === 'dark' ? 'gray.400' : 'gray.600'}>
{attendedCount} {t('journal.pl.common.of')} {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'}
>
<AttendanceTableContent />
</Box>
</Collapse>
{/* Модальное окно для отображения таблицы на весь экран */}
<Modal isOpen={isOpen} onClose={onClose} size="full">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Flex justifyContent="space-between" alignItems="center">
{t('journal.pl.attendance.table.attendanceData')}
</Flex>
</ModalHeader>
<ModalCloseButton size="lg" top="16px" />
<ModalBody pb={6}>
<Box overflowX="auto">
<AttendanceTableContent />
</Box>
</ModalBody>
</ModalContent>
</Modal>
</Box>
)
}