523 lines
15 KiB
TypeScript
523 lines
15 KiB
TypeScript
import React, {
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react'
|
||
import { ResponsiveBar } from '@nivo/bar'
|
||
import dayjs from 'dayjs'
|
||
import { Link, useParams } from 'react-router-dom'
|
||
import { getNavigationsValue, getFeatures } from '@ijl/cli'
|
||
import { useForm, Controller } from 'react-hook-form'
|
||
import {
|
||
Breadcrumb,
|
||
BreadcrumbItem,
|
||
BreadcrumbLink,
|
||
Container,
|
||
Box,
|
||
Card,
|
||
CardBody,
|
||
CardHeader,
|
||
Heading,
|
||
Button,
|
||
CloseButton,
|
||
useToast,
|
||
VStack,
|
||
FormControl,
|
||
FormLabel,
|
||
Toast,
|
||
FormHelperText,
|
||
FormErrorMessage,
|
||
Input,
|
||
TableContainer,
|
||
Table,
|
||
Thead,
|
||
Tr,
|
||
Th,
|
||
Tbody,
|
||
Td,
|
||
Menu,
|
||
MenuButton,
|
||
MenuItem,
|
||
Text,
|
||
MenuList,
|
||
Center,
|
||
Spinner,
|
||
AlertDialog,
|
||
AlertDialogBody,
|
||
AlertDialogContent,
|
||
AlertDialogFooter,
|
||
AlertDialogHeader,
|
||
AlertDialogOverlay,
|
||
} from '@chakra-ui/react'
|
||
import { AddIcon, EditIcon } from '@chakra-ui/icons'
|
||
|
||
import { useAppSelector } from '../__data__/store'
|
||
import { api } from '../__data__/api/api'
|
||
import { isTeacher } from '../utils/user'
|
||
import { qrCode } from '../assets'
|
||
import { Lesson } from '../__data__/model'
|
||
|
||
import { ErrorSpan, BreadcrumbsWrapper } from './style'
|
||
|
||
const features = getFeatures('journal')
|
||
|
||
const barFeature = features?.['lesson.bar']
|
||
const groupByDate = features?.['group.by.date']
|
||
|
||
interface NewLessonForm {
|
||
name: string
|
||
date: string
|
||
}
|
||
|
||
interface LessonFormProps {
|
||
lesson?: Partial<Lesson>
|
||
isLoading: boolean
|
||
onCancel: () => void
|
||
onSubmit: (lesson: Lesson) => void
|
||
error?: string
|
||
title: string
|
||
nameButton: string
|
||
}
|
||
|
||
const LessonForm = ({
|
||
lesson,
|
||
isLoading,
|
||
onCancel,
|
||
onSubmit,
|
||
error,
|
||
title,
|
||
nameButton,
|
||
}: LessonFormProps) => {
|
||
const {
|
||
control,
|
||
handleSubmit,
|
||
reset,
|
||
formState: { errors },
|
||
} = useForm<NewLessonForm>({
|
||
defaultValues: lesson || {
|
||
name: '',
|
||
date: '',
|
||
},
|
||
})
|
||
|
||
return (
|
||
<Card align="left">
|
||
<CardHeader display="flex">
|
||
<Heading as="h2" mt="0">
|
||
{title}
|
||
</Heading>
|
||
<CloseButton
|
||
ml="auto"
|
||
onClick={() => {
|
||
reset()
|
||
onCancel()
|
||
}}
|
||
/>
|
||
</CardHeader>
|
||
<CardBody>
|
||
<form onSubmit={handleSubmit(onSubmit)}>
|
||
<VStack spacing="10" align="left">
|
||
<Controller
|
||
control={control}
|
||
name="date"
|
||
rules={{ required: 'Обязательное поле' }}
|
||
render={({ field }) => (
|
||
<FormControl>
|
||
<FormLabel>Дата</FormLabel>
|
||
<Input
|
||
{...field}
|
||
required={false}
|
||
placeholder="Укажите дату лекции"
|
||
size="md"
|
||
type="datetime-local"
|
||
/>
|
||
{errors.date ? (
|
||
<FormErrorMessage>{errors.date?.message}</FormErrorMessage>
|
||
) : (
|
||
<FormHelperText>Укажите дату и время лекции</FormHelperText>
|
||
)}
|
||
</FormControl>
|
||
)}
|
||
/>
|
||
|
||
<Controller
|
||
control={control}
|
||
name="name"
|
||
rules={{ required: 'Обязательное поле' }}
|
||
render={({ field }) => (
|
||
<FormControl isRequired isInvalid={Boolean(errors.name)}>
|
||
<FormLabel>Название новой лекции:</FormLabel>
|
||
<Input
|
||
{...field}
|
||
required={false}
|
||
placeholder="Название лекции"
|
||
size="md"
|
||
/>
|
||
{errors.name && (
|
||
<FormErrorMessage>{errors.name.message}</FormErrorMessage>
|
||
)}
|
||
</FormControl>
|
||
)}
|
||
/>
|
||
<Box mt="10">
|
||
<Button
|
||
size="lg"
|
||
type="submit"
|
||
leftIcon={<AddIcon />}
|
||
colorScheme="blue"
|
||
isLoading={isLoading}
|
||
>
|
||
{nameButton}
|
||
</Button>
|
||
</Box>
|
||
</VStack>
|
||
|
||
{error && <ErrorSpan>{error}</ErrorSpan>}
|
||
</form>
|
||
</CardBody>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
const LessonList = () => {
|
||
const { courseId } = useParams()
|
||
const user = useAppSelector((s) => s.user)
|
||
const { data, isLoading, error, isSuccess } = api.useLessonListQuery(courseId)
|
||
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 sorted = useMemo(() => [...(data?.body || [])]?.sort((a, b) => a.date > b.date ? 1 : -1), [data, data?.body])
|
||
|
||
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])
|
||
|
||
const onSubmit = (lessonData) => {
|
||
toastRef.current = toast({
|
||
title: 'Отправляем',
|
||
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">{`Удалена лекция ${lesson.name}`}</Text>
|
||
</Box>
|
||
<Button
|
||
onClick={() => {
|
||
createLesson({ courseId, ...lesson })
|
||
toast.close(id)
|
||
}}
|
||
>
|
||
Восстановить
|
||
</Button>
|
||
</>
|
||
}
|
||
/>
|
||
),
|
||
})
|
||
setlessonToDelete(null)
|
||
}
|
||
}, [deletingRqst.isLoading, deletingRqst.isSuccess, deletingRqst.isError])
|
||
|
||
useEffect(() => {
|
||
if (crLQuery.isSuccess) {
|
||
const toastProps = {
|
||
title: 'Лекция создана',
|
||
description: `Лекция ${createdLessonRef.current.name} успешно создана`,
|
||
status: 'success' as 'success',
|
||
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: 'Лекция Обновлена',
|
||
description: `Лекция ${createdLessonRef.current.name} успешно обновлена`,
|
||
status: 'success' as 'success',
|
||
duration: 9000,
|
||
isClosable: true,
|
||
}
|
||
if (toastRef.current) toast.update(toastRef.current, toastProps)
|
||
else toast(toastProps)
|
||
setShowForm(false)
|
||
}
|
||
}, [updateLessonRqst.isSuccess])
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<Container maxW="container.xl">
|
||
<Center h="300px">
|
||
<Spinner
|
||
thickness="4px"
|
||
speed="0.65s"
|
||
emptyColor="gray.200"
|
||
color="blue.500"
|
||
size="xl"
|
||
/>
|
||
</Center>
|
||
</Container>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<AlertDialog
|
||
isOpen={Boolean(lessonToDelete)}
|
||
leastDestructiveRef={cancelRef}
|
||
onClose={() => setlessonToDelete(null)}
|
||
>
|
||
<AlertDialogOverlay>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||
Удалить занятие от{' '}
|
||
{dayjs(lessonToDelete?.date).format('DD.MM.YY')}?
|
||
</AlertDialogHeader>
|
||
|
||
<AlertDialogBody>
|
||
Все данные о посещении данного занятия будут удалены
|
||
</AlertDialogBody>
|
||
|
||
<AlertDialogFooter>
|
||
<Button
|
||
isDisabled={deletingRqst.isLoading}
|
||
ref={cancelRef}
|
||
onClick={() => setlessonToDelete(null)}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
colorScheme="red"
|
||
loadingText=""
|
||
isLoading={deletingRqst.isLoading}
|
||
onClick={() => deleteLesson(lessonToDelete._id)}
|
||
ml={3}
|
||
>
|
||
Delete
|
||
</Button>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialogOverlay>
|
||
</AlertDialog>
|
||
<BreadcrumbsWrapper>
|
||
<Breadcrumb>
|
||
<BreadcrumbItem>
|
||
<BreadcrumbLink as={Link} to={getNavigationsValue('journal.main')}>
|
||
Журнал
|
||
</BreadcrumbLink>
|
||
</BreadcrumbItem>
|
||
|
||
<BreadcrumbItem isCurrentPage>
|
||
<BreadcrumbLink href="#">Курс</BreadcrumbLink>
|
||
</BreadcrumbItem>
|
||
</Breadcrumb>
|
||
</BreadcrumbsWrapper>
|
||
<Container maxW="container.xl" position="relative">
|
||
{isTeacher(user) && (
|
||
<Box mt="15" mb="15">
|
||
{showForm ? (
|
||
<LessonForm
|
||
key={editLesson?._id}
|
||
isLoading={crLQuery.isLoading}
|
||
onSubmit={onSubmit}
|
||
onCancel={() => {
|
||
setShowForm(false)
|
||
setEditLesson(null)
|
||
}}
|
||
error={(crLQuery.error as any)?.error}
|
||
lesson={editLesson}
|
||
title={editLesson ? 'Редактирование лекции' : 'Создание лекции'}
|
||
nameButton={editLesson ? 'Редактировать' : 'Создать'}
|
||
/>
|
||
) : (
|
||
<Box p="2" m="2">
|
||
<Button
|
||
leftIcon={<AddIcon />}
|
||
colorScheme="green"
|
||
onClick={() => setShowForm(true)}
|
||
>
|
||
Добавить
|
||
</Button>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
)}
|
||
{barFeature && sorted?.length && (
|
||
<Box height="300">
|
||
<Bar
|
||
data={sorted.map((lesson, index) => ({
|
||
lessonIndex: `#${index + 1}`,
|
||
count: lesson.students.length,
|
||
}))}
|
||
/>
|
||
</Box>
|
||
)}
|
||
<TableContainer whiteSpace="wrap" pb={13}>
|
||
<Table variant="striped" colorScheme="cyan">
|
||
<Thead>
|
||
<Tr>
|
||
{isTeacher(user) && (
|
||
<Th align="center" width={1}>
|
||
ссылка
|
||
</Th>
|
||
)}
|
||
<Th textAlign="center" width={1}>
|
||
Дата
|
||
</Th>
|
||
<Th>Название</Th>
|
||
{isTeacher(user) && <Th>action</Th>}
|
||
<Th isNumeric>Отмечено</Th>
|
||
</Tr>
|
||
</Thead>
|
||
<Tbody>
|
||
{lessonCalc?.map(({ data: lessons, date }) => (
|
||
<React.Fragment key={date}>
|
||
{date && <Tr><Td colSpan={isTeacher(user) ? 5 : 3}>{dayjs(date).format('DD MMMM YYYY')}</Td></Tr>}
|
||
{lessons.map((lesson) => (
|
||
<Tr key={lesson._id}>
|
||
{isTeacher(user) && (
|
||
<Td>
|
||
<Link
|
||
to={`${getNavigationsValue('journal.main')}/lesson/${courseId}/${lesson._id}`}
|
||
style={{ display: 'flex' }}
|
||
>
|
||
<img
|
||
width={24}
|
||
src={qrCode}
|
||
style={{ margin: '0 auto' }}
|
||
/>
|
||
</Link>
|
||
</Td>
|
||
)}
|
||
<Td textAlign="center">
|
||
{dayjs(lesson.date).format(groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
|
||
</Td>
|
||
<Td>{lesson.name}</Td>
|
||
{isTeacher(user) && (
|
||
<Td>
|
||
<Menu>
|
||
<MenuButton as={Button}>
|
||
<EditIcon />
|
||
</MenuButton>
|
||
<MenuList>
|
||
<MenuItem
|
||
onClick={() => {
|
||
setShowForm(true)
|
||
setEditLesson(lesson)
|
||
}}
|
||
>
|
||
Edit
|
||
</MenuItem>
|
||
<MenuItem
|
||
onClick={() => setlessonToDelete(lesson)}
|
||
>
|
||
Delete
|
||
</MenuItem>
|
||
</MenuList>
|
||
</Menu>
|
||
</Td>
|
||
)}
|
||
<Td isNumeric>{lesson.students.length}</Td>
|
||
</Tr>
|
||
))}
|
||
</React.Fragment>
|
||
))}
|
||
</Tbody>
|
||
</Table>
|
||
</TableContainer>
|
||
</Container>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default LessonList
|
||
|
||
const Bar = ({ data }) => (
|
||
<ResponsiveBar
|
||
data={data}
|
||
keys={['count']}
|
||
indexBy="lessonIndex"
|
||
margin={{ top: 50, right: 130, bottom: 50, left: 60 }}
|
||
padding={0.3}
|
||
valueScale={{ type: 'linear' }}
|
||
indexScale={{ type: 'band', round: true }}
|
||
colors={{ scheme: 'set3' }}
|
||
axisTop={null}
|
||
axisRight={null}
|
||
labelSkipWidth={12}
|
||
labelSkipHeight={12}
|
||
labelTextColor={{
|
||
from: 'color',
|
||
modifiers: [['brighter', 1.4]],
|
||
}}
|
||
role="application"
|
||
ariaLabel="График посещаемости лекций"
|
||
barAriaLabel={(e) =>
|
||
e.id + ': ' + e.formattedValue + ' on lection: ' + e.indexValue
|
||
}
|
||
/>
|
||
)
|