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

569 lines
21 KiB
TypeScript

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