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 (
);
}
// ----------------------------------------------------------------------
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 (
Сегодня · {todayUpcoming.length} {todayUpcoming.length === 1 ? 'занятие' : 'занятий'}
{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 (
!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' } : {},
}}
>
{initial}
{subject}
{personName && (
{personName}
)}
}
sx={{ mt: 0.5, height: 20, fontSize: 11 }}
/>
{canJoin && (
)}
);
})}
);
}
// ----------------------------------------------------------------------
// 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 (
<>
Расписание
{isMentor && (
}
onClick={() => onOpenForm()}
>
Новое занятие
)}
{
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 }}
/>
{isMentor && (
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 && }
{/* Controlled: opened from CalendarForm */}
{isMentor && (
setCompleteLessonOpen(false)}
/>
)}
>
);
}