/** * Календарь занятий для Dashboard ментора * Реализация “с нуля” на Material UI (M3) в iOS-стиле: * - сетка месяца 7×6 с тонкими разделителями * - число дня в правом верхнем углу * - занятия плашками внутри ячейки (лимит + “+ ещё N”) * - без внутреннего скролла */ 'use client'; import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { addDays, addMonths, endOfMonth, format, isSameDay, isSameMonth, startOfDay, startOfMonth, startOfWeek, subMonths, } from 'date-fns'; import { ru } from 'date-fns/locale'; import { Box, IconButton, Typography } from '@mui/material'; import { ChevronLeft, ChevronRight } from '@mui/icons-material'; import { parseISOToUserTimezone } from '@/utils/timezone'; import { LoadingSpinner } from '@/components/common/LoadingSpinner'; interface Lesson { id: string; title: string; start_time: string; end_time: string; status?: 'scheduled' | 'in_progress' | 'completed' | 'cancelled'; client?: { id: string; name?: string; first_name?: string; last_name?: string; }; } interface LessonsCalendarProps { lessons: Lesson[]; onSelectEvent?: (lesson: Lesson) => void; onSelectSlot?: (date: Date) => void; onMonthChange?: (start: Date, end: Date) => void; selectedDate?: Date; userTimezone?: string; /** Идёт загрузка данных (запрос нового месяца) — блокирует навигацию */ loading?: boolean; } export const LessonsCalendar: React.FC = ({ lessons, onSelectEvent, onSelectSlot, onMonthChange, selectedDate, userTimezone, loading = false, }) => { const safeSelectedDate = useMemo(() => { if (selectedDate && !Number.isNaN(selectedDate.getTime())) return startOfDay(selectedDate); return startOfDay(new Date()); }, [selectedDate]); const [currentMonth, setCurrentMonth] = useState(() => startOfMonth(safeSelectedDate)); const [slideDir, setSlideDir] = useState<'prev' | 'next' | 'today' | null>(null); useEffect(() => { setCurrentMonth(startOfMonth(safeSelectedDate)); }, [safeSelectedDate]); useEffect(() => { if (!slideDir) return; const t = window.setTimeout(() => setSlideDir(null), 240); return () => window.clearTimeout(t); }, [slideDir]); useEffect(() => { const start = startOfMonth(currentMonth); const end = endOfMonth(currentMonth); onMonthChange?.(start, end); }, [currentMonth, onMonthChange]); // Группируем занятия по дате (ключ YYYY-MM-DD) с учётом timezone пользователя const lessonsByDay = useMemo(() => { const map = new Map(); if (!lessons || lessons.length === 0) return map; lessons.forEach((lesson) => { try { // Используем timezone пользователя для определения дня const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone); const key = parsed.date; // уже в формате 'yyyy-MM-dd' const existing = map.get(key) || []; existing.push(lesson); map.set(key, existing); } catch { /* ignore invalid date */ } }); // Сортируем занятия внутри каждого дня по времени map.forEach((dayLessons, key) => { dayLessons.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime() ); map.set(key, dayLessons); }); return map; }, [lessons, userTimezone]); const monthLabel = useMemo(() => { const label = format(currentMonth, 'LLLL yyyy', { locale: ru }); return label.charAt(0).toUpperCase() + label.slice(1); }, [currentMonth]); const weekdayLabels = useMemo(() => { const start = startOfWeek(new Date(), { weekStartsOn: 1, locale: ru }); return Array.from({ length: 7 }).map((_, idx) => { const d = addDays(start, idx); return format(d, 'EE', { locale: ru }).substring(0, 2).toUpperCase(); }); }, []); const daysGrid = useMemo(() => { const monthStart = startOfMonth(currentMonth); const gridStart = startOfWeek(monthStart, { weekStartsOn: 1, locale: ru }); return Array.from({ length: 42 }).map((_, i) => addDays(gridStart, i)); }, [currentMonth]); const handleDayClick = useCallback( (day: Date) => { onSelectSlot?.(startOfDay(day)); }, [onSelectSlot] ); const handleLessonClick = useCallback( (day: Date, lesson: Lesson, e: React.MouseEvent) => { e.stopPropagation(); onSelectSlot?.(startOfDay(day)); onSelectEvent?.(lesson); }, [onSelectEvent, onSelectSlot] ); const goToday = useCallback(() => { const today = startOfDay(new Date()); const curr = startOfMonth(currentMonth).getTime(); const target = startOfMonth(today).getTime(); if (target < curr) setSlideDir('prev'); else if (target > curr) setSlideDir('next'); else setSlideDir('today'); setCurrentMonth(startOfMonth(today)); onSelectSlot?.(today); }, [currentMonth, onSelectSlot]); const goPrevMonth = useCallback(() => { setSlideDir('prev'); setCurrentMonth((m) => subMonths(m, 1)); }, []); const goNextMonth = useCallback(() => { setSlideDir('next'); setCurrentMonth((m) => addMonths(m, 1)); }, []); return ( {/* Header как в iOS */} {monthLabel} Сегодня {loading && ( )} {/* Grid */} {/* Weekdays */} {weekdayLabels.map((label, idx) => ( {label} ))} {/* Days */} {daysGrid.map((day) => { const dayKey = format(startOfDay(day), 'yyyy-MM-dd'); const dayLessons = lessonsByDay.get(dayKey) || []; const inMonth = isSameMonth(day, currentMonth); const isToday = isSameDay(day, new Date()); const isSelected = isSameDay(startOfDay(day), safeSelectedDate); const bg = !inMonth ? 'var(--md-sys-color-surface-variant)' : isSelected ? 'rgba(116, 68, 253, 0.10)' : 'var(--md-sys-color-surface)'; return ( handleDayClick(day)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleDayClick(day); }} sx={{ position: 'relative', minHeight: '110px', px: 1, pt: 1, pb: 0.75, borderRight: '1px solid var(--md-sys-color-outline-variant)', borderBottom: '1px solid var(--md-sys-color-outline-variant)', backgroundColor: bg, overflow: 'hidden', cursor: 'pointer', opacity: inMonth ? 1 : 0.55, transition: 'background-color 120ms ease', '&:hover': { backgroundColor: isSelected ? 'rgba(116, 68, 253, 0.13)' : 'rgba(116, 68, 253, 0.06)', }, }} > {/* Day number */} {format(day, 'd')} {/* Lessons (no scroll) */} {dayLessons.slice(0, 2).map((lesson) => { const timeStr = (() => { try { // Используем timezone пользователя для отображения времени const parsed = parseISOToUserTimezone(lesson.start_time, userTimezone); return parsed.time; } catch { return ''; } })(); const baseTitle = lesson.client?.first_name || lesson.client?.name || lesson.title; const title = baseTitle && baseTitle.length > 18 ? baseTitle.substring(0, 16) + '…' : baseTitle; return ( handleLessonClick(day, lesson, e)} sx={{ px: 0.75, py: 0.25, borderRadius: 1.25, backgroundColor: 'rgba(116, 68, 253, 0.14)', color: 'var(--md-sys-color-on-surface)', fontSize: 11, fontWeight: 700, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', border: '1px solid rgba(116, 68, 253, 0.20)', }} > {timeStr ? `${timeStr} ${title}` : title} ); })} {dayLessons.length > 2 && ( + ещё {dayLessons.length - 2} )} ); })} ); };