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 ( { if (reason !== 'backdropClick') handleClose(); }} maxWidth="sm" fullWidth disableEscapeKeyDown > Завершение урока Заполните информацию о прошедшем занятии {error && {error}} {/* Grades */} Оценка за занятие Успеваемость (1–5) setMentorGrade(v ?? 0)} disabled={loading} /> Школьная оценка (1–5) setSchoolGrade(v ?? 0)} disabled={loading} /> {/* Comment */} setNotes(e.target.value)} multiline rows={3} disabled={loading} fullWidth /> {/* Homework toggle */} setHasHw(e.target.checked)} disabled={loading} /> } label="Выдать домашнее задание" /> {/* Homework form */} {hasHw && ( setHwTitle(e.target.value)} disabled={loading} fullWidth size="small" /> setHwText(e.target.value)} multiline rows={3} disabled={loading} fullWidth /> setHwDeadline(e.target.value)} disabled={loading} fullWidth size="small" InputLabelProps={{ shrink: true }} inputProps={{ min: new Date().toISOString().slice(0, 10) }} /> {/* File upload */} {hwFiles.length > 0 && ( {hwFiles.map((f, i) => ( {f.name} ))} )} )} ); } // ---------------------------------------------------------------------- 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 && ( )} { 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)} /> )} ); }