Обновлен компонент CourseCard: добавлены адаптивные размеры и улучшена компоновка для различных экранов. Реализована возможность сворачивания/разворачивания списка уроков. Удален компонент CourseDetails, его функциональность интегрирована в CourseCard. Обновлен компонент CoursesList для поддержки адаптивного дизайна и улучшения пользовательского интерфейса.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-23 12:51:34 +03:00
parent 142ee6c496
commit ef8f7356e9
4 changed files with 141 additions and 164 deletions

View File

@ -34,13 +34,16 @@ import {
TagLeftIcon, TagLeftIcon,
Wrap, Wrap,
WrapItem, WrapItem,
useBreakpointValue,
useMediaQuery,
Icon
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FaExpand, FaCompress } from 'react-icons/fa'
import { api } from '../../__data__/api/api' import { api } from '../../__data__/api/api'
import { ArrowUpIcon, LinkIcon, CalendarIcon, ViewIcon, WarningIcon, StarIcon, TimeIcon } from '@chakra-ui/icons' import { ArrowUpIcon, LinkIcon, CalendarIcon, ViewIcon, WarningIcon, StarIcon, TimeIcon } from '@chakra-ui/icons'
import { Course } from '../../__data__/model' import { Course } from '../../__data__/model'
import { CourseDetails } from './course-details'
export const CourseCard = ({ course }: { course: Course }) => { export const CourseCard = ({ course }: { course: Course }) => {
const [getLessonList, populatedCourse] = api.useLazyGetCourseByIdQuery() const [getLessonList, populatedCourse] = api.useLazyGetCourseByIdQuery()
@ -51,9 +54,25 @@ export const CourseCard = ({ course }: { course: Course }) => {
}), }),
}) })
const [isOpened, setIsOpened] = useState(false) const [isOpened, setIsOpened] = useState(false)
const [isLessonsExpanded, setIsLessonsExpanded] = useState(false)
const { t } = useTranslation() const { t } = useTranslation()
const { colorMode } = useColorMode() const { colorMode } = useColorMode()
// Адаптивные размеры и компоновка для различных размеров экрана
const headingSize = useBreakpointValue({ base: 'sm', md: 'md' })
const buttonSize = useBreakpointValue({ base: 'xs', md: 'md' })
const tagSize = useBreakpointValue({ base: 'sm', md: 'md' })
const avatarSize = useBreakpointValue({ base: 'xs', md: 'sm' })
const cardPadding = useBreakpointValue({ base: 2, md: 4 })
// Используем медиа-запросы для определения направления бейджей
const [isLargerThanSm] = useMediaQuery("(min-width: 480px)")
const [badgeDirection, setBadgeDirection] = useState<'column' | 'row'>('row')
useEffect(() => {
setBadgeDirection(isLargerThanSm ? 'row' : 'column')
}, [isLargerThanSm])
useEffect(() => { useEffect(() => {
if (isOpened) { if (isOpened) {
getLessonList(course.id, true) getLessonList(course.id, true)
@ -64,6 +83,10 @@ export const CourseCard = ({ course }: { course: Course }) => {
setIsOpened((opened) => !opened) setIsOpened((opened) => !opened)
}, [setIsOpened]) }, [setIsOpened])
const handleToggleExpand = useCallback(() => {
setIsLessonsExpanded((expanded) => !expanded)
}, [setIsLessonsExpanded])
// Рассчитываем статистику курса и посещаемости // Рассчитываем статистику курса и посещаемости
const stats = useMemo(() => { const stats = useMemo(() => {
if (!populatedCourse.data) { if (!populatedCourse.data) {
@ -173,9 +196,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
bg={colorMode === 'dark' ? 'gray.700' : 'white'} bg={colorMode === 'dark' ? 'gray.700' : 'white'}
borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'} borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}
> >
<CardHeader pb={2}> <CardHeader pb={2} px={{ base: 3, md: 5 }}>
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center" flexWrap="wrap">
<Heading as="h2" size="md"> <Heading as="h2" size={headingSize} mb={{ base: 2, md: 0 }}>
{course.name} {course.name}
</Heading> </Heading>
<Tooltip label={isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')}> <Tooltip label={isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')}>
@ -190,21 +213,37 @@ export const CourseCard = ({ course }: { course: Course }) => {
/> />
</Tooltip> </Tooltip>
</Flex> </Flex>
<HStack spacing={2} mt={2}> <Flex gap={2} mt={2} flexWrap="wrap">
<Badge colorScheme="blue"> {badgeDirection === 'column' ? (
<HStack spacing={1}> <VStack align="start" spacing={2} width="100%">
<CalendarIcon boxSize="3" /> <Badge colorScheme="blue">
<Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text> <HStack spacing={1}>
<CalendarIcon boxSize="3" />
<Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text>
</HStack>
</Badge>
<Badge colorScheme="purple">
{stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
</VStack>
) : (
<HStack spacing={2}>
<Badge colorScheme="blue">
<HStack spacing={1}>
<CalendarIcon boxSize="3" />
<Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text>
</HStack>
</Badge>
<Badge colorScheme="purple">
{stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
</HStack> </HStack>
</Badge> )}
<Badge colorScheme="purple"> </Flex>
{stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
</HStack>
</CardHeader> </CardHeader>
{!isOpened && ( {!isOpened && (
<CardBody pt={2} pb={3}> <CardBody pt={2} pb={3} px={{ base: 3, md: 5 }}>
{lessonListLoading ? ( {lessonListLoading ? (
<Flex justify="center" py={3}> <Flex justify="center" py={3}>
<Spinner size="sm" /> <Spinner size="sm" />
@ -214,7 +253,7 @@ export const CourseCard = ({ course }: { course: Course }) => {
<Text fontSize="sm" fontWeight="medium" mb={2}> <Text fontSize="sm" fontWeight="medium" mb={2}>
{t('journal.pl.attendance.stats.topStudents')}: {t('journal.pl.attendance.stats.topStudents')}:
</Text> </Text>
<AvatarGroup size="sm" max={3} mb={1}> <AvatarGroup size={avatarSize} max={3} mb={1}>
{attendanceStats.topStudents.map(student => ( {attendanceStats.topStudents.map(student => (
<Avatar <Avatar
key={student.id} key={student.id}
@ -233,8 +272,8 @@ export const CourseCard = ({ course }: { course: Course }) => {
)} )}
{isOpened && ( {isOpened && (
<CardBody pt={2}> <CardBody pt={2} px={{ base: 3, md: 5 }}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mb={4}> <SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 3, md: 4 }} mb={4}>
<Stat> <Stat>
<StatLabel>{t('journal.pl.course.completedLessons')}</StatLabel> <StatLabel>{t('journal.pl.course.completedLessons')}</StatLabel>
<HStack align="baseline"> <HStack align="baseline">
@ -253,9 +292,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
<Stat> <Stat>
<StatLabel>{t('journal.pl.course.upcomingLessons')}</StatLabel> <StatLabel>{t('journal.pl.course.upcomingLessons')}</StatLabel>
<HStack align="baseline"> <HStack align="baseline" flexWrap="wrap">
<StatNumber>{stats.upcomingLessons}</StatNumber> <StatNumber>{stats.upcomingLessons}</StatNumber>
<Text color="gray.500"> <Text color="gray.500" fontSize={{ base: 'xs', md: 'sm' }}>
<TimeIcon ml={1} mr={1} /> <TimeIcon ml={1} mr={1} />
{populatedCourse.data?.lessons {populatedCourse.data?.lessons
.filter(lesson => dayjs(lesson.date).isAfter(dayjs())) .filter(lesson => dayjs(lesson.date).isAfter(dayjs()))
@ -290,9 +329,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
{attendanceStats.topStudents.map((student, index) => ( {attendanceStats.topStudents.map((student, index) => (
<HStack key={student.id} spacing={2}> <HStack key={student.id} spacing={2}>
<Avatar size="sm" name={student.name} src={student.avatarUrl} /> <Avatar size={avatarSize} name={student.name} src={student.avatarUrl} />
<Box flex="1"> <Box flex="1">
<Text fontSize="sm" fontWeight="medium">{student.name}</Text> <Text fontSize="sm" fontWeight="medium" isTruncated maxWidth={{ base: '120px', sm: '100%' }}>{student.name}</Text>
<Progress <Progress
value={student.percent} value={student.percent}
size="xs" size="xs"
@ -318,9 +357,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
{attendanceStats.lowAttendanceStudents.map((student) => ( {attendanceStats.lowAttendanceStudents.map((student) => (
<HStack key={student.id} spacing={2}> <HStack key={student.id} spacing={2}>
<Avatar size="sm" name={student.name} src={student.avatarUrl} /> <Avatar size={avatarSize} name={student.name} src={student.avatarUrl} />
<Box flex="1"> <Box flex="1">
<Text fontSize="sm" fontWeight="medium">{student.name}</Text> <Text fontSize="sm" fontWeight="medium" isTruncated maxWidth={{ base: '120px', sm: '100%' }}>{student.name}</Text>
<Progress <Progress
value={student.percent} value={student.percent}
size="xs" size="xs"
@ -349,8 +388,19 @@ export const CourseCard = ({ course }: { course: Course }) => {
{!populatedCourse.isFetching && populatedCourse.isSuccess && populatedCourse.data && ( {!populatedCourse.isFetching && populatedCourse.isSuccess && populatedCourse.data && (
<> <>
<Heading size="sm" mb={3}>{t('journal.pl.lesson.list')}</Heading> <Flex justify="space-between" align="center" mb={3}>
<VStack align="stretch" spacing={2} maxH="300px" overflowY="auto" pr={2}> <Heading size="sm">{t('journal.pl.lesson.list')}</Heading>
<Tooltip label={isLessonsExpanded ? t('journal.pl.lesson.collapse') : t('journal.pl.lesson.expand')}>
<IconButton
aria-label={isLessonsExpanded ? t('journal.pl.lesson.collapse') : t('journal.pl.lesson.expand')}
icon={isLessonsExpanded ? <Icon as={FaCompress} /> : <Icon as={FaExpand} />}
size="xs"
onClick={handleToggleExpand}
/>
</Tooltip>
</Flex>
<VStack align="stretch" spacing={2} maxH={isLessonsExpanded ? "none" : "300px"} overflowY={isLessonsExpanded ? "visible" : "auto"} pr={2}>
{[...populatedCourse.data.lessons] {[...populatedCourse.data.lessons]
.sort((a, b) => dayjs(b.date).valueOf() - dayjs(a.date).valueOf()) .sort((a, b) => dayjs(b.date).valueOf() - dayjs(a.date).valueOf())
.map(lesson => { .map(lesson => {
@ -379,10 +429,18 @@ export const CourseCard = ({ course }: { course: Course }) => {
(colorMode === 'dark' ? 'blue.400' : 'blue.500') (colorMode === 'dark' ? 'blue.400' : 'blue.500')
} }
> >
<Flex justify="space-between" align="center"> <Flex justify="space-between" align={{ base: 'flex-start', sm: 'center' }} flexDirection={{ base: 'column', sm: 'row' }} gap={{ base: 2, sm: 0 }}>
<Box> <Box>
<Text fontWeight="medium">{lesson.name}</Text> <Text
<HStack spacing={2} mt={1}> fontWeight="medium"
fontSize={{ base: 'sm', md: 'md' }}
noOfLines={2}
wordBreak="break-word"
maxWidth={{ base: '100%', sm: '200px', md: '300px' }}
>
{lesson.name}
</Text>
<HStack spacing={2} mt={1} flexWrap="wrap">
<Tag size="sm" colorScheme={isPast ? "green" : "blue"} borderRadius="full"> <Tag size="sm" colorScheme={isPast ? "green" : "blue"} borderRadius="full">
<TagLeftIcon as={CalendarIcon} boxSize='10px' /> <TagLeftIcon as={CalendarIcon} boxSize='10px' />
<TagLabel>{dayjs(lesson.date).format('DD.MM.YYYY')}</TagLabel> <TagLabel>{dayjs(lesson.date).format('DD.MM.YYYY')}</TagLabel>
@ -405,6 +463,8 @@ export const CourseCard = ({ course }: { course: Course }) => {
variant="ghost" variant="ghost"
colorScheme="blue" colorScheme="blue"
leftIcon={<ViewIcon />} leftIcon={<ViewIcon />}
ml={{ base: 0, sm: 'auto' }}
alignSelf={{ base: 'flex-end', sm: 'center' }}
> >
{t('journal.pl.common.open')} {t('journal.pl.common.open')}
</Button> </Button>
@ -418,16 +478,17 @@ export const CourseCard = ({ course }: { course: Course }) => {
</CardBody> </CardBody>
)} )}
<CardFooter pt={2}> <CardFooter pt={2} px={{ base: 3, md: 5 }}>
<ButtonGroup spacing={2} width="100%"> <ButtonGroup spacing={{ base: 1, md: 2 }} width="100%" flexDirection={{ base: 'column', sm: 'row' }}>
<Tooltip label={t('journal.pl.lesson.list')}> <Tooltip label={t('journal.pl.lesson.list')}>
<Button <Button
leftIcon={<ViewIcon />} leftIcon={<ViewIcon />}
as={ConnectedLink} as={ConnectedLink}
colorScheme="blue" colorScheme="blue"
size="md" size={buttonSize}
flexGrow={1} flexGrow={1}
to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`} to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`}
mb={{ base: 2, sm: 0 }}
> >
{t('journal.pl.lesson.list')} {t('journal.pl.lesson.list')}
</Button> </Button>
@ -440,7 +501,7 @@ export const CourseCard = ({ course }: { course: Course }) => {
as={ConnectedLink} as={ConnectedLink}
variant="outline" variant="outline"
colorScheme="blue" colorScheme="blue"
size="md" size={buttonSize}
flexGrow={1} flexGrow={1}
to={generatePath( to={generatePath(
`${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`, `${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`,

View File

@ -1,110 +0,0 @@
import React from 'react'
import dayjs from 'dayjs'
import { Link as ConnectedLink } from 'react-router-dom'
import { getNavigationValue, getHistory } from '@brojs/cli'
import { Stack, Heading, Link, Button, Tooltip, Box } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { LinkIcon } from '@chakra-ui/icons'
import { useAppSelector } from '../../__data__/store'
import { isTeacher } from '../../utils/user'
import { PopulatedCourse } from '../../__data__/model'
import { api } from '../../__data__/api/api'
type CourseDetailsProps = {
populatedCourse: PopulatedCourse
}
const history = getHistory()
export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
const user = useAppSelector((s) => s.user)
const exam = populatedCourse.examWithJury
const [toggleExamWithJury, examWithJuryRequest] =
api.useToggleExamWithJuryMutation()
const { t } = useTranslation()
return (
<>
{isTeacher(user) && (
<Heading as="h3" mt={4} mb={3} size="lg">
{t('journal.pl.exam.title')}: {exam?.name}{' '}
{exam && getNavigationValue('exam.main') && getNavigationValue('link.exam.details') && (
<Tooltip label={t('journal.pl.exam.startExam')} fontSize="12px" top="16px">
<Button
leftIcon={<LinkIcon />}
as={'a'}
colorScheme="blue"
href={
getNavigationValue('exam.main') +
getNavigationValue('link.exam.details')
.replace(':courseId', populatedCourse.id)
.replace(':examId', exam.id)
}
onClick={(event) => {
event.preventDefault()
history.push(
getNavigationValue('exam.main') +
getNavigationValue('link.exam.details')
.replace(':courseId', populatedCourse.id)
.replace(':examId', exam.id),
)
}}
>
{t('journal.pl.exam.open')}
</Button>
</Tooltip>
)}
</Heading>
)}
{!Boolean(exam) && (
<>
<Heading as="h3" mt={4} mb={3} size="lg">
{t('journal.pl.exam.notSpecified')}
</Heading>
<Box mt={10}>
<Tooltip label={t('journal.pl.exam.createWithJury')} fontSize="12px" top="16px">
<Button
colorScheme="blue"
mt={['16px', 0]}
variant="outline"
isLoading={examWithJuryRequest.isLoading}
onClick={() => toggleExamWithJury(populatedCourse.id)}
>
{t('journal.pl.common.create')}
</Button>
</Tooltip>
</Box>
</>
)}
{Boolean(exam) && (
<>
<Heading as="h3" mt={4} mb={3} size="lg">
{t('journal.pl.exam.juryCount')}:
</Heading>
<Heading as="h3" mt={4} mb={3} size="lg">
{populatedCourse.examWithJury.jury.length}
</Heading>
</>
)}
<Heading as="h3" mt={4} mb={3} size="lg">
{t('journal.pl.lesson.list')}:
</Heading>
<Stack>
{populatedCourse?.lessons?.map((lesson) => (
<Link
as={ConnectedLink}
key={lesson.id}
to={
isTeacher(user)
? `${getNavigationValue('journal.main')}/lesson/${populatedCourse.id}/${lesson.id}`
: ''
}
>
{lesson.name}
</Link>
))}
</Stack>
</>
)
}

View File

@ -17,6 +17,9 @@ import {
FormErrorMessage, FormErrorMessage,
useToast, useToast,
useColorMode, useColorMode,
useBreakpointValue,
Flex,
Stack
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useForm, Controller } from 'react-hook-form' import { useForm, Controller } from 'react-hook-form'
import { AddIcon } from '@chakra-ui/icons' import { AddIcon } from '@chakra-ui/icons'
@ -44,6 +47,12 @@ export const CoursesList = () => {
const { t } = useTranslation() 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 { const {
control, control,
@ -80,6 +89,7 @@ export const CoursesList = () => {
}) })
} }
reset() reset()
setShowForm(false) // Закрываем форму после успешного создания
} }
}, [crucQuery.isSuccess, t]) }, [crucQuery.isSuccess, t])
@ -90,20 +100,20 @@ export const CoursesList = () => {
} }
return ( return (
<Container maxW="container.xl"> <Container maxW="container.xl" px={containerPadding}>
{isTeacher(user) && ( {isTeacher(user) && (
<Box mt="15" mb="15"> <Box mt={{ base: 3, md: 5 }} mb={{ base: 3, md: 5 }}>
{showForm ? ( {showForm ? (
<Card align="left"> <Card align="left">
<CardHeader display="flex"> <CardHeader display="flex" flexWrap="wrap" alignItems="center">
<Heading as="h2" mt="0"> <Heading as="h2" size={headingSize} mt="0" flex="1" mr={2} mb={{ base: 2, md: 0 }}>
{t('journal.pl.course.createTitle')} {t('journal.pl.course.createTitle')}
</Heading> </Heading>
<CloseButton ml="auto" onClick={() => setShowForm(false)} /> <CloseButton ml={{ base: 'auto', md: 0 }} onClick={() => setShowForm(false)} />
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={10} align="left"> <VStack spacing={formSpacing} align="left">
<Controller <Controller
control={control} control={control}
name="startDt" name="startDt"
@ -160,38 +170,54 @@ export const CoursesList = () => {
)} )}
/> />
<Box mt={10}> <Flex mt={formSpacing} justifyContent={{ base: 'center', md: 'flex-start' }}>
<Button <Stack direction={{ base: 'column', sm: 'row' }} spacing={2} width={{ base: '100%', sm: 'auto' }}>
size="lg" <Button
type="submit" size={buttonSize}
leftIcon={<AddIcon />} type="submit"
colorScheme="blue" leftIcon={<AddIcon />}
colorScheme="blue"
width={{ base: '100%', sm: 'auto' }}
isLoading={crucQuery.isLoading}
> >
{t('journal.pl.common.create')} {t('journal.pl.common.create')}
</Button> </Button>
</Box> <Button
size={buttonSize}
variant="outline"
width={{ base: '100%', sm: 'auto' }}
onClick={() => setShowForm(false)}
>
{t('journal.pl.common.cancel')}
</Button>
</Stack>
</Flex>
</VStack> </VStack>
{crucQuery?.error && ( {crucQuery?.error && (
<ErrorSpan>{(crucQuery?.error as any).error}</ErrorSpan> <Box mt={4}>
<ErrorSpan>{(crucQuery?.error as any).error}</ErrorSpan>
</Box>
)} )}
</form> </form>
</CardBody> </CardBody>
</Card> </Card>
) : ( ) : (
<Box p="2" m="2"> <Box p={{ base: 1, md: 2 }} m={{ base: 1, md: 2 }}>
<Button <Button
leftIcon={<AddIcon />} leftIcon={<AddIcon />}
colorScheme="green" colorScheme="green"
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
> size={buttonSize}
width={{ base: '100%', sm: 'auto' }}
>
{t('journal.pl.common.add')} {t('journal.pl.common.add')}
</Button> </Button>
</Box> </Box>
)} )}
</Box> </Box>
)} )}
<VStack as="ul" align="stretch"> <VStack as="ul" align="stretch" spacing={{ base: 3, md: 4 }}>
{data?.body?.map((c) => ( {data?.body?.map((c) => (
<CourseCard <CourseCard
key={c.id} key={c.id}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { generatePath, Link, useParams } from 'react-router-dom' import { generatePath, Link, useParams } from 'react-router-dom'
import { getNavigationsValue, getFeatures } from '@brojs/cli' import { getNavigationValue, getFeatures } from '@brojs/cli'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@ -219,7 +219,7 @@ const LessonList = () => {
<BreadcrumbsWrapper> <BreadcrumbsWrapper>
<Breadcrumb> <Breadcrumb>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink as={Link} to={getNavigationsValue('journal.main')}> <BreadcrumbLink as={Link} to={getNavigationValue('journal.main')}>
{t('journal.pl.common.journal')} {t('journal.pl.common.journal')}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>