329 lines
11 KiB
TypeScript
329 lines
11 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
|
|
} 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>
|
|
)
|
|
}
|