423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
/**
|
||
* Календарь занятий для 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<LessonsCalendarProps> = ({
|
||
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<Date>(() => 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<string, Lesson[]>();
|
||
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 (
|
||
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||
{/* Header как в iOS */}
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||
<Typography sx={{ fontSize: 18, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
|
||
{monthLabel}
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||
<IconButton
|
||
onClick={loading ? undefined : goPrevMonth}
|
||
size="small"
|
||
disabled={loading}
|
||
sx={{
|
||
borderRadius: 2,
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
backgroundColor: 'var(--md-sys-color-surface)',
|
||
opacity: loading ? 0.6 : 1,
|
||
pointerEvents: loading ? 'none' : 'auto',
|
||
}}
|
||
>
|
||
<ChevronLeft fontSize="small" />
|
||
</IconButton>
|
||
<IconButton
|
||
onClick={loading ? undefined : goToday}
|
||
size="small"
|
||
disabled={loading}
|
||
sx={{
|
||
borderRadius: 2,
|
||
px: 1.25,
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
backgroundColor: 'var(--md-sys-color-surface)',
|
||
opacity: loading ? 0.6 : 1,
|
||
pointerEvents: loading ? 'none' : 'auto',
|
||
}}
|
||
>
|
||
<Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
|
||
Сегодня
|
||
</Typography>
|
||
</IconButton>
|
||
<IconButton
|
||
onClick={loading ? undefined : goNextMonth}
|
||
size="small"
|
||
disabled={loading}
|
||
sx={{
|
||
borderRadius: 2,
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
backgroundColor: 'var(--md-sys-color-surface)',
|
||
opacity: loading ? 0.6 : 1,
|
||
pointerEvents: loading ? 'none' : 'auto',
|
||
}}
|
||
>
|
||
<ChevronRight fontSize="small" />
|
||
</IconButton>
|
||
{loading && (
|
||
<Box sx={{ ml: 0.5, display: 'flex', alignItems: 'center' }}>
|
||
<LoadingSpinner size="small" inline />
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Grid */}
|
||
<Box
|
||
sx={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
borderRadius: 2,
|
||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||
backgroundColor: 'var(--md-sys-color-surface)',
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
}}
|
||
>
|
||
{/* Weekdays */}
|
||
<Box
|
||
sx={{
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||
}}
|
||
>
|
||
{weekdayLabels.map((label, idx) => (
|
||
<Box
|
||
key={`${label}-${idx}`}
|
||
sx={{
|
||
py: 1,
|
||
textAlign: 'center',
|
||
fontSize: 11,
|
||
fontWeight: 800,
|
||
letterSpacing: '0.04em',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
}}
|
||
>
|
||
{label}
|
||
</Box>
|
||
))}
|
||
</Box>
|
||
|
||
{/* Days */}
|
||
<Box
|
||
sx={{
|
||
flex: 1,
|
||
minHeight: 0,
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(7, 1fr)',
|
||
gridTemplateRows: 'repeat(6, minmax(110px, 1fr))',
|
||
// слайдер при переключении месяцев
|
||
'@keyframes iosCalSlideNext': {
|
||
from: { transform: 'translateX(16px)', opacity: 0.2 },
|
||
to: { transform: 'translateX(0)', opacity: 1 },
|
||
},
|
||
'@keyframes iosCalSlidePrev': {
|
||
from: { transform: 'translateX(-16px)', opacity: 0.2 },
|
||
to: { transform: 'translateX(0)', opacity: 1 },
|
||
},
|
||
'@keyframes iosCalFade': {
|
||
from: { opacity: 0.4 },
|
||
to: { opacity: 1 },
|
||
},
|
||
animation:
|
||
slideDir === 'next'
|
||
? 'iosCalSlideNext 220ms ease'
|
||
: slideDir === 'prev'
|
||
? 'iosCalSlidePrev 220ms ease'
|
||
: slideDir === 'today'
|
||
? 'iosCalFade 180ms ease'
|
||
: 'none',
|
||
}}
|
||
key={`${currentMonth.getFullYear()}-${currentMonth.getMonth()}`}
|
||
>
|
||
{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 (
|
||
<Box
|
||
key={dayKey}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => 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 */}
|
||
<Box
|
||
sx={{
|
||
position: 'absolute',
|
||
top: 8,
|
||
right: 10,
|
||
fontSize: 12,
|
||
fontWeight: 800,
|
||
color: isToday ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface-variant)',
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
{format(day, 'd')}
|
||
</Box>
|
||
|
||
{/* Lessons (no scroll) */}
|
||
<Box
|
||
sx={{
|
||
mt: 2.5,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: 0.5,
|
||
height: 'calc(100% - 28px)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{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 (
|
||
<Box
|
||
key={lesson.id}
|
||
onClick={(e) => 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}
|
||
</Box>
|
||
);
|
||
})}
|
||
|
||
{dayLessons.length > 2 && (
|
||
<Box sx={{ mt: 0.25, fontSize: 11, fontWeight: 800, color: 'var(--md-sys-color-primary)' }}>
|
||
+ ещё {dayLessons.length - 2}
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
);
|
||
})}
|
||
</Box>
|
||
</Box>
|
||
</Box>
|
||
);
|
||
};
|