import React, { useState, useEffect, useContext, useCallback } from 'react'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { Typography, Box, Grid, Button, ButtonGroup, CircularProgress, Alert, Tabs, Tab, Chip, Select, MenuItem, FormControl, InputLabel, Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, TextField, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Badge, Tooltip, IconButton, Avatar, AvatarGroup, } from '@mui/material'; import { Add as AddIcon, Telegram as TelegramIcon, CalendarMonth as CalendarViewIcon, ViewList as ListViewIcon, Edit as EditIcon, } from '@mui/icons-material'; import Layout from '../components/Layout'; import PostCard from '../components/PostCard'; import PostModal from '../components/PostModal'; import PostFormModal from '../components/PostFormModal'; import { CalendarContext } from '../context/CalendarContext'; import { AuthContext } from '../context/AuthContext'; import { SocketContext } from '../context/SocketContext'; import { postAPI } from '../utils/api'; import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, startOfWeek, getWeek } from 'date-fns'; import { ru } from 'date-fns/locale'; import axios from 'axios'; const ViewMode = { LIST: 'list', CALENDAR: 'calendar' }; const CalendarPage = () => { const { id } = useParams(); const navigate = useNavigate(); const location = useLocation(); const { user } = useContext(AuthContext); const { currentCalendar, fetchCalendar, loading: calendarLoading, error: calendarError, } = useContext(CalendarContext); const { joinCalendar, activeUsers, emitPostCreated, emitPostUpdated, emitPostDeleted, socket } = useContext(SocketContext); // Локальное состояние const [posts, setPosts] = useState([]); const [filteredPosts, setFilteredPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState(0); const [statusFilter, setStatusFilter] = useState('все'); const [typeFilter, setTypeFilter] = useState('все'); const [viewMode, setViewMode] = useState(ViewMode.CALENDAR); const [currentDate, setCurrentDate] = useState(new Date()); // Добавляем состояние для модальных окон const [selectedPostId, setSelectedPostId] = useState(null); const [postModalOpen, setPostModalOpen] = useState(false); const [formModalOpen, setFormModalOpen] = useState(false); const [editingPostId, setEditingPostId] = useState(null); const [selectedDate, setSelectedDate] = useState(null); // Фильтрация публикаций - объявляем функцию до всех useEffect, которые её используют const filterPosts = useCallback((postsToFilter = posts) => { let filtered = [...postsToFilter]; // Фильтрация по вкладке (дате) const today = new Date(); const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(currentDate); if (activeTab === 1) { // Прошлые filtered = filtered.filter(post => new Date(post.publishDate) < today); } else if (activeTab === 2) { // Будущие filtered = filtered.filter(post => new Date(post.publishDate) > today); } else if (activeTab === 3) { // Этот месяц filtered = filtered.filter(post => new Date(post.publishDate) >= monthStart && new Date(post.publishDate) <= monthEnd ); } // Фильтрация по статусу if (statusFilter !== 'все') { filtered = filtered.filter(post => post.status === statusFilter); } // Фильтрация по типу if (typeFilter !== 'все') { filtered = filtered.filter(post => post.type === typeFilter); } // Сортировка по дате публикации filtered.sort((a, b) => new Date(a.publishDate) - new Date(b.publishDate)); setFilteredPosts(filtered); }, [posts, activeTab, statusFilter, typeFilter, currentDate]); // Применение фильтров при изменении dependecies filterposts useEffect(() => { filterPosts(); }, [filterPosts]); // Загрузка календаря и публикаций при монтировании useEffect(() => { const loadData = async () => { try { console.log('Загрузка календаря с ID:', id); // Проверяем, есть ли токен аутентификации const token = localStorage.getItem('token'); if (!token) { console.error('Отсутствует токен аутентификации'); navigate('/login'); return; } // Устанавливаем заголовок авторизации axios.defaults.headers.common['x-auth-token'] = token; const calendar = await fetchCalendar(id); if (calendar) { console.log('Календарь успешно загружен:', calendar); console.log('Владелец календаря:', calendar.owner); console.log('Текущий пользователь:', user?._id); await fetchPosts(); } else { console.error('Календарь не удалось загрузить'); setError(calendarError || 'Не удалось загрузить календарь'); setLoading(false); } } catch (err) { console.error('Ошибка при загрузке данных:', err); setError('Произошла ошибка при загрузке данных'); setLoading(false); } }; // Если есть ID календаря и мы авторизованы, загружаем календарь if (id && user) { console.log('Пользователь авторизован, загружаем календарь', id); loadData(); } // Если ID календаря отсутствует, перенаправляем на главную else if (!id) { console.log('ID календаря отсутствует, перенаправление на главную'); navigate('/'); } // Если нет пользователя, но есть ID, ждем загрузки пользователя // Это предотвратит ненужное перенаправление на главную страницу // eslint-disable-next-line }, [id, user?._id]); // Эффект для присоединения к комнате календаря // Выделен в отдельный useEffect для гарантии присоединения даже при обновлении страницы useEffect(() => { if (id && user && socket && currentCalendar) { console.log('Присоединяемся к комнате календаря', id); joinCalendar(id); } }, [id, user, socket, currentCalendar, joinCalendar]); // Настройка слушателей событий Socket.IO useEffect(() => { if (!socket) return; // Обработчик нового поста const handlePostCreated = ({ post }) => { console.log('Получено событие о новом посте:', post._id); setPosts(prevPosts => { // Проверяем, нет ли уже этого поста const exists = prevPosts.some(p => p._id === post._id); if (exists) return prevPosts; console.log('Добавляем новый пост в состояние:', post); // Создаем новый массив с добавленным постом const updatedPosts = [...prevPosts, post]; // Применяем фильтрацию сразу после обновления filterPosts(updatedPosts); return updatedPosts; }); }; // Обработчик обновления поста const handlePostUpdated = ({ post }) => { console.log('Получено событие об обновлении поста:', post._id); setPosts(prevPosts => { const updated = prevPosts.map(p => p._id === post._id ? post : p ); // Применяем фильтрацию сразу после обновления filterPosts(updated); return updated; }); }; // Обработчик удаления поста const handlePostDeleted = ({ postId }) => { console.log('Получено событие об удалении поста:', postId); setPosts(prevPosts => { const filtered = prevPosts.filter(p => p._id !== postId); // Применяем фильтрацию сразу после обновления filterPosts(filtered); return filtered; }); // Если пост открыт в модальном окне, закрываем его if (selectedPostId === postId) { console.log('Закрываем модальное окно, т.к. публикация была удалена'); setPostModalOpen(false); setSelectedPostId(null); } }; // Добавляем слушателей socket.on('post_created', handlePostCreated); socket.on('post_updated', handlePostUpdated); socket.on('post_deleted', handlePostDeleted); // Удаляем слушателей при размонтировании return () => { socket.off('post_created', handlePostCreated); socket.off('post_updated', handlePostUpdated); socket.off('post_deleted', handlePostDeleted); }; }, [socket, filterPosts]); // Получение всех публикаций для календаря const fetchPosts = async () => { setLoading(true); try { console.log('Загрузка публикаций для календаря:', id); const response = await postAPI.getPosts(id); if (response.success) { console.log('Публикации загружены:', response.data.length); setPosts(response.data); // После обновления публикаций применяем фильтры filterPosts(response.data); setError(null); } else { console.error('Ошибка при загрузке публикаций:', response.error); setError(response.error || 'Ошибка при загрузке публикаций'); } } catch (err) { console.error('Исключение при загрузке публикаций:', err); setError('Ошибка при загрузке публикаций. Проверьте сетевое соединение.'); } finally { setLoading(false); } }; // Обработчики действий с публикациями const handleDeletePost = async (postId) => { try { console.log(`Удаление публикации с ID: ${postId}`); const response = await postAPI.deletePost(postId); if (response.success) { console.log('Публикация успешно удалена'); // Обновляем локальное состояние setPosts(posts.filter(post => post._id !== postId)); // Уведомляем других пользователей через Socket.IO emitPostDeleted(postId); } else { console.error('Ошибка при удалении публикации:', response.error); // Если публикация не найдена (404), всё равно обновляем интерфейс if (response.statusCode === 404) { console.log('Публикация не найдена на сервере, обновляем UI'); setPosts(posts.filter(post => post._id !== postId)); emitPostDeleted(postId); } else { // Для других ошибок показываем сообщение setError(`Ошибка при удалении публикации: ${response.error}`); } } } catch (err) { console.error('Исключение при удалении публикации:', err); setError('Ошибка при удалении публикации: ' + (err.message || '')); } }; const handleSendToTelegram = async (postId) => { try { const response = await postAPI.sendPostToTelegram(postId); if (response.success) { // Обновляем статус публикации const updatedPosts = posts.map((post) => post._id === postId ? { ...post, status: 'готово к проверке' } : post ); setPosts(updatedPosts); // Обновляем отфильтрованные публикации filterPosts(updatedPosts); alert('Публикация успешно отправлена в Telegram'); // Если пост открыт в модальном окне, обновляем его статус if (selectedPostId === postId) { setPostModalOpen(false); setTimeout(() => { setPostModalOpen(true); }, 100); } } else { setError(response.error); } } catch (err) { setError('Ошибка при отправке публикации в Telegram'); } }; // Обработчики для модального окна просмотра поста const handleOpenPostModal = (postId) => { setSelectedPostId(postId); setPostModalOpen(true); }; const handleClosePostModal = () => { setPostModalOpen(false); }; // Обработчики для модального окна создания/редактирования поста const handleCreatePost = (date) => { setEditingPostId(null); setSelectedDate(date); setFormModalOpen(true); }; const handleEditPost = (postId) => { setEditingPostId(postId); setFormModalOpen(true); }; const handleCloseFormModal = () => { setFormModalOpen(false); setEditingPostId(null); setSelectedDate(null); }; const handlePostSaved = (post, calendarId) => { // Обновляем список постов после сохранения if (post) { if (editingPostId) { // Пост был обновлен console.log('Пост обновлен:', post._id); // Обновляем локальное состояние setPosts(prevPosts => prevPosts.map(p => p._id === post._id ? post : p) ); // Уведомляем других пользователей через Socket.IO emitPostUpdated(post); } else { // Был создан новый пост console.log('Создан новый пост:', post._id); // Обновляем локальное состояние setPosts(prevPosts => [...prevPosts, post]); // Уведомляем других пользователей через Socket.IO emitPostCreated(post); } } else { // На всякий случай обновляем полный список постов с сервера fetchPosts(); } // Сбрасываем идентификатор редактируемого поста setEditingPostId(null); setFormModalOpen(false); }; // Функции для отображения календаря const getCalendarDays = () => { const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(currentDate); const startDate = startOfWeek(monthStart, { locale: ru }); // Получаем все дни месяца return eachDayOfInterval({ start: startDate, end: monthEnd }); }; const getPostsForDay = (day) => { return filteredPosts.filter(post => isSameDay(new Date(post.publishDate), day) ); }; const renderCalendarDay = (day) => { const postsForDay = getPostsForDay(day); const isCurrentMonth = day.getMonth() === currentDate.getMonth(); const isCurrentDay = isToday(day); return ( {format(day, 'd')} {postsForDay.length > 0 && ( )} {postsForDay.slice(0, 3).map(post => ( handleOpenPostModal(post._id)} sx={{ mb: 0.5, p: 0.5, bgcolor: post.type === 'пост' ? 'rgba(33, 150, 243, 0.1)' : post.type === 'reels' ? 'rgba(244, 67, 54, 0.1)' : post.type === 'рассылка' ? 'rgba(76, 175, 80, 0.1)' : post.type === 'история' ? 'rgba(255, 152, 0, 0.1)' : 'grey.200', borderRadius: 1, fontSize: '0.75rem', cursor: 'pointer', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', '&:hover': { opacity: 0.8, } }} > {post.title} ))} {postsForDay.length > 3 && ( +{postsForDay.length - 3} ещё )} {isCurrentMonth && ( handleCreatePost(day)} sx={{ ml: 'auto', display: 'block', padding: '2px' }} > )} ); }; const renderCalendarHeader = () => { const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; return ( {weekDays.map((day, index) => ( = 5 ? 'error.main' : 'text.primary'} > {day} ))} ); }; const renderCalendarGrid = () => { const days = getCalendarDays(); return ( {days.map((day) => ( {renderCalendarDay(day)} ))} ); }; const renderCalendarNavigation = () => { // Проверяем, является ли текущий пользователь владельцем календаря const isOwner = currentCalendar && user && currentCalendar.owner._id === user._id; return ( {format(currentDate, 'LLLL yyyy', { locale: ru })} {/* Отображаем активных пользователей */} {activeUsers.length > 0 && ( Сейчас в календаре: {activeUsers.map(user => ( {user.name} ))}
} > {activeUsers.map(user => ( {user.name?.charAt(0)} ))} )} {/* Правая часть - управление */} ); }; // Фильтрация постов по текущему месяцу для режима списка const getPostsForCurrentMonth = () => { if (viewMode === ViewMode.LIST) { const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(currentDate); return filteredPosts.filter(post => { const postDate = new Date(post.publishDate); return postDate >= monthStart && postDate <= monthEnd; }); } return filteredPosts; }; // Получаем посты для текущего месяца в режиме списка const currentMonthPosts = getPostsForCurrentMonth(); if (calendarLoading || (loading && posts.length === 0)) { return ( ); } if (!currentCalendar) { return ( {calendarError || 'Календарь не найден'} ); } // Форматируем дату последнего обновления const formattedUpdatedAt = format( new Date(currentCalendar.updatedAt), "d MMMM yyyy 'в' HH:mm", { locale: ru } ); return ( {/* Верхняя панель с информацией и действиями */} {/* Кнопки для мобильных устройств - только иконки, выше заголовка */} handleCreatePost()} sx={{ mr: 1, bgcolor: 'primary.main', color: 'white', '&:hover': { bgcolor: 'primary.dark' } }} size="medium" aria-label="Новая публикация" > {/* Заголовок календаря - только для мобильных устройств */} {currentCalendar.name} {/* Кнопки для десктопной версии - с текстом, рядом с заголовком */} {currentCalendar.name} {error && ( {error} )} {/* Режимы отображения и фильтры */} {/* Переключатели месяцев - отображаются в обоих режимах */} {format(currentDate, 'LLLL yyyy', { locale: ru })} Статус Тип {/* Содержимое в зависимости от выбранного режима */} {loading ? ( ) : viewMode === ViewMode.LIST ? ( // Режим списка currentMonthPosts.length === 0 ? ( Публикации за {format(currentDate, 'LLLL yyyy', { locale: ru })} не найдены Создайте новую публикацию или измените параметры фильтрации ) : ( {currentMonthPosts.map((post) => ( ))} ) ) : ( // Режим календаря {renderCalendarNavigation()} {renderCalendarHeader()} {renderCalendarGrid()} )} {/* Модальное окно для просмотра поста */} {/* Модальное окно для создания/редактирования поста */} ); }; export default CalendarPage;