Добавлены новые переводы для управления списком уроков в файлы локализации (en.json и ru.json). Обновлен компонент CoursesList: реализована форма создания нового курса с использованием нового хука useCreateCourse, а также добавлен компонент YearGroup для отображения курсов по годам.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-23 13:26:04 +03:00
parent 4416a53bc1
commit e277308ec2
9 changed files with 310 additions and 214 deletions

View File

@ -73,6 +73,8 @@
"journal.pl.lesson.link": "Link",
"journal.pl.lesson.time": "Time",
"journal.pl.lesson.action": "Actions",
"journal.pl.lesson.expand": "Expand lesson list",
"journal.pl.lesson.collapse": "Collapse lesson list",
"journal.pl.exam.title": "Exam",
"journal.pl.exam.startExam": "Start exam",

View File

@ -69,6 +69,8 @@
"journal.pl.lesson.link": "Ссылка",
"journal.pl.lesson.time": "Время",
"journal.pl.lesson.action": "Действия",
"journal.pl.lesson.expand": "Развернуть список занятий",
"journal.pl.lesson.collapse": "Свернуть список занятий",
"journal.pl.exam.title": "Экзамен",
"journal.pl.exam.startExam": "Начать экзамен",
@ -90,6 +92,7 @@
"journal.pl.attendance.emojis.good": "Хорошая посещаемость",
"journal.pl.attendance.emojis.average": "Средняя посещаемость",
"journal.pl.attendance.emojis.poor": "Низкая посещаемость",
"journal.pl.attendance.emojis.critical": "Критическая посещаемость",
"journal.pl.attendance.emojis.none": "Нет посещений",
"journal.pl.attendance.table.copy": "Копировать таблицу",

View File

@ -0,0 +1,141 @@
import React from 'react'
import {
Box,
CardHeader,
CardBody,
Button,
Card,
Heading,
Input,
CloseButton,
FormControl,
FormLabel,
FormHelperText,
FormErrorMessage,
useBreakpointValue,
Flex,
Stack
} from '@chakra-ui/react'
import { Controller } from 'react-hook-form'
import { AddIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { ErrorSpan } from '../../style'
import { useCreateCourse } from '../hooks'
interface CreateCourseFormProps {
onClose: () => void
}
/**
* Компонент формы создания нового курса
*/
export const CreateCourseForm = ({ onClose }: CreateCourseFormProps) => {
const { t } = useTranslation()
const { control, errors, handleSubmit, onSubmit, isLoading, error } = useCreateCourse(onClose)
const headingSize = useBreakpointValue({ base: 'md', md: 'lg' })
const formSpacing = useBreakpointValue({ base: 5, md: 10 })
const buttonSize = useBreakpointValue({ base: 'md', md: 'lg' })
return (
<Card align="left">
<CardHeader display="flex" flexWrap="wrap" alignItems="center">
<Heading as="h2" size={headingSize} mt="0" flex="1" mr={2} mb={{ base: 2, md: 0 }}>
{t('journal.pl.course.createTitle')}
</Heading>
<CloseButton ml={{ base: 'auto', md: 0 }} onClick={onClose} />
</CardHeader>
<CardBody>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={formSpacing} align="left">
<Controller
control={control}
name="startDt"
rules={{ required: t('journal.pl.common.requiredField') }}
render={({ field }) => (
<FormControl
isRequired
isInvalid={Boolean(errors.startDt)}
>
<FormLabel>{t('journal.pl.common.startDate')}</FormLabel>
<Input
{...field}
required={false}
placeholder={t('journal.pl.common.selectDateTime')}
size="md"
type="date"
/>
{errors.startDt ? (
<FormErrorMessage>
{errors.startDt?.message}
</FormErrorMessage>
) : (
<FormHelperText>
{t('journal.pl.course.specifyStartDate')}
</FormHelperText>
)}
</FormControl>
)}
/>
<Controller
control={control}
name="name"
rules={{
required: t('journal.pl.common.requiredField'),
}}
render={({ field }) => (
<FormControl
isRequired
isInvalid={Boolean(errors.name)}
>
<FormLabel>{t('journal.pl.course.newLectureName')}:</FormLabel>
<Input
{...field}
required={false}
placeholder={t('journal.pl.course.namePlaceholder')}
size="md"
/>
{errors.name && (
<FormErrorMessage>
{errors.name.message}
</FormErrorMessage>
)}
</FormControl>
)}
/>
<Flex mt={formSpacing} justifyContent={{ base: 'center', md: 'flex-start' }}>
<Stack direction={{ base: 'column', sm: 'row' }} spacing={2} width={{ base: '100%', sm: 'auto' }}>
<Button
size={buttonSize}
type="submit"
leftIcon={<AddIcon />}
colorScheme="blue"
width={{ base: '100%', sm: 'auto' }}
isLoading={isLoading}
>
{t('journal.pl.common.create')}
</Button>
<Button
size={buttonSize}
variant="outline"
width={{ base: '100%', sm: 'auto' }}
onClick={onClose}
>
{t('journal.pl.common.cancel')}
</Button>
</Stack>
</Flex>
</Stack>
{error && (
<Box mt={4}>
<ErrorSpan>{(error as any).error}</ErrorSpan>
</Box>
)}
</form>
</CardBody>
</Card>
)
}

View File

@ -0,0 +1,34 @@
import React from 'react'
import { Box, Flex, Heading, Divider, VStack } from '@chakra-ui/react'
import { Course } from '../../../__data__/model'
import { CourseCard } from '../course-card'
interface YearGroupProps {
year: string
courses: Course[]
colorMode: string
}
/**
* Компонент для отображения курсов одного года
*/
export const YearGroup = ({ year, courses, colorMode }: YearGroupProps) => {
return (
<Box mb={6}>
<Flex align="center" mb={3}>
<Heading size="md" color={colorMode === 'dark' ? 'blue.300' : 'blue.600'}>
{year}
</Heading>
<Divider ml={4} flex="1" />
</Flex>
<VStack as="ul" align="stretch" spacing={{ base: 3, md: 4 }}>
{courses.map((course) => (
<CourseCard
key={course.id}
course={course}
/>
))}
</VStack>
</Box>
)
}

View File

@ -0,0 +1,2 @@
export * from './YearGroup'
export * from './CreateCourseForm'

View File

@ -1,232 +1,50 @@
import React, { useEffect, useRef, useState, useMemo } from 'react'
import dayjs from 'dayjs'
import React, { useState } from 'react'
import {
Box,
CardHeader,
CardBody,
Button,
Card,
Heading,
Container,
VStack,
Input,
CloseButton,
FormControl,
FormLabel,
FormHelperText,
FormErrorMessage,
useToast,
Text,
useColorMode,
useBreakpointValue,
Flex,
Stack,
Divider,
Text
useBreakpointValue
} from '@chakra-ui/react'
import { useForm, Controller } from 'react-hook-form'
import { AddIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { ErrorSpan } from '../style'
import { useAppSelector } from '../../__data__/store'
import { api } from '../../__data__/api/api'
import { isTeacher } from '../../utils/user'
import { PageLoader } from '../../components/page-loader/page-loader'
import { CourseCard } from './course-card'
interface NewCourseForm {
startDt: string
name: string
}
import { useGroupedCourses } from './hooks'
import { CreateCourseForm, YearGroup } from './components'
/**
* Основной компонент списка курсов
*/
export const CoursesList = () => {
const toast = useToast()
const user = useAppSelector((s) => s.user)
const { data, isLoading } = api.useCoursesListQuery()
const [createUpdateCourse, crucQuery] = api.useCreateUpdateCourseMutation()
const [showForm, setShowForm] = useState(false)
const toastRef = useRef(null)
const { t } = useTranslation()
const { colorMode } = useColorMode();
const { colorMode } = useColorMode()
// Определяем размеры для адаптивного дизайна
const buttonSize = useBreakpointValue({ base: 'md', md: 'lg' })
const headingSize = useBreakpointValue({ base: 'md', md: 'lg' })
const formSpacing = useBreakpointValue({ base: 5, md: 10 })
const containerPadding = useBreakpointValue({ base: '2', md: '4' })
const {
control,
handleSubmit,
reset,
formState: { errors },
getValues,
} = useForm<NewCourseForm>({
defaultValues: {
startDt: dayjs().format('YYYY-MM-DD'),
name: t('journal.pl.course.defaultName'),
},
})
const onSubmit = ({ startDt, name }) => {
toastRef.current = toast({
title: t('journal.pl.course.sending'),
status: 'loading',
duration: 9000,
})
createUpdateCourse({ name, startDt })
}
useEffect(() => {
if (crucQuery.isSuccess) {
const values = getValues()
if (toastRef.current) {
toast.update(toastRef.current, {
title: t('journal.pl.course.created'),
description: t('journal.pl.course.successMessage', { name: values.name }),
status: 'success',
duration: 9000,
isClosable: true,
})
}
reset()
setShowForm(false) // Закрываем форму после успешного создания
}
}, [crucQuery.isSuccess, t])
// Группировка курсов по годам
const groupedCourses = useMemo(() => {
if (!data?.body?.length) return {}
const grouped: Record<string, typeof data.body> = {}
// Сортируем курсы по дате начала (от новых к старым)
const sortedCourses = [...data.body].sort((a, b) =>
dayjs(b.startDt).valueOf() - dayjs(a.startDt).valueOf()
)
// Группируем по годам
sortedCourses.forEach(course => {
const year = dayjs(course.startDt).format('YYYY')
if (!grouped[year]) {
grouped[year] = []
}
grouped[year].push(course)
})
return grouped
}, [data?.body])
// Используем хук для группировки курсов по годам
const groupedCourses = useGroupedCourses(data?.body)
if (isLoading) {
return (
<PageLoader />
)
return <PageLoader />
}
const handleCloseForm = () => setShowForm(false)
return (
<Container maxW="container.xl" px={containerPadding}>
{isTeacher(user) && (
<Box mt={{ base: 3, md: 5 }} mb={{ base: 3, md: 5 }}>
{showForm ? (
<Card align="left">
<CardHeader display="flex" flexWrap="wrap" alignItems="center">
<Heading as="h2" size={headingSize} mt="0" flex="1" mr={2} mb={{ base: 2, md: 0 }}>
{t('journal.pl.course.createTitle')}
</Heading>
<CloseButton ml={{ base: 'auto', md: 0 }} onClick={() => setShowForm(false)} />
</CardHeader>
<CardBody>
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={formSpacing} align="left">
<Controller
control={control}
name="startDt"
rules={{ required: t('journal.pl.common.requiredField') }}
render={({ field }) => (
<FormControl
isRequired
isInvalid={Boolean(errors.startDt)}
>
<FormLabel>{t('journal.pl.common.startDate')}</FormLabel>
<Input
{...field}
required={false}
placeholder={t('journal.pl.common.selectDateTime')}
size="md"
type="date"
/>
{errors.startDt ? (
<FormErrorMessage>
{errors.startDt?.message}
</FormErrorMessage>
) : (
<FormHelperText>
{t('journal.pl.course.specifyStartDate')}
</FormHelperText>
)}
</FormControl>
)}
/>
<Controller
control={control}
name="name"
rules={{
required: t('journal.pl.common.requiredField'),
}}
render={({ field }) => (
<FormControl
isRequired
isInvalid={Boolean(errors.name)}
>
<FormLabel>{t('journal.pl.course.newLectureName')}:</FormLabel>
<Input
{...field}
required={false}
placeholder={t('journal.pl.course.namePlaceholder')}
size="md"
/>
{errors.name && (
<FormErrorMessage>
{errors.name.message}
</FormErrorMessage>
)}
</FormControl>
)}
/>
<Flex mt={formSpacing} justifyContent={{ base: 'center', md: 'flex-start' }}>
<Stack direction={{ base: 'column', sm: 'row' }} spacing={2} width={{ base: '100%', sm: 'auto' }}>
<Button
size={buttonSize}
type="submit"
leftIcon={<AddIcon />}
colorScheme="blue"
width={{ base: '100%', sm: 'auto' }}
isLoading={crucQuery.isLoading}
>
{t('journal.pl.common.create')}
</Button>
<Button
size={buttonSize}
variant="outline"
width={{ base: '100%', sm: 'auto' }}
onClick={() => setShowForm(false)}
>
{t('journal.pl.common.cancel')}
</Button>
</Stack>
</Flex>
</VStack>
{crucQuery?.error && (
<Box mt={4}>
<ErrorSpan>{(crucQuery?.error as any).error}</ErrorSpan>
</Box>
)}
</form>
</CardBody>
</Card>
<CreateCourseForm onClose={handleCloseForm} />
) : (
<Box p={{ base: 1, md: 2 }} m={{ base: 1, md: 2 }}>
<Button
@ -247,23 +65,13 @@ export const CoursesList = () => {
Object.entries(groupedCourses)
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA)) // Сортируем годы по убыванию
.map(([year, courses]) => (
<Box key={year} mb={6}>
<Flex align="center" mb={3}>
<Heading size="md" color={colorMode === 'dark' ? 'blue.300' : 'blue.600'}>
{year}
</Heading>
<Divider ml={4} flex="1" />
</Flex>
<VStack as="ul" align="stretch" spacing={{ base: 3, md: 4 }}>
{courses.map((c) => (
<CourseCard
key={c.id}
course={c}
/>
))}
</VStack>
</Box>
))
<YearGroup
key={year}
year={year}
courses={courses}
colorMode={colorMode}
/>
))
) : (
<Box textAlign="center" py={10}>
<Text color="gray.500">{t('journal.pl.course.noCourses')}</Text>

View File

@ -0,0 +1,2 @@
export * from './useGroupedCourses'
export * from './useCreateCourse'

View File

@ -0,0 +1,72 @@
import { useRef, useEffect } from 'react'
import dayjs from 'dayjs'
import { useForm } from 'react-hook-form'
import { useToast } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { api } from '../../../__data__/api/api'
interface NewCourseForm {
startDt: string
name: string
}
/**
* Хук для управления созданием нового курса
* @param onSuccess Коллбэк, который будет вызван после успешного создания курса
* @returns Объект с формой и функциями для создания курса
*/
export const useCreateCourse = (onSuccess: () => void) => {
const toast = useToast()
const toastRef = useRef(null)
const { t } = useTranslation()
const [createUpdateCourse, crucQuery] = api.useCreateUpdateCourseMutation()
const {
control,
handleSubmit,
reset,
formState: { errors },
getValues,
} = useForm<NewCourseForm>({
defaultValues: {
startDt: dayjs().format('YYYY-MM-DD'),
name: t('journal.pl.course.defaultName'),
},
})
const onSubmit = ({ startDt, name }: NewCourseForm) => {
toastRef.current = toast({
title: t('journal.pl.course.sending'),
status: 'loading',
duration: 9000,
})
createUpdateCourse({ name, startDt })
}
useEffect(() => {
if (crucQuery.isSuccess) {
const values = getValues()
if (toastRef.current) {
toast.update(toastRef.current, {
title: t('journal.pl.course.created'),
description: t('journal.pl.course.successMessage', { name: values.name }),
status: 'success',
duration: 9000,
isClosable: true,
})
}
reset()
onSuccess()
}
}, [crucQuery.isSuccess, t, onSuccess, reset, getValues, toast])
return {
control,
errors,
handleSubmit,
onSubmit,
isLoading: crucQuery.isLoading,
error: crucQuery.error
}
}

View File

@ -0,0 +1,32 @@
import { useMemo } from 'react'
import dayjs from 'dayjs'
import { Course } from '../../../__data__/model'
/**
* Хук для группировки курсов по годам их начала
* @param courses Массив курсов для группировки
* @returns Объект с группировкой курсов по годам, отсортированный от новых к старым
*/
export const useGroupedCourses = (courses?: Course[]) => {
return useMemo(() => {
if (!courses?.length) return {}
const grouped: Record<string, Course[]> = {}
// Сортируем курсы по дате начала (от новых к старым)
const sortedCourses = [...courses].sort((a, b) =>
dayjs(b.startDt).valueOf() - dayjs(a.startDt).valueOf()
)
// Группируем по годам
sortedCourses.forEach(course => {
const year = dayjs(course.startDt).format('YYYY')
if (!grouped[year]) {
grouped[year] = []
}
grouped[year].push(course)
})
return grouped
}, [courses])
}