uchill/front_material/components/dashboard/LessonsCalendar.tsx

405 lines
14 KiB
TypeScript
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.

/**
* Календарь занятий для 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';
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;
}
export const LessonsCalendar: React.FC<LessonsCalendarProps> = ({
lessons,
onSelectEvent,
onSelectSlot,
onMonthChange,
selectedDate,
userTimezone,
}) => {
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={goPrevMonth}
size="small"
sx={{
borderRadius: 2,
border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)',
}}
>
<ChevronLeft fontSize="small" />
</IconButton>
<IconButton
onClick={goToday}
size="small"
sx={{
borderRadius: 2,
px: 1.25,
border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)',
}}
>
<Typography sx={{ fontSize: 12, fontWeight: 700, color: 'var(--md-sys-color-on-surface)' }}>
Сегодня
</Typography>
</IconButton>
<IconButton
onClick={goNextMonth}
size="small"
sx={{
borderRadius: 2,
border: '1px solid var(--md-sys-color-outline-variant)',
backgroundColor: 'var(--md-sys-color-surface)',
}}
>
<ChevronRight fontSize="small" />
</IconButton>
</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>
);
};