Добавлены новые зависимости: "react-select" и "@floating-ui/core". Реализована локализация с использованием i18next, добавлены переводы для английского и русского языков. Обновлены компоненты для поддержки локализации, включая AppHeader, Attendance, Dashboard и другие. Улучшена логика отображения данных и взаимодействия с пользователем.
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user