From 93308b3267f4ce0fb6aed277a6d1c2e28ac28410 Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Wed, 25 Feb 2026 01:04:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(calendar):=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=BD=D1=8B=D0=B9=20UX=20-=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D1=8B=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D0=B4?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D1=82=D0=B2=D0=B8=D1=8F=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новые компоненты: - ViewDayEventsModal: показывает все события в выбранный день - Список событий с временем и описанием - Кнопка "Создать событие на этот день" - Клик на событие → ViewEventDetailsModal - ViewEventDetailsModal: детали события (read-only) - Все поля события (время, описание, участники) - Индикаторы повторяющихся событий � - Кнопки "Редактировать" и "Удалить" Изменения в BigCalendar: - handleSelectSlot: клик на дату → открывает ViewDayEventsModal вместо прямого создания - handleSelectEvent: клик на событие → открывает ViewEventDetailsModal вместо прямого редактирования - Добавлены обработчики переходов между модалами Изменения в AppShell: - handleOpenEventModal: логика маршрутизации модалов (дата/событие/создание) - Добавлены обработчики для новых модалов - sidebarEvents: состояние для передачи событий в ViewDayEventsModal - Интеграция в CalendarCard для обоих календарей (desktop + mobile) Преимущества UX: - Пользователь видит контекст перед действием - Предотвращение случайных дубликатов событий - Логичный flow: Просмотр → Действие (create/edit/delete) - Особенно полезно для повторяющихся событий --- frontend/src/components/AppShell.tsx | 133 +++++++++++- frontend/src/components/BigCalendar.tsx | 150 ++++++++++--- .../src/components/ViewDayEventsModal.tsx | 136 ++++++++++++ .../src/components/ViewEventDetailsModal.tsx | 204 ++++++++++++++++++ 4 files changed, 587 insertions(+), 36 deletions(-) create mode 100644 frontend/src/components/ViewDayEventsModal.tsx create mode 100644 frontend/src/components/ViewEventDetailsModal.tsx diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index d34bf95a..1c4d5207 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -10,6 +10,8 @@ import { CalendarProvider, useCalendar } from "@/contexts/CalendarContext"; import { CalendarModal } from "@/components/CalendarModal"; import CalendarParticipantsModal from "@/components/CalendarParticipantsModal"; import { EventModal } from "@/components/EventModal"; +import { ViewDayEventsModal } from "@/components/ViewDayEventsModal"; +import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal"; type AppShellProps = { children: ReactNode; @@ -299,13 +301,15 @@ function CalendarCard({ onOpenEventModal, onOpenParticipantsModal, eventsRefreshTrigger, - setEventsRefreshTrigger + setEventsRefreshTrigger, + setSidebarEvents }: { onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void; onOpenEventModal: (event: any, date?: Date) => void; onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void; eventsRefreshTrigger: number; setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void; + setSidebarEvents: (events: any[]) => void; }) { const { calendars, selectedCalendarId, setSelectedCalendarId, loading: calendarsLoading, reloadCalendars } = useCalendar(); @@ -353,6 +357,7 @@ function CalendarCard({ if (!cancelled) { const eventsList = Array.isArray(myEventsResult) ? myEventsResult : (myEventsResult?.results || []); setEvents(eventsList); + setSidebarEvents(eventsList); // Передаем события наружу } return; } @@ -382,7 +387,9 @@ function CalendarCard({ const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring); // Объединяем оба типа событий - setEvents([...regularEvents, ...recurringOccurrences]); + const allEvents = [...regularEvents, ...recurringOccurrences]; + setEvents(allEvents); + setSidebarEvents(allEvents); // Передаем события наружу для ViewDayEventsModal } } catch (err) { if (!cancelled) { @@ -803,13 +810,15 @@ function Calendar({ onOpenEventModal, onOpenParticipantsModal, eventsRefreshTrigger, - setEventsRefreshTrigger + setEventsRefreshTrigger, + setSidebarEvents }: { onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void; onOpenEventModal: (event: any, date?: Date) => void; onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void; eventsRefreshTrigger: number; setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void; + setSidebarEvents: (events: any[]) => void; }) { return ( @@ -860,6 +870,13 @@ export function AppShell({ children }: AppShellProps) { const [editingEvent, setEditingEvent] = useState(null); const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null); const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0); + + // Новые модалы для просмотра + const [showDayEventsModal, setShowDayEventsModal] = useState(false); + const [selectedDateForModal, setSelectedDateForModal] = useState(null); + const [showEventDetailsModal, setShowEventDetailsModal] = useState(false); + const [viewingEvent, setViewingEvent] = useState(null); + const [sidebarEvents, setSidebarEvents] = useState([]); // Для ViewDayEventsModal // Обработчики модалов const handleOpenCalendarModal = (calendar?: { id?: number; name: string }) => { @@ -868,10 +885,75 @@ export function AppShell({ children }: AppShellProps) { }; const handleOpenEventModal = (event: any, date?: Date) => { - setEditingEvent(event); - setShowEventModal(true); + // Если передана дата (клик на дату), открываем модал просмотра дня + if (date && !event.id) { + setSelectedDateForModal(date); + setShowDayEventsModal(true); + } + // Если передано событие с id (клик на событие), открываем детали + else if (event.id) { + setViewingEvent(event); + setShowEventDetailsModal(true); + } + // Fallback - создание нового события + else { + setEditingEvent(event); + setShowEventModal(true); + } }; + // Создание события из модала просмотра дня + const handleCreateEventFromDay = useCallback(() => { + if (!selectedDateForModal) return; + + const startDate = new Date(selectedDateForModal); + startDate.setHours(10, 0, 0, 0); + const endDate = new Date(selectedDateForModal); + endDate.setHours(11, 0, 0, 0); + + setEditingEvent({ + title: "", + description: "", + start: startDate.toISOString(), + end: endDate.toISOString(), + calendar: null, + color_event: "#3498db", + }); + + setShowDayEventsModal(false); + setShowEventModal(true); + }, [selectedDateForModal]); + + // Переход к редактированию из модала просмотра + const handleEditFromDetails = useCallback(() => { + setEditingEvent(viewingEvent); + setShowEventDetailsModal(false); + setShowEventModal(true); + }, [viewingEvent]); + + // Клик на событие из модала просмотра дня + const handleEventClickFromDay = useCallback(async (event: any) => { + setShowDayEventsModal(false); + + // Если это occurrence, загружаем базовое событие + if (event.is_recurring && event.event_id) { + try { + const fullEvent = await apiClient.getEvent(event.event_id); + setViewingEvent({ + ...fullEvent, + start: event.start, + end: event.end, + }); + setShowEventDetailsModal(true); + } catch (err) { + console.error("Ошибка загрузки события:", err); + } + } else { + setViewingEvent(event); + setShowEventDetailsModal(true); + } + }, []); + const handleEventSaved = useCallback(() => { // Обновляем список событий в CalendarCard setEventsRefreshTrigger(prev => prev + 1); @@ -953,6 +1035,7 @@ export function AppShell({ children }: AppShellProps) { onOpenParticipantsModal={handleOpenParticipantsModal} eventsRefreshTrigger={eventsRefreshTrigger} setEventsRefreshTrigger={setEventsRefreshTrigger} + setSidebarEvents={setSidebarEvents} /> @@ -1010,6 +1093,7 @@ export function AppShell({ children }: AppShellProps) { onOpenParticipantsModal={handleOpenParticipantsModal} eventsRefreshTrigger={eventsRefreshTrigger} setEventsRefreshTrigger={setEventsRefreshTrigger} + setSidebarEvents={setSidebarEvents} /> @@ -1049,6 +1133,45 @@ export function AppShell({ children }: AppShellProps) { onSave={handleEventSaved} showParticipants={true} /> + + {/* Модальное окно просмотра событий дня */} + { + setShowDayEventsModal(false); + setSelectedDateForModal(null); + }} + date={selectedDateForModal} + events={sidebarEvents} + onEventClick={handleEventClickFromDay} + onCreateEvent={handleCreateEventFromDay} + /> + + {/* Модальное окно просмотра деталей события */} + { + setShowEventDetailsModal(false); + setViewingEvent(null); + }} + event={viewingEvent} + onEdit={handleEditFromDetails} + onDelete={async () => { + if (!viewingEvent?.id) return; + if (!confirm("Удалить это событие?")) return; + + try { + await apiClient.deleteEvent(viewingEvent.id); + setShowEventDetailsModal(false); + setViewingEvent(null); + handleEventSaved(); // Обновляем список событий + } catch (err) { + console.error("Ошибка удаления события:", err); + alert("Не удалось удалить событие"); + } + }} + showParticipants={true} + /> ); diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index 57459c7d..05284715 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -9,6 +9,8 @@ import { useCalendar } from "@/contexts/CalendarContext"; import { X, Plus, Trash2, Settings, ChevronLeft, ChevronRight } from "lucide-react"; import { CalendarModal } from "@/components/CalendarModal"; import { EventModal } from "@/components/EventModal"; +import { ViewDayEventsModal } from "@/components/ViewDayEventsModal"; +import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal"; import "react-big-calendar/lib/css/react-big-calendar.css"; import "../app/calendar/calendar.css"; @@ -132,6 +134,12 @@ export function BigCalendar() { const [showCalendarModal, setShowCalendarModal] = useState(false); const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null); + // Новые модалы для просмотра + const [showDayEventsModal, setShowDayEventsModal] = useState(false); + const [selectedDate, setSelectedDate] = useState(null); + const [showEventDetailsModal, setShowEventDetailsModal] = useState(false); + const [viewingEvent, setViewingEvent] = useState(null); + // Загрузка событий const loadEvents = useCallback(async () => { try { @@ -232,57 +240,97 @@ export function BigCalendar() { // Обработчики календаря const handleSelectSlot = useCallback( - ({ start, end }: { start: Date; end: Date }) => { - if (!selectedCalendarId) { - alert("Сначала выберите календарь"); - return; - } - - // Если клик на весь день (00:00 - 00:00), устанавливаем разумное время - const startDate = new Date(start); - const endDate = new Date(end); - - // Проверяем, если время 00:00 (клик на день в режиме месяца) - if (startDate.getHours() === 0 && startDate.getMinutes() === 0) { - startDate.setHours(10, 0, 0, 0); // 10:00 - endDate.setHours(11, 0, 0, 0); // 11:00 - } - - setEditingEvent({ - title: "", - description: "", - start: startDate, - end: endDate, - calendar: selectedCalendarId, - color_event: "#3498db", - }); - setShowEventModal(true); + ({ start }: { start: Date; end: Date }) => { + // Открываем модальное окно просмотра событий в этот день + setSelectedDate(start); + setShowDayEventsModal(true); }, - [selectedCalendarId] + [] ); + // Создание события из модала просмотра дня + const handleCreateEventFromDay = useCallback(() => { + if (!selectedCalendarId) { + alert("Сначала выберите календарь"); + return; + } + + if (!selectedDate) return; + + // Устанавливаем время по умолчанию 10:00 - 11:00 + const startDate = new Date(selectedDate); + startDate.setHours(10, 0, 0, 0); + + const endDate = new Date(selectedDate); + endDate.setHours(11, 0, 0, 0); + + setEditingEvent({ + title: "", + description: "", + start: startDate, + end: endDate, + calendar: selectedCalendarId, + color_event: "#3498db", + }); + + // Закрываем модал просмотра дня и открываем модал создания + setShowDayEventsModal(false); + setShowEventModal(true); + }, [selectedCalendarId, selectedDate]); + const handleSelectEvent = useCallback(async (calEvent: CalendarEvent) => { // Если это occurrence (повторяющееся событие), загружаем базовое событие if (calEvent.isOccurrence && calEvent.event_id) { try { const fullEvent = await apiClient.getEvent(calEvent.event_id); // Устанавливаем время из occurrence, но остальные данные из базового события - setEditingEvent({ + setViewingEvent({ ...fullEvent, start: calEvent.start, end: calEvent.end, }); - setShowEventModal(true); + setShowEventDetailsModal(true); } catch (err) { console.error("Ошибка загрузки базового события:", err); } } else { - // Обычное событие - данные уже полные - setEditingEvent(calEvent); - setShowEventModal(true); + // Обычное событие - открываем детали + setViewingEvent(calEvent); + setShowEventDetailsModal(true); } }, []); + // Переход к редактированию из модала просмотра + const handleEditFromDetails = useCallback(() => { + setEditingEvent(viewingEvent); + setShowEventDetailsModal(false); + setShowEventModal(true); + }, [viewingEvent]); + + // Клик на событие из модала просмотра дня + const handleEventClickFromDay = useCallback(async (event: any) => { + setShowDayEventsModal(false); + + // Если это occurrence, загружаем базовое событие + if (event.isOccurrence && event.event_id) { + try { + const fullEvent = await apiClient.getEvent(event.event_id); + setViewingEvent({ + ...fullEvent, + start: event.start, + end: event.end, + }); + setShowEventDetailsModal(true); + } catch (err) { + console.error("Ошибка загрузки события:", err); + } + } else { + setViewingEvent(event); + setShowEventDetailsModal(true); + } + }, []); + + // Стилизация событий по цвету const eventStyleGetter = (event: CalendarEvent) => { const backgroundColor = event.color_event || "#3498db"; @@ -414,6 +462,46 @@ export function BigCalendar() { }} showParticipants={true} /> + + {/* Модальное окно просмотра событий дня */} + { + setShowDayEventsModal(false); + setSelectedDate(null); + }} + date={selectedDate} + events={events} + onEventClick={handleEventClickFromDay} + onCreateEvent={handleCreateEventFromDay} + /> + + {/* Модальное окно просмотра деталей события */} + { + setShowEventDetailsModal(false); + setViewingEvent(null); + }} + event={viewingEvent} + onEdit={handleEditFromDetails} + onDelete={async () => { + if (!viewingEvent?.id) return; + if (!confirm("Удалить это событие?")) return; + + try { + await apiClient.deleteEvent(viewingEvent.id); + setShowEventDetailsModal(false); + setViewingEvent(null); + loadEvents(); + } catch (err) { + console.error("Ошибка удаления события:", err); + alert("Не удалось удалить событие"); + } + }} + showParticipants={true} + /> + {/* Модальное окно управления календарем */} void; + date: Date | null; + events: any[]; + onEventClick: (event: any) => void; + onCreateEvent: () => void; +} + +export function ViewDayEventsModal({ + isOpen, + onClose, + date, + events, + onEventClick, + onCreateEvent, +}: ViewDayEventsModalProps) { + if (!isOpen || !date) return null; + + const formattedDate = format(date, "d MMMM yyyy, EEEE", { locale: ru }); + const capitalizedDate = formattedDate.charAt(0).toUpperCase() + formattedDate.slice(1); + + const dayEvents = events.filter(event => { + const eventDate = new Date(event.start); + return eventDate.toDateString() === date.toDateString(); + }); + + // Сортируем события по времени начала + const sortedEvents = [...dayEvents].sort((a, b) => { + return new Date(a.start).getTime() - new Date(b.start).getTime(); + }); + + const formatTime = (dateStr: string) => { + const date = new Date(dateStr); + return format(date, "HH:mm", { locale: ru }); + }; + + return ( +
+
+ {/* Header */} +
+
+

События на {capitalizedDate}

+

+ {sortedEvents.length === 0 + ? "Нет событий" + : `${sortedEvents.length} ${sortedEvents.length === 1 ? "событие" : "событий"}`} +

+
+ +
+ + {/* Events List */} +
+ {sortedEvents.length === 0 ? ( +
+ +

Нет событий в этот день

+

Создайте новое событие, нажав кнопку ниже

+
+ ) : ( +
+ {sortedEvents.map((event) => ( + + ))} +
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/ViewEventDetailsModal.tsx b/frontend/src/components/ViewEventDetailsModal.tsx new file mode 100644 index 00000000..aaa6791f --- /dev/null +++ b/frontend/src/components/ViewEventDetailsModal.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { X, Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react"; +import { format } from "date-fns"; +import { ru } from "date-fns/locale"; +import { useState, useEffect } from "react"; +import { apiClient } from "@/lib/api"; + +interface ViewEventDetailsModalProps { + isOpen: boolean; + onClose: () => void; + event: any | null; + onEdit: () => void; + onDelete: () => void; + showParticipants?: boolean; +} + +export function ViewEventDetailsModal({ + isOpen, + onClose, + event, + onEdit, + onDelete, + showParticipants = false, +}: ViewEventDetailsModalProps) { + const [participants, setParticipants] = useState([]); + const [loadingParticipants, setLoadingParticipants] = useState(false); + + useEffect(() => { + if (isOpen && event?.id && showParticipants) { + loadParticipants(); + } else { + setParticipants([]); + } + }, [isOpen, event?.id, showParticipants]); + + const loadParticipants = async () => { + if (!event?.id) return; + + try { + setLoadingParticipants(true); + const result = await apiClient.getEventParticipants(event.id); + const participantsList = Array.isArray(result) ? result : (result?.results || []); + setParticipants(participantsList); + } catch (error) { + console.error("Failed to load participants:", error); + } finally { + setLoadingParticipants(false); + } + }; + + if (!isOpen || !event) return null; + + const formatDateTime = (dateStr: string) => { + const date = new Date(dateStr); + return format(date, "d MMMM yyyy, HH:mm", { locale: ru }); + }; + + const formatDate = (dateStr: string) => { + const date = new Date(dateStr); + return format(date, "d MMMM yyyy", { locale: ru }); + }; + + const capitalizedStart = formatDateTime(event.start).charAt(0).toUpperCase() + formatDateTime(event.start).slice(1); + const capitalizedEnd = formatDateTime(event.end).charAt(0).toUpperCase() + formatDateTime(event.end).slice(1); + + return ( +
+
+ {/* Header */} +
+
+
+

+ {event.title} +

+ {event.rule && ( + + 🔁 Повторяется + + )} +
+
+
+ Календарь #{event.calendar} +
+
+ +
+ + {/* Content */} +
+ {/* Time */} +
+ +
+

Время

+

+ Начало: {capitalizedStart} +

+

+ Конец: {capitalizedEnd} +

+
+
+ + {/* Recurring Info */} + {event.rule && event.end_recurring_period && ( +
+ +
+

Повторение

+

+ До {formatDate(event.end_recurring_period)} +

+
+
+ )} + + {/* Description */} + {event.description && ( +
+ +
+

Описание

+

+ {event.description} +

+
+
+ )} + + {/* Participants */} + {showParticipants && ( +
+ +
+

+ Участники {participants.length > 0 && `(${participants.length})`} +

+ {loadingParticipants ? ( +

Загрузка...

+ ) : participants.length > 0 ? ( +
+ {participants.map((participant: any) => ( +
+
+ + {participant.user_name?.[0] || "?"} + +
+
+
+ {participant.user_name} +
+
+ {participant.distinction || "attendee"} +
+
+
+ ))} +
+ ) : ( +

Нет участников

+ )} +
+
+ )} +
+ + {/* Footer */} +
+
+ + +
+
+
+
+ ); +}