uchill/front_minimal/src/sections/calendar/view/calendar-view.jsx

649 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }}>Успеваемость (15)</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 }}>Школьная оценка (15)</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)}
/>
)}
</>
);
}