journal.pl/src/pages/lesson-list/lesson-list.tsx

569 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useRef, useState } from 'react'
import dayjs, { formatDate } from '../../utils/dayjs-config'
import { generatePath, Link, useParams } from 'react-router-dom'
import { getNavigationValue, getFeatures } from '@brojs/cli'
import {
Container,
Box,
Button,
useToast,
Toast,
TableContainer,
Table,
Thead,
Tr,
Th,
Tbody,
Td,
Text,
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
useBreakpointValue,
Flex,
Menu,
MenuButton,
MenuList,
MenuItem,
useColorMode,
Portal,
} from '@chakra-ui/react'
import { AddIcon, EditIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { useAppSelector } from '../../__data__/store'
import { api } from '../../__data__/api/api'
import { isTeacher } from '../../utils/user'
import { Lesson } from '../../__data__/model'
import { XlSpinner, useSetBreadcrumbs } from '../../components'
import { qrCode } from '../../assets'
import { LessonForm } from './components/lessons-form'
import { Bar } from './components/bar'
import { LessonItems } from './components/lesson-items'
import { CourseStatistics } from './components/statistics'
const features = getFeatures('journal')
const barFeature = features?.['lesson.bar']
const groupByDate = features?.['group.by.date']
const courseStatistics = features?.['course.statistics']
const LessonList = () => {
const { courseId } = useParams()
const user = useAppSelector((s) => s.user)
const { data, isLoading, error, isSuccess } = api.useLessonListQuery(courseId)
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
const [generateLessonsMutation, {
data: generateLessons,
isLoading: isLoadingGenerateLessons,
error: errorGenerateLessons,
isSuccess: isSuccessGenerateLessons
}, ] = api.useGenerateLessonsMutation()
const { colorMode } = useColorMode()
const [createLesson, crLQuery] = api.useCreateLessonMutation()
const [deleteLesson, deletingRqst] = api.useDeleteLessonMutation()
const [updateLesson, updateLessonRqst] = api.useUpdateLessonMutation()
const [showForm, setShowForm] = useState(false)
const [lessonToDelete, setlessonToDelete] = useState<Lesson>(null)
const cancelRef = React.useRef()
const toast = useToast()
const toastRef = useRef(null)
const createdLessonRef = useRef(null)
const [editLesson, setEditLesson] = useState<Lesson>(null)
const [suggestedLessonToCreate, setSuggestedLessonToCreate] = useState(null)
const { t } = useTranslation()
// Устанавливаем хлебные крошки для страницы списка уроков
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: courseData?.name || t('journal.pl.breadcrumbs.course'),
isCurrentPage: true
}
])
const sorted = useMemo(
() => [...(data?.body || [])]?.sort((a, b) => (a.date > b.date ? 1 : -1)),
[data, data?.body],
)
// Найдем максимальное количество студентов среди всех уроков
const maxStudents = useMemo(() => {
if (!sorted || sorted.length === 0) return 1
const max = Math.max(...sorted.map(lesson => lesson.students?.length || 0))
return max > 0 ? max : 1 // Избегаем деления на ноль
}, [sorted])
// Функция для определения цвета на основе посещаемости
const getAttendanceColor = (attendance: number) => {
const percentage = (attendance / maxStudents) * 100
if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } }
if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } }
if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } }
if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } }
return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } }
}
const lessonCalc = useMemo(() => {
if (!isSuccess) {
return []
}
if (!groupByDate) {
return [{ date: '', data: sorted }]
}
const lessonsData = []
for (let i = 0; i < sorted.length; i++) {
const element = sorted[i]
const find = lessonsData.find(
(item) => dayjs(element.date).diff(dayjs(item.date), 'day') === 0,
)
if (find) {
find.data.push(element)
} else {
lessonsData.push({
date: element.date,
data: [element],
})
}
}
return lessonsData.sort((a, b) => (a.date < b.date ? 1 : -1))
}, [groupByDate, isSuccess, sorted])
useEffect(() => {
if (isSuccessGenerateLessons) {
console.log(generateLessons)
// Проверяем корректность ответа API
if (typeof generateLessons?.body === 'string') {
toast({
title: t('journal.pl.lesson.aiGenerationError'),
description: t('journal.pl.lesson.tryAgainLater'),
status: 'error',
duration: 5000,
isClosable: true,
});
}
}
}, [isSuccessGenerateLessons, generateLessons])
useEffect(() => {
if (errorGenerateLessons) {
toast({
title: t('journal.pl.lesson.aiGenerationError'),
description: t('journal.pl.lesson.tryAgainLater'),
status: 'error',
duration: 5000,
isClosable: true,
});
}
}, [errorGenerateLessons])
const onSubmit = (lessonData) => {
toastRef.current = toast({
title: t('journal.pl.common.sending'),
status: 'loading',
duration: 9000,
})
createdLessonRef.current = lessonData
if (editLesson) updateLesson(lessonData)
else createLesson({ courseId, ...lessonData })
}
useEffect(() => {
if (deletingRqst.isError) {
toast({
title: (deletingRqst.error as any)?.error,
status: 'error',
duration: 3000,
})
}
if (deletingRqst.isSuccess) {
const lesson = { ...lessonToDelete }
toast({
status: 'warning',
duration: 9000,
render: ({ id, ...toastProps }) => (
<Toast
{...toastProps}
id={id}
title={
<>
<Box pb={3}>
<Text fontSize="xl">{t('journal.pl.lesson.deletedMessage', { name: lesson.name })}</Text>
</Box>
<Button
onClick={() => {
createLesson({ courseId, ...lesson })
toast.close(id)
}}
>
{t('journal.pl.common.restored')}
</Button>
</>
}
/>
),
})
setlessonToDelete(null)
}
}, [deletingRqst.isLoading, deletingRqst.isSuccess, deletingRqst.isError])
useEffect(() => {
if (crLQuery.isSuccess) {
const toastProps = {
title: t('journal.pl.lesson.created'),
description: t('journal.pl.lesson.successMessage', { name: createdLessonRef.current?.name }),
status: 'success' as const,
duration: 9000,
isClosable: true,
}
if (toastRef.current) toast.update(toastRef.current, toastProps)
else toast(toastProps)
setShowForm(false)
}
}, [crLQuery.isSuccess])
useEffect(() => {
if (updateLessonRqst.isSuccess) {
const toastProps = {
title: t('journal.pl.lesson.updated'),
description: t('journal.pl.lesson.updateMessage', { name: createdLessonRef.current?.name }),
status: 'success' as const,
duration: 9000,
isClosable: true,
}
if (toastRef.current) toast.update(toastRef.current, toastProps)
else toast(toastProps)
setShowForm(false)
}
}, [updateLessonRqst.isSuccess])
// Обработчик выбора предложения ИИ в форме
const handleSelectAiSuggestion = (suggestion) => {
setSuggestedLessonToCreate(suggestion)
}
// Очищаем выбранную сгенерированную лекцию при закрытии формы
const handleCancelForm = () => {
setShowForm(false)
setEditLesson(null)
setSuggestedLessonToCreate(null)
// Сбрасываем флаги генерации, чтобы при повторном открытии формы
// генерация запускалась снова при необходимости
// (особенно если была ошибка в предыдущей генерации)
}
// Обработчик открытия формы создания новой лекции
const handleOpenForm = () => {
setShowForm(true)
// Запускаем генерацию лекций только при открытии формы создания новой лекции
// и если генерация ещё не была запущена или предыдущая попытка завершилась с ошибкой
const shouldGenerateAgain = !generateLessons ||
typeof generateLessons?.body === 'string' ||
errorGenerateLessons;
if (isTeacher(user) && !editLesson && (!isLoadingGenerateLessons && shouldGenerateAgain)) {
generateLessonsMutation(courseId)
}
}
// Обработчик редактирования существующей лекции
const handleEditLesson = (lesson) => {
setEditLesson(lesson)
setShowForm(true)
// Не запускаем генерацию при редактировании
}
// Обработчик повторной генерации предложений ИИ
const handleRetryAiGeneration = () => {
if (isTeacher(user) && !isLoadingGenerateLessons) {
generateLessonsMutation(courseId)
}
}
// Добавляем определение размера экрана
const isMobile = useBreakpointValue({ base: true, md: false })
if (isLoading) {
return <XlSpinner />
}
return (
<>
<AlertDialog
isOpen={Boolean(lessonToDelete)}
leastDestructiveRef={cancelRef}
onClose={() => setlessonToDelete(null)}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('journal.pl.lesson.deleteConfirm', { date: formatDate(lessonToDelete?.date, 'DD.MM.YY') })}
</AlertDialogHeader>
<AlertDialogBody>
{t('journal.pl.lesson.deleteWarning')}
</AlertDialogBody>
<AlertDialogFooter>
<Button
isDisabled={deletingRqst.isLoading}
ref={cancelRef}
onClick={() => setlessonToDelete(null)}
>
{t('journal.pl.cancel')}
</Button>
<Button
colorScheme="red"
loadingText=""
isLoading={deletingRqst.isLoading}
onClick={() => deleteLesson(lessonToDelete.id)}
ml={3}
>
{t('journal.pl.delete')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
<Container maxW="container.xl" position="relative">
{isTeacher(user) && (
<Box mt="15" mb="15">
{showForm ? (
<LessonForm
key={editLesson?.id || 'new-lesson'}
isLoading={crLQuery.isLoading}
onSubmit={onSubmit}
onCancel={handleCancelForm}
error={(crLQuery.error as any)?.error}
lesson={editLesson || suggestedLessonToCreate || undefined}
title={editLesson ? t('journal.pl.lesson.editTitle') : t('journal.pl.lesson.createTitle')}
nameButton={editLesson ? t('journal.pl.edit') : t('journal.pl.common.create')}
aiSuggestions={generateLessons?.body}
isLoadingAiSuggestions={isLoadingGenerateLessons}
onSelectAiSuggestion={handleSelectAiSuggestion}
selectedAiSuggestion={suggestedLessonToCreate}
onRetryAiGeneration={handleRetryAiGeneration}
existingLessons={data?.body?.map(lesson => ({
date: lesson.date,
name: lesson.name
}))}
/>
) : (
<Button
leftIcon={<AddIcon />}
colorScheme="green"
onClick={handleOpenForm}
>
{t('journal.pl.common.create')}
</Button>
)}
</Box>
)}
{/* Статистика курса */}
{!showForm && courseStatistics && (
<CourseStatistics lessons={sorted} isLoading={isLoading} />
)}
{barFeature && sorted?.length > 1 && (
<Box height="300">
<Bar
data={sorted.map((lesson, index) => ({
lessonIndex: `#${index + 1}`,
count: lesson.students.length,
}))}
/>
</Box>
)}
{isMobile ? (
<Box pb={13}>
{lessonCalc?.map(({ data: lessons, date }) => (
<LessonItems
courseId={courseId}
date={date}
isTeacher={isTeacher(user)}
lessons={lessons}
setlessonToDelete={setlessonToDelete}
setEditLesson={handleEditLesson}
key={date}
/>
))}
</Box>
) : (
<Box pb={13}>
{lessonCalc?.map(({ data: lessons, date }) => (
<Box key={date} mb={6}>
{date && (
<Box
p={3}
mb={4}
bg="cyan.50"
borderRadius="md"
_dark={{ bg: "cyan.900" }}
boxShadow="sm"
>
<Text fontWeight="bold" fontSize="lg">
{formatDate(date, 'DD MMMM YYYY')}
</Text>
</Box>
)}
<Box>
{lessons.map((lesson, index) => (
<Box
key={lesson.id}
borderRadius="lg"
boxShadow="md"
bg="white"
_dark={{ bg: "gray.700" }}
transition="all 0.3s"
_hover={{
transform: "translateX(5px)",
boxShadow: "lg"
}}
overflow="hidden"
position="relative"
mb={4}
animation={`slideIn 0.6s ease-out ${index * 0.15}s both`}
sx={{
'@keyframes slideIn': {
'0%': {
opacity: 0,
transform: 'translateX(-30px)'
},
'100%': {
opacity: 1,
transform: 'translateX(0)'
}
}
}}
>
<Flex direction={{ base: "column", sm: "row" }}>
{/* QR код и ссылка - левая часть карточки */}
{isTeacher(user) && (
<Link
to={`${getNavigationValue('journal.main')}/lesson/${courseId}/${lesson.id}`}
>
<Box
p={4}
bg="cyan.500"
_dark={{ bg: "cyan.600" }}
color="white"
display="flex"
alignItems="center"
justifyContent="center"
transition="all 0.2s"
_hover={{ bg: "cyan.600", _dark: { bg: "cyan.700" } }}
height="100%"
minW="150px"
>
<Box
mr={0}
bg="white"
borderRadius="md"
p={2}
display="flex"
>
<img width={32} src={qrCode} alt="QR код" />
</Box>
</Box>
</Link>
)}
{/* Содержимое карточки */}
<Box p={5} w="100%" display="flex" flexDirection="column" justifyContent="space-between">
<Flex mb={3} justify="space-between" align="center">
{/* Название урока */}
<Text fontWeight="bold" fontSize="xl" lineHeight="1.4" flex="1">
{lesson.name}
</Text>
<Text fontSize="sm" color="gray.500" _dark={{ color: "gray.300" }} ml={3} whiteSpace="nowrap">
{formatDate(lesson.date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
</Text>
</Flex>
{/* Нижняя часть с метками и действиями */}
<Flex justifyContent="space-between" alignItems="center" mt={1}>
<Flex align="center">
<Text fontSize="sm" mr={2}>
{t('journal.pl.common.marked')}:
</Text>
<Text
px={2}
py={1}
bg={getAttendanceColor(lesson.students.length).bg}
color={getAttendanceColor(lesson.students.length).color}
_dark={{
bg: getAttendanceColor(lesson.students.length).dark.bg,
color: getAttendanceColor(lesson.students.length).dark.color
}}
borderRadius="md"
fontWeight="bold"
fontSize="sm"
>
{lesson.students.length}
</Text>
</Flex>
{isTeacher(user) && (
<Menu>
<MenuButton
as={Button}
size="sm"
colorScheme="cyan"
variant="ghost"
rightIcon={<EditIcon />}
>
{t('journal.pl.edit')}
</MenuButton>
<Portal>
<MenuList zIndex={1000}>
<MenuItem
onClick={() => handleEditLesson(lesson)}
icon={<EditIcon />}
>
{t('journal.pl.edit')}
</MenuItem>
<MenuItem
onClick={() => setlessonToDelete(lesson)}
color="red.500"
>
{t('journal.pl.delete')}
</MenuItem>
</MenuList>
</Portal>
</Menu>
)}
</Flex>
</Box>
</Flex>
</Box>
))}
</Box>
</Box>
))}
</Box>
)}
</Container>
</>
)
}
export default LessonList