649 lines
23 KiB
JavaScript
649 lines
23 KiB
JavaScript
|
||
import FullCalendar from '@fullcalendar/react';
|
||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||
import timeGridPlugin from '@fullcalendar/timegrid';
|
||
import interactionPlugin from '@fullcalendar/interaction';
|
||
import listPlugin from '@fullcalendar/list';
|
||
import timelinePlugin from '@fullcalendar/timeline';
|
||
import ruLocale from '@fullcalendar/core/locales/ru';
|
||
|
||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||
|
||
import Box from '@mui/material/Box';
|
||
import Card from '@mui/material/Card';
|
||
import Chip from '@mui/material/Chip';
|
||
import Stack from '@mui/material/Stack';
|
||
import Alert from '@mui/material/Alert';
|
||
import Avatar from '@mui/material/Avatar';
|
||
import Button from '@mui/material/Button';
|
||
import Dialog from '@mui/material/Dialog';
|
||
import Switch from '@mui/material/Switch';
|
||
import Rating from '@mui/material/Rating';
|
||
import Divider from '@mui/material/Divider';
|
||
import TextField from '@mui/material/TextField';
|
||
import Container from '@mui/material/Container';
|
||
import Typography from '@mui/material/Typography';
|
||
import DialogTitle from '@mui/material/DialogTitle';
|
||
import FormControl from '@mui/material/FormControl';
|
||
import DialogActions from '@mui/material/DialogActions';
|
||
import DialogContent from '@mui/material/DialogContent';
|
||
import CircularProgress from '@mui/material/CircularProgress';
|
||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||
|
||
import { paths } from 'src/routes/paths';
|
||
import { useRouter } from 'src/routes/hooks';
|
||
import { useAuthContext } from 'src/auth/hooks';
|
||
import { useBoolean } from 'src/hooks/use-boolean';
|
||
import { useGetEvents, updateEvent, createEvent, deleteEvent } from 'src/actions/calendar';
|
||
import { createLiveKitRoom } from 'src/utils/livekit-api';
|
||
import { completeLesson, uploadLessonFile } from 'src/utils/dashboard-api';
|
||
import { createHomework } from 'src/utils/homework-api';
|
||
|
||
import { Iconify } from 'src/components/iconify';
|
||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||
import { useSettingsContext } from 'src/components/settings';
|
||
|
||
import { CalendarForm } from '../calendar-form';
|
||
import { StyledCalendar } from '../styles';
|
||
import { CalendarToolbar } from '../calendar-toolbar';
|
||
import { useCalendar } from '../hooks/use-calendar';
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
const MAX_HW_FILES = 10;
|
||
const MAX_HW_FILE_MB = 10;
|
||
|
||
function defaultDeadline() {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() + 10);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
// Controlled: lessonId + open + onClose props.
|
||
// Uncontrolled: reads lessonId from sessionStorage on mount.
|
||
function CompleteLessonDialog({ lessonId: propLessonId, open: propOpen, onClose: propOnClose }) {
|
||
const isControlled = propLessonId != null;
|
||
|
||
const [ssLessonId, setSsLessonId] = useState(null);
|
||
const [ssOpen, setSsOpen] = useState(false);
|
||
|
||
const [mentorGrade, setMentorGrade] = useState(0);
|
||
const [schoolGrade, setSchoolGrade] = useState(0);
|
||
const [notes, setNotes] = useState('');
|
||
const [hasHw, setHasHw] = useState(false);
|
||
const [hwTitle, setHwTitle] = useState('Домашнее задание');
|
||
const [hwText, setHwText] = useState('');
|
||
const [hwFiles, setHwFiles] = useState([]);
|
||
const [hwDeadline, setHwDeadline] = useState(defaultDeadline);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const fileInputRef = useRef(null);
|
||
|
||
// Uncontrolled: check sessionStorage once on mount
|
||
useEffect(() => {
|
||
if (isControlled) return;
|
||
try {
|
||
const id = sessionStorage.getItem('complete_lesson_id');
|
||
if (id) {
|
||
setSsLessonId(parseInt(id, 10));
|
||
setSsOpen(true);
|
||
sessionStorage.removeItem('complete_lesson_id');
|
||
}
|
||
} catch { /* ignore */ }
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Reset form when a new lesson is opened
|
||
useEffect(() => {
|
||
const willOpen = isControlled ? propOpen : ssOpen;
|
||
if (willOpen) {
|
||
setMentorGrade(0);
|
||
setSchoolGrade(0);
|
||
setNotes('');
|
||
setHasHw(false);
|
||
setHwTitle('Домашнее задание');
|
||
setHwText('');
|
||
setHwFiles([]);
|
||
setHwDeadline(defaultDeadline());
|
||
setError(null);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isControlled ? propOpen : ssOpen, isControlled ? propLessonId : ssLessonId]);
|
||
|
||
const lessonId = isControlled ? propLessonId : ssLessonId;
|
||
const open = isControlled ? (propOpen ?? false) : ssOpen;
|
||
|
||
const handleClose = () => {
|
||
if (loading) return;
|
||
if (isControlled) propOnClose?.();
|
||
else setSsOpen(false);
|
||
};
|
||
|
||
const handleFileChange = (e) => {
|
||
const { files: list } = e.target;
|
||
if (!list?.length) return;
|
||
const toAdd = Array.from(list).filter((f) => f.size <= MAX_HW_FILE_MB * 1024 * 1024);
|
||
setHwFiles((prev) => [...prev, ...toAdd].slice(0, MAX_HW_FILES));
|
||
e.target.value = '';
|
||
};
|
||
|
||
const handleSubmit = async () => {
|
||
if (!lessonId) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const mg = mentorGrade > 0 ? mentorGrade : undefined;
|
||
const sg = schoolGrade > 0 ? schoolGrade : undefined;
|
||
const n = notes.trim() || '';
|
||
|
||
let fileIds = [];
|
||
if (hasHw && hwFiles.length > 0) {
|
||
// eslint-disable-next-line no-restricted-syntax
|
||
for (const file of hwFiles) {
|
||
// eslint-disable-next-line no-await-in-loop
|
||
const created = await uploadLessonFile(lessonId, file);
|
||
const fid = typeof created.id === 'number' ? created.id : parseInt(String(created.id), 10);
|
||
if (!Number.isNaN(fid)) fileIds.push(fid);
|
||
}
|
||
}
|
||
|
||
const hwT = hasHw ? hwText.trim() || undefined : undefined;
|
||
await completeLesson(String(lessonId), n, mg, sg, hwT, hasHw && fileIds.length > 0, fileIds.length > 0 ? fileIds : undefined);
|
||
|
||
if (hasHw && (hwText.trim() || hwFiles.length > 0)) {
|
||
await createHomework({
|
||
title: hwTitle.trim() || 'Домашнее задание',
|
||
description: hwText.trim(),
|
||
lesson_id: lessonId,
|
||
due_date: hwDeadline,
|
||
status: 'published',
|
||
});
|
||
}
|
||
|
||
handleClose();
|
||
} catch (e) {
|
||
setError(e?.response?.data?.detail || e?.message || 'Не удалось сохранить данные урока');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<Dialog
|
||
open={open}
|
||
onClose={(_, reason) => { if (reason !== 'backdropClick') handleClose(); }}
|
||
maxWidth="sm"
|
||
fullWidth
|
||
disableEscapeKeyDown
|
||
>
|
||
<DialogTitle sx={{ pb: 1 }}>
|
||
<Typography variant="h6">Завершение урока</Typography>
|
||
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
|
||
Заполните информацию о прошедшем занятии
|
||
</Typography>
|
||
</DialogTitle>
|
||
|
||
<DialogContent dividers sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||
{error && <Alert severity="error">{error}</Alert>}
|
||
|
||
{/* Grades */}
|
||
<Box>
|
||
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Оценка за занятие</Typography>
|
||
<Stack direction="row" spacing={4}>
|
||
<FormControl>
|
||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5 }}>Успеваемость (1–5)</Typography>
|
||
<Rating
|
||
value={mentorGrade}
|
||
max={5}
|
||
onChange={(_, v) => setMentorGrade(v ?? 0)}
|
||
disabled={loading}
|
||
/>
|
||
</FormControl>
|
||
<FormControl>
|
||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5 }}>Школьная оценка (1–5)</Typography>
|
||
<Rating
|
||
value={schoolGrade}
|
||
max={5}
|
||
onChange={(_, v) => setSchoolGrade(v ?? 0)}
|
||
disabled={loading}
|
||
/>
|
||
</FormControl>
|
||
</Stack>
|
||
</Box>
|
||
|
||
<Divider />
|
||
|
||
{/* Comment */}
|
||
<TextField
|
||
label="Комментарий к уроку"
|
||
placeholder="Что прошли, успехи, рекомендации…"
|
||
value={notes}
|
||
onChange={(e) => setNotes(e.target.value)}
|
||
multiline
|
||
rows={3}
|
||
disabled={loading}
|
||
fullWidth
|
||
/>
|
||
|
||
<Divider />
|
||
|
||
{/* Homework toggle */}
|
||
<Box>
|
||
<FormControlLabel
|
||
control={
|
||
<Switch
|
||
checked={hasHw}
|
||
onChange={(e) => setHasHw(e.target.checked)}
|
||
disabled={loading}
|
||
/>
|
||
}
|
||
label="Выдать домашнее задание"
|
||
/>
|
||
</Box>
|
||
|
||
{/* Homework form */}
|
||
{hasHw && (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
<TextField
|
||
label="Название ДЗ"
|
||
value={hwTitle}
|
||
onChange={(e) => setHwTitle(e.target.value)}
|
||
disabled={loading}
|
||
fullWidth
|
||
size="small"
|
||
/>
|
||
<TextField
|
||
label="Описание задания"
|
||
placeholder="Опишите задание…"
|
||
value={hwText}
|
||
onChange={(e) => setHwText(e.target.value)}
|
||
multiline
|
||
rows={3}
|
||
disabled={loading}
|
||
fullWidth
|
||
/>
|
||
<TextField
|
||
label="Дедлайн"
|
||
type="date"
|
||
value={hwDeadline}
|
||
onChange={(e) => setHwDeadline(e.target.value)}
|
||
disabled={loading}
|
||
fullWidth
|
||
size="small"
|
||
InputLabelProps={{ shrink: true }}
|
||
inputProps={{ min: new Date().toISOString().slice(0, 10) }}
|
||
/>
|
||
{/* File upload */}
|
||
<Box>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
onChange={handleFileChange}
|
||
disabled={loading}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<Button
|
||
size="small"
|
||
variant="outlined"
|
||
startIcon={<Iconify icon="solar:paperclip-bold" width={16} />}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={loading || hwFiles.length >= MAX_HW_FILES}
|
||
>
|
||
Прикрепить файлы
|
||
</Button>
|
||
{hwFiles.length > 0 && (
|
||
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
||
{hwFiles.map((f, i) => (
|
||
<Stack
|
||
key={`${f.name}-${i}-${f.size}`}
|
||
direction="row"
|
||
alignItems="center"
|
||
spacing={1}
|
||
sx={{ p: 0.75, borderRadius: 1, bgcolor: 'action.hover' }}
|
||
>
|
||
<Iconify icon="solar:document-bold" width={16} color="text.secondary" />
|
||
<Typography variant="caption" noWrap sx={{ flex: 1 }}>{f.name}</Typography>
|
||
<Button
|
||
size="small"
|
||
color="error"
|
||
disabled={loading}
|
||
onClick={() => setHwFiles((p) => p.filter((_, j) => j !== i))}
|
||
sx={{ minWidth: 0, p: 0.5 }}
|
||
>
|
||
<Iconify icon="solar:trash-bin-trash-bold" width={14} />
|
||
</Button>
|
||
</Stack>
|
||
))}
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
)}
|
||
</DialogContent>
|
||
|
||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||
<Button onClick={handleClose} disabled={loading} color="inherit">
|
||
Пропустить
|
||
</Button>
|
||
<Button
|
||
onClick={handleSubmit}
|
||
disabled={loading}
|
||
variant="contained"
|
||
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <Iconify icon="solar:check-circle-bold" width={18} />}
|
||
>
|
||
{loading ? 'Сохранение…' : 'Сохранить'}
|
||
</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
function isValidTimezone(tz) {
|
||
if (!tz) return false;
|
||
try { Intl.DateTimeFormat(undefined, { timeZone: tz }); return true; } catch { return false; }
|
||
}
|
||
|
||
function fTime(str, timezone) {
|
||
if (!str) return '';
|
||
const opts = { hour: '2-digit', minute: '2-digit', ...(isValidTimezone(timezone) ? { timeZone: timezone } : {}) };
|
||
return new Date(str).toLocaleTimeString('ru-RU', opts);
|
||
}
|
||
|
||
function UpcomingLessonsBar({ events, isMentor, timezone }) {
|
||
const router = useRouter();
|
||
const [joiningId, setJoiningId] = useState(null);
|
||
|
||
const now = new Date();
|
||
const todayKey = now.toDateString();
|
||
|
||
// Занятия сегодня, ещё не завершённые (не завершено и не отменено), сортируем по времени
|
||
const todayUpcoming = useMemo(() => {
|
||
return events
|
||
.filter((ev) => {
|
||
const st = new Date(ev.start);
|
||
const status = String(ev.extendedProps?.status || '').toLowerCase();
|
||
return st.toDateString() === todayKey && status !== 'completed' && status !== 'cancelled';
|
||
})
|
||
.sort((a, b) => new Date(a.start) - new Date(b.start))
|
||
.slice(0, 3);
|
||
}, [events, todayKey]);
|
||
|
||
if (todayUpcoming.length === 0) return null;
|
||
|
||
const handleJoin = async (ev) => {
|
||
setJoiningId(ev.id);
|
||
try {
|
||
const room = await createLiveKitRoom(ev.id);
|
||
const token = room.access_token || room.token;
|
||
router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${ev.id}`);
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setJoiningId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Box sx={{ mb: 3 }}>
|
||
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}>
|
||
Сегодня · {todayUpcoming.length} {todayUpcoming.length === 1 ? 'занятие' : 'занятий'}
|
||
</Typography>
|
||
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', sm: 'repeat(2,1fr)', md: 'repeat(3,1fr)' } }}>
|
||
{todayUpcoming.map((ev) => {
|
||
const ep = ev.extendedProps || {};
|
||
const startTime = new Date(ev.start);
|
||
const diffMin = (startTime - now) / 60000;
|
||
const canJoin = diffMin < 11 && diffMin > -90;
|
||
const isJoining = joiningId === ev.id;
|
||
|
||
// Групповое занятие — показываем название группы, индивидуальное — имя ученика/ментора
|
||
const personName = ep.group_name
|
||
? ep.group_name
|
||
: isMentor
|
||
? (ep.student || ep.client_name || '')
|
||
: (ep.mentor_name || ep.mentor || '');
|
||
const subject = ep.subject_name || ep.subject || ev.title || 'Занятие';
|
||
const initial = (personName[0] || subject[0] || 'З').toUpperCase();
|
||
|
||
return (
|
||
<Card
|
||
key={ev.id}
|
||
onClick={() => !canJoin && router.push(paths.dashboard.lesson(ev.id))}
|
||
sx={{
|
||
p: 2,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 1.5,
|
||
border: '1px solid',
|
||
borderColor: canJoin ? 'primary.main' : 'divider',
|
||
boxShadow: canJoin ? (t) => `0 0 0 2px ${t.vars.palette.primary.main}22` : 0,
|
||
transition: 'all 0.2s',
|
||
cursor: canJoin ? 'default' : 'pointer',
|
||
'&:hover': !canJoin ? { bgcolor: 'action.hover' } : {},
|
||
}}
|
||
>
|
||
<Avatar sx={{ bgcolor: 'primary.main', width: 44, height: 44, flexShrink: 0 }}>
|
||
{initial}
|
||
</Avatar>
|
||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||
<Typography variant="subtitle2" noWrap>{subject}</Typography>
|
||
{personName && (
|
||
<Typography variant="caption" color="text.secondary" noWrap display="block">{personName}</Typography>
|
||
)}
|
||
<Chip
|
||
label={fTime(ev.start, timezone)}
|
||
size="small"
|
||
color={canJoin ? 'primary' : 'default'}
|
||
variant={canJoin ? 'filled' : 'outlined'}
|
||
icon={<Iconify icon="solar:clock-circle-bold" width={12} />}
|
||
sx={{ mt: 0.5, height: 20, fontSize: 11 }}
|
||
/>
|
||
</Box>
|
||
{canJoin && (
|
||
<Button
|
||
size="small"
|
||
variant="contained"
|
||
color="primary"
|
||
disabled={isJoining}
|
||
onClick={(e) => { e.stopPropagation(); handleJoin(ev); }}
|
||
startIcon={isJoining
|
||
? <CircularProgress size={14} color="inherit" />
|
||
: <Iconify icon="solar:videocamera-record-bold" width={16} />}
|
||
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
|
||
>
|
||
{isJoining ? '...' : 'Войти'}
|
||
</Button>
|
||
)}
|
||
</Card>
|
||
);
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
|
||
// Statuses that should open the detail page instead of the edit form
|
||
const NON_PLANNED_STATUSES = ['completed', 'in_progress', 'ongoing', 'cancelled'];
|
||
|
||
export function CalendarView() {
|
||
const settings = useSettingsContext();
|
||
const router = useRouter();
|
||
const { user } = useAuthContext();
|
||
const isMentor = user?.role === 'mentor';
|
||
|
||
const [completeLessonId, setCompleteLessonId] = useState(null);
|
||
const [completeLessonOpen, setCompleteLessonOpen] = useState(false);
|
||
|
||
const handleOpenCompleteLesson = useCallback((lessonId) => {
|
||
setCompleteLessonId(lessonId);
|
||
setCompleteLessonOpen(true);
|
||
}, []);
|
||
|
||
const { calendarRef, view, date, onDatePrev, onDateNext, onDateToday, onChangeView,
|
||
onSelectRange, onClickEvent, onResizeEvent, onDropEvent, onInitialView,
|
||
openForm, onOpenForm, onCloseForm, selectEventId, selectedRange } = useCalendar();
|
||
|
||
// Intercept event click:
|
||
// - completed/cancelled/ongoing → detail page
|
||
// - past lessons (end_time already passed) → detail page regardless of status
|
||
// - future planned → mentor gets edit form, student gets detail page
|
||
const handleEventClick = useCallback((arg) => {
|
||
const eventId = arg.event.id;
|
||
if (!eventId || eventId === 'undefined' || eventId === 'null') return;
|
||
|
||
const ep = arg.event.extendedProps || {};
|
||
const status = String(ep.status || '').toLowerCase();
|
||
|
||
// Use raw API timestamps from extendedProps (more reliable than FullCalendar Date objects)
|
||
const rawEnd = ep.end_time || ep.end;
|
||
const rawStart = ep.start_time || ep.start;
|
||
const refTime = rawEnd || rawStart;
|
||
const isPast = refTime ? new Date(refTime).getTime() < Date.now() : false;
|
||
|
||
if (NON_PLANNED_STATUSES.includes(status) || isPast) {
|
||
router.push(paths.dashboard.lesson(eventId));
|
||
} else if (isMentor) {
|
||
onClickEvent(arg);
|
||
} else {
|
||
// student/parent — open detail page for upcoming lessons too
|
||
router.push(paths.dashboard.lesson(eventId));
|
||
}
|
||
}, [router, isMentor, onClickEvent]);
|
||
|
||
const { events, eventsLoading } = useGetEvents(date);
|
||
|
||
useEffect(() => {
|
||
onInitialView();
|
||
}, [onInitialView]);
|
||
|
||
const handleCreateEvent = useCallback(
|
||
(eventData) => createEvent(eventData, date),
|
||
[date]
|
||
);
|
||
|
||
const handleUpdateEvent = useCallback(
|
||
(eventData) => updateEvent(eventData, date),
|
||
[date]
|
||
);
|
||
|
||
const handleDeleteEvent = useCallback(
|
||
(eventId, deleteAllFuture) => deleteEvent(eventId, deleteAllFuture, date),
|
||
[date]
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<Container maxWidth={settings.themeStretch ? false : 'xl'}>
|
||
<Stack
|
||
direction="row"
|
||
alignItems="center"
|
||
justifyContent="space-between"
|
||
sx={{ mb: { xs: 3, md: 5 } }}
|
||
>
|
||
<Stack spacing={1}>
|
||
<Typography variant="h4">Расписание</Typography>
|
||
<CustomBreadcrumbs
|
||
links={[
|
||
{ name: 'Дашборд', href: paths.dashboard.root },
|
||
{ name: 'Расписание' },
|
||
]}
|
||
/>
|
||
</Stack>
|
||
|
||
{isMentor && (
|
||
<Button
|
||
variant="contained"
|
||
startIcon={<Iconify icon="mingcute:add-line" />}
|
||
onClick={() => onOpenForm()}
|
||
>
|
||
Новое занятие
|
||
</Button>
|
||
)}
|
||
</Stack>
|
||
|
||
<UpcomingLessonsBar events={events} isMentor={isMentor} timezone={user?.timezone} />
|
||
|
||
<Card sx={{ position: 'relative' }}>
|
||
<StyledCalendar>
|
||
<CalendarToolbar
|
||
date={date}
|
||
view={view}
|
||
loading={eventsLoading}
|
||
onNextDate={onDateNext}
|
||
onPrevDate={onDatePrev}
|
||
onToday={onDateToday}
|
||
onChangeView={onChangeView}
|
||
/>
|
||
|
||
<FullCalendar
|
||
weekends
|
||
editable={isMentor}
|
||
droppable={isMentor}
|
||
selectable={isMentor}
|
||
rerenderEvents
|
||
events={events}
|
||
ref={calendarRef}
|
||
initialDate={date}
|
||
initialView={view}
|
||
dayMaxEventRows={5}
|
||
eventDisplay="block"
|
||
headerToolbar={false}
|
||
allDayMaintainDuration
|
||
eventResizableFromStart
|
||
displayEventTime={false}
|
||
select={isMentor ? onSelectRange : undefined}
|
||
eventClick={handleEventClick}
|
||
eventDrop={isMentor ? onDropEvent : undefined}
|
||
eventResize={isMentor ? onResizeEvent : undefined}
|
||
eventOrder={(a, b) => {
|
||
const done = (e) => {
|
||
const s = String(e.extendedProps?.status || '').toLowerCase();
|
||
return s === 'completed' || s === 'cancelled' ? 1 : 0;
|
||
};
|
||
return done(a) - done(b);
|
||
}}
|
||
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]}
|
||
locale={ruLocale}
|
||
timeZone={isValidTimezone(user?.timezone) ? user.timezone : 'local'}
|
||
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||
/>
|
||
</StyledCalendar>
|
||
</Card>
|
||
</Container>
|
||
|
||
{isMentor && (
|
||
<CalendarForm
|
||
currentEvent={events.find((event) => event.id === selectEventId)}
|
||
range={selectedRange}
|
||
open={openForm}
|
||
onClose={onCloseForm}
|
||
onCreateEvent={handleCreateEvent}
|
||
onUpdateEvent={handleUpdateEvent}
|
||
onDeleteEvent={handleDeleteEvent}
|
||
onCompleteLesson={(lessonId) => { onCloseForm(); handleOpenCompleteLesson(lessonId); }}
|
||
/>
|
||
)}
|
||
|
||
{/* Auto-open after video call redirect (reads sessionStorage) */}
|
||
{isMentor && <CompleteLessonDialog />}
|
||
|
||
{/* Controlled: opened from CalendarForm */}
|
||
{isMentor && (
|
||
<CompleteLessonDialog
|
||
lessonId={completeLessonId}
|
||
open={completeLessonOpen}
|
||
onClose={() => setCompleteLessonOpen(false)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|