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

329 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState } from 'react'
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Box,
useColorMode,
Button,
useToast,
Flex,
Collapse,
HStack,
Text,
Icon,
Tooltip,
Avatar,
AvatarBadge
} from '@chakra-ui/react'
import { CopyIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
import { FaSmile, FaMeh, FaFrown, FaSadTear } 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 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>
}
return (
<Box
boxShadow="md"
borderRadius="lg"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
>
<Flex justifyContent="space-between" p={3} alignItems="center">
<Button
leftIcon={<CopyIcon />}
size="sm"
colorScheme="blue"
onClick={copyTableData}
mr={2}
>
{t('journal.pl.attendance.table.copy')}
</Button>
<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'}
>
<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>
</Box>
</Collapse>
</Box>
)
}