Добавлены новые зависимости: "react-select" и "@floating-ui/core". Реализована локализация с использованием i18next, добавлены переводы для английского и русского языков. Обновлены компоненты для поддержки локализации, включая AppHeader, Attendance, Dashboard и другие. Улучшена логика отображения данных и взаимодействия с пользователем.

This commit is contained in:
2025-03-23 11:41:29 +03:00
parent d5b5838e51
commit d3a7f70d12
27 changed files with 995 additions and 191 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ import {
Spacer,
Badge
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { PageLoader } from '../../components/page-loader/page-loader'
import { useAttendanceData, useAttendanceStats } from './hooks'
@@ -17,6 +18,7 @@ import { AttendanceTable, StatsCard } from './components'
export const Attendance = () => {
const { courseId } = useParams()
const { colorMode } = useColorMode()
const { t } = useTranslation()
const data = useAttendanceData(courseId)
const stats = useAttendanceStats(data)
@@ -30,7 +32,7 @@ export const Attendance = () => {
<Box>
<Heading size="lg" mb={2}>{data.courseInfo?.name}</Heading>
<Badge colorScheme="blue">
{data.students.length} студентов {data.teachers.length} преподавателей
{data.students.length} {t('journal.pl.common.students')} {data.teachers.length} {t('journal.pl.common.teachers')}
</Badge>
</Box>
<Spacer />
@@ -0,0 +1,142 @@
import React, { useState } from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
VStack,
FormControl,
FormLabel,
Input,
FormHelperText,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
useColorMode,
} from "@chakra-ui/react";
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import UserSelect from "../../../components/user-select";
interface AttendanceEntry {
name: string;
date: string;
students: any[];
teachers: any[];
}
interface AddDataDialogProps {
isOpen: boolean;
onClose: () => void;
onAddData: (data: AttendanceEntry) => void;
}
const AddDataDialog = ({ isOpen, onClose, onAddData }: AddDataDialogProps) => {
const { colorMode } = useColorMode();
const { t } = useTranslation();
const [name, setName] = useState("");
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
const [selectedStudents, setSelectedStudents] = useState<any[]>([]);
const [selectedTeachers, setSelectedTeachers] = useState<any[]>([]);
const handleSubmit = () => {
const newEntry: AttendanceEntry = {
name,
date,
students: selectedStudents,
teachers: selectedTeachers,
};
onAddData(newEntry);
resetForm();
onClose();
};
const resetForm = () => {
setName("");
setDate(dayjs().format('YYYY-MM-DD'));
setSelectedStudents([]);
setSelectedTeachers([]);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
<ModalHeader>{t('journal.pl.attendance.addDialog.title')}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<FormControl isRequired>
<FormLabel>{t('journal.pl.common.lessonName')}</FormLabel>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('journal.pl.attendance.addDialog.lessonNamePlaceholder')}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>{t('journal.pl.common.date')}</FormLabel>
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</FormControl>
<Tabs isFitted variant="enclosed" colorScheme="blue" mt={4}>
<TabList>
<Tab>{t('journal.pl.common.students')}</Tab>
<Tab>{t('journal.pl.common.teachers')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<FormControl>
<FormLabel>{t('journal.pl.attendance.addDialog.selectStudents')}</FormLabel>
<UserSelect
isMulti
value={selectedStudents}
onChange={setSelectedStudents}
placeholder={t('journal.pl.attendance.addDialog.studentsPlaceholder')}
/>
<FormHelperText>{t('journal.pl.attendance.addDialog.studentsHelperText')}</FormHelperText>
</FormControl>
</TabPanel>
<TabPanel>
<FormControl>
<FormLabel>{t('journal.pl.attendance.addDialog.selectTeachers')}</FormLabel>
<UserSelect
isMulti
value={selectedTeachers}
onChange={setSelectedTeachers}
placeholder={t('journal.pl.attendance.addDialog.teachersPlaceholder')}
/>
<FormHelperText>{t('journal.pl.attendance.addDialog.teachersHelperText')}</FormHelperText>
</FormControl>
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
{t('journal.pl.common.cancel')}
</Button>
<Button colorScheme="blue" onClick={handleSubmit} isDisabled={!name || !date}>
{t('journal.pl.common.add')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default AddDataDialog;
@@ -22,19 +22,11 @@ import {
import { CopyIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
import { FaSmile, FaMeh, FaFrown, FaSadTear } from 'react-icons/fa'
import dayjs from 'dayjs'
import { sha256 } from 'js-sha256'
import { useTranslation } from 'react-i18next'
import { getGravatarURL } from '../../../utils/gravatar'
import { ShortText } from './ShortText'
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 {
data: AttendanceData
}
@@ -42,6 +34,7 @@ interface AttendanceTableProps {
export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
const { colorMode } = useColorMode()
const toast = useToast()
const { t } = useTranslation()
const [showTable, setShowTable] = useState(false)
const getPresentColor = () => {
@@ -60,25 +53,25 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return {
icon: FaSmile,
color: 'green.500',
label: 'Отличная посещаемость'
label: t('journal.pl.attendance.emojis.excellent')
}
} else if (attendanceRate >= 0.75) {
return {
icon: FaMeh,
color: 'blue.400',
label: 'Хорошая посещаемость'
label: t('journal.pl.attendance.emojis.good')
}
} else if (attendanceRate >= 0.5) {
return {
icon: FaFrown,
color: 'orange.400',
label: 'Низкая посещаемость'
label: t('journal.pl.attendance.emojis.poor')
}
} else {
return {
icon: FaSadTear,
color: 'red.500',
label: 'Критически низкая посещаемость'
label: t('journal.pl.attendance.emojis.none')
}
}
}
@@ -97,11 +90,11 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
})
// Добавляем столбцы даты и названия занятия
headerRow.push('Дата', 'Название занятия')
headerRow.push(t('journal.pl.common.date'), t('journal.pl.common.lessonName'))
// Добавляем студентов
data.students.forEach(student => {
headerRow.push(student.name || student.value || 'Имя не определено')
headerRow.push(student.name || student.value || t('journal.pl.common.name'))
})
// Добавляем заголовок в таблицу
@@ -139,8 +132,8 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
navigator.clipboard.writeText(finalContent)
.then(() => {
toast({
title: 'Скопировано в буфер обмена',
description: 'Таблица успешно скопирована без сокращений',
title: t('journal.pl.attendance.table.copySuccess'),
description: t('journal.pl.attendance.table.copySuccessDescription'),
status: 'success',
duration: 3000,
isClosable: true,
@@ -148,8 +141,8 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
})
.catch(err => {
toast({
title: 'Ошибка копирования',
description: 'Не удалось скопировать таблицу',
title: t('journal.pl.attendance.table.copyError'),
description: t('journal.pl.attendance.table.copyErrorDescription'),
status: 'error',
duration: 3000,
isClosable: true,
@@ -171,7 +164,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return {
student,
name: student.name || student.value || 'Имя не определено',
name: student.name || student.value || t('journal.pl.common.name'),
email: student.email,
picture: student.picture || getGravatarURL(student.email),
attendedCount,
@@ -182,7 +175,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
}
if (!data.attendance?.length || !data.students?.length) {
return <Box>Нет данных для отображения</Box>
return <Box>{t('journal.pl.common.noData')}</Box>
}
return (
@@ -199,7 +192,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
onClick={copyTableData}
mr={2}
>
Копировать таблицу
{t('journal.pl.attendance.table.copy')}
</Button>
<Button
@@ -208,7 +201,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
variant="outline"
onClick={() => setShowTable(!showTable)}
>
{showTable ? 'Скрыть таблицу' : 'Показать таблицу'}
{showTable ? t('journal.pl.attendance.table.hide') : t('journal.pl.attendance.table.show')}
</Button>
</Flex>
@@ -221,7 +214,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return (
<Tooltip
key={student.sub}
label={`${emoji.label}: ${attendedCount} из ${totalLessons} занятий (${attendance.toFixed(0)}%)`}
label={`${emoji.label}: ${attendedCount} ${t('journal.pl.common.of')} ${totalLessons} ${t('journal.pl.common.students')} (${attendance.toFixed(0)}%)`}
hasArrow
>
<Box
@@ -238,13 +231,13 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
name={name}
>
<AvatarBadge boxSize='2em' bg={emoji.color}>
<Icon as={emoji.icon} color="white" boxSize={6} />
<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} из {totalLessons} ({attendance.toFixed(0)}%)
{attendedCount} {t('journal.pl.common.of')} {totalLessons} ({attendance.toFixed(0)}%)
</Text>
</Box>
</HStack>
@@ -269,17 +262,17 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
{data.teachers?.map(teacher => (
<Th key={teacher.id}>{teacher.value}</Th>
))}
<Th>Дата</Th>
<Th>Название занятия</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 || 'Имя не определено'}
name={student.name || student.value || t('journal.pl.common.name')}
/>
<Text>{student.name || student.value || 'Имя не определено'}</Text>
<Text>{student.name || student.value || t('journal.pl.common.name')}</Text>
</HStack>
</Th>
))}
@@ -0,0 +1,42 @@
import { Box, Button, Text, VStack, Icon, useColorMode } from "@chakra-ui/react";
import { FaPlus, FaUsers } from "react-icons/fa";
import { useTranslation } from 'react-i18next';
interface EmptyStateProps {
onAddData: () => void;
}
const EmptyState = ({ onAddData }: EmptyStateProps) => {
const { colorMode } = useColorMode();
const { t } = useTranslation();
return (
<Box
p={8}
borderRadius="lg"
boxShadow="md"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
textAlign="center"
>
<VStack spacing={4}>
<Icon as={FaUsers} boxSize={12} color={colorMode === 'dark' ? 'blue.300' : 'blue.500'} />
<Text fontSize="xl" fontWeight="bold">
{t('journal.pl.attendance.emptyState.title')}
</Text>
<Text color={colorMode === 'dark' ? 'gray.400' : 'gray.600'}>
{t('journal.pl.attendance.emptyState.description')}
</Text>
<Button
leftIcon={<FaPlus />}
colorScheme="blue"
onClick={onAddData}
mt={2}
>
{t('journal.pl.common.add')}
</Button>
</VStack>
</Box>
);
};
export default EmptyState;
+28 -18
View File
@@ -1,30 +1,40 @@
import React from 'react'
import { Tooltip, Text, useColorMode } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
interface ShortTextProps {
text: string
maxLength?: number
}
export const ShortText: React.FC<ShortTextProps> = ({ text, maxLength = 20 }) => {
const needShortText = text.length > maxLength
export const ShortText = ({ text, maxLength = 30 }: ShortTextProps) => {
const { t } = useTranslation()
const { colorMode } = useColorMode()
if (needShortText) {
return (
<Tooltip
label={text}
fontSize="sm"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
color={colorMode === 'dark' ? 'white' : 'gray.800'}
boxShadow="md"
borderRadius="md"
p={2}
>
<Text>{text.slice(0, maxLength)}...</Text>
</Tooltip>
)
if (!text) {
return <Text>{t('journal.pl.common.noData')}</Text>
}
return <Text>{text}</Text>
}
if (text.length <= maxLength) {
return <Text>{text}</Text>
}
const shortText = `${text.substring(0, maxLength)}...`
return (
<Tooltip
label={text}
fontSize="sm"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
color={colorMode === 'dark' ? 'white' : 'gray.800'}
boxShadow="md"
borderRadius="md"
p={2}
hasArrow
>
<Text>{shortText}</Text>
</Tooltip>
)
}
export default ShortText
@@ -15,6 +15,7 @@ import {
Text,
Badge
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { AttendanceStats } from '../hooks'
interface StatsCardProps {
@@ -23,6 +24,7 @@ interface StatsCardProps {
export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
const { colorMode } = useColorMode()
const { t } = useTranslation()
const getBgColor = () => {
return colorMode === 'dark' ? 'gray.700' : 'white'
@@ -42,19 +44,19 @@ export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
bg={getBgColor()}
mb={6}
>
<Heading size="md" mb={4}>Статистика посещаемости</Heading>
<Heading size="md" mb={4}>{t('journal.pl.attendance.stats.title')}</Heading>
<Divider mb={4} />
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
<Box>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
<Stat>
<StatLabel>Всего занятий</StatLabel>
<StatLabel>{t('journal.pl.attendance.stats.totalLessons')}</StatLabel>
<StatNumber>{stats.totalLessons}</StatNumber>
</Stat>
<Stat>
<StatLabel>Средняя посещаемость</StatLabel>
<StatLabel>{t('journal.pl.attendance.stats.averageAttendance')}</StatLabel>
<StatNumber>{stats.averageAttendance.toFixed(1)}%</StatNumber>
<Progress
value={stats.averageAttendance}
@@ -68,7 +70,7 @@ export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
<Box>
<Stat>
<StatLabel mb={3}>Топ-3 студента по посещаемости</StatLabel>
<StatLabel mb={3}>{t('journal.pl.attendance.stats.topStudents')}</StatLabel>
<VStack align="stretch" spacing={2}>
{stats.topStudents.map((student, index) => (
<HStack key={index} justify="space-between">
@@ -84,12 +86,12 @@ export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
<Text fontWeight="medium">{student.name}</Text>
</HStack>
<Text>
{student.attendance} из {stats.totalLessons} ({student.attendancePercent.toFixed(0)}%)
{student.attendance} {t('journal.pl.common.of')} {stats.totalLessons} ({student.attendancePercent.toFixed(0)}%)
</Text>
</HStack>
))}
{stats.topStudents.length === 0 && (
<Text color="gray.500">Нет данных</Text>
<Text color="gray.500">{t('journal.pl.attendance.stats.noData')}</Text>
)}
</VStack>
</Stat>