diff --git a/frontend/next.config.ts b/frontend/next.config.ts index c7987de8..5f0393d3 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,6 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // Отключаем React Strict Mode для production-like поведения в dev + reactStrictMode: false, + // Указываем корень проекта для Turbopack, чтобы не путался с lockfile в корне монорепо turbopack: { root: __dirname, diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index f8486334..c66a71cb 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,17 +1,19 @@ "use client"; -import { Bell, Building2, CalendarDays, ChevronLeft, ChevronRight, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Plus, Search, Trash2, Users, Wallet, X } from "lucide-react"; +import { Bell, Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, Users, Wallet, X } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import { ReactNode, useEffect, useMemo, useState, useRef, useCallback } from "react"; +import { ReactNode, useEffect, useState, useCallback } from "react"; import { apiClient } from "@/lib/api"; import { useUser } from "@/contexts/UserContext"; -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"; +import { CalendarSidebar } from "@/components/calendar/CalendarSidebar"; +import { CalendarCard } from "@/components/calendar/CalendarCard"; +import type { CalendarEvent } from "@/services/calendarService"; type AppShellProps = { children: ReactNode; @@ -44,35 +46,6 @@ const navItems = [ { href: "/finances", label: "Финансы", icon: Wallet }, ]; -const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; - -type SidebarCalendarEvent = { - id: number; - title: string; - start?: string | null; - end?: string | null; - allDay?: boolean; - color?: string | null; - color_event?: string | null; // Цвет события (альтернативное поле) - rule?: number | null; // ID правила повторения - is_recurring?: boolean; // Флаг из occurrences API -}; - -const pad = (v: number) => String(v).padStart(2, "0"); - -const formatDateKey = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - -const startOfWeekMonday = (date: Date) => { - const d = new Date(date); - d.setHours(0, 0, 0, 0); - const dayIndex = (d.getDay() + 6) % 7; // Пн = 0 - d.setDate(d.getDate() - dayIndex); - return d; -}; - -const sameDate = (a: Date, b: Date) => - a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); - function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) { const router = useRouter(); const { user, logout } = useUser(); @@ -297,551 +270,6 @@ function LeftNav() { ); } -function CalendarCard({ - onOpenCalendarModal, - onOpenEventModal, - onOpenParticipantsModal, - eventsRefreshTrigger, - setEventsRefreshTrigger, - setSidebarEvents, - onCalendarChange -}: { - 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; - onCalendarChange: (calendarId: number | null) => void; -}) { - const { calendars, selectedCalendarId, setSelectedCalendarId, loading: calendarsLoading, reloadCalendars } = useCalendar(); - - const [monthDate, setMonthDate] = useState(() => { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), 1); - }); - const [events, setEvents] = useState([]); - - // Синхронизация selectedCalendarId с родительским компонентом - useEffect(() => { - onCalendarChange(selectedCalendarId); - }, [selectedCalendarId, onCalendarChange]); - const [loading, setLoading] = useState(false); - const [showCalendarMenu, setShowCalendarMenu] = useState(false); - const [error, setError] = useState(null); - const [selectedDate, setSelectedDate] = useState(null); - const fileInputRef = useRef(null); - - // Загрузка событий выбранного календаря - useEffect(() => { - let cancelled = false; - - const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); - const end = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1); - const weekStart = startOfWeekMonday(new Date()); - const weekEndExclusive = new Date(weekStart); - weekEndExclusive.setDate(weekEndExclusive.getDate() + 7); - - const fetchStart = weekStart < start ? weekStart : start; - const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end; - - async function loadCalendarEvents() { - try { - setLoading(true); - setError(null); - - // Загружаем обычные события и occurrences параллельно - // Если selectedCalendarId === null, загружаем все доступные события со всех календарей - const [eventsResult, occurrencesResult] = await Promise.all([ - apiClient.getCalendarEvents({ - start: formatDateKey(fetchStart), - end: formatDateKey(fetchEnd), - calendar: selectedCalendarId || undefined, // undefined означает все календари - }), - apiClient.getOccurrences({ - start: formatDateKey(fetchStart), - end: formatDateKey(fetchEnd), - calendar: selectedCalendarId || undefined, - }), - ]); - - if (!cancelled) { - // Обычные события (без правил повторения) - const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []); - const regularEvents = eventsList.filter((evt: any) => !evt.rule); - - // Occurrences (развернутые повторяющиеся события) - const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []); - // Фильтруем только повторяющиеся события (обычные события уже в regularEvents) - const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring); - - // Объединяем оба типа событий - const allEvents = [...regularEvents, ...recurringOccurrences]; - setEvents(allEvents); - setSidebarEvents(allEvents); // Передаем события наружу для ViewDayEventsModal - } - } catch (err) { - if (!cancelled) { - const message = err instanceof Error ? err.message : String(err || ""); - const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message); - if (!isNetworkLike) { - console.error("Ошибка загрузки событий календаря:", err); - } - setError("Не удалось загрузить события"); - setEvents([]); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } - } - - loadCalendarEvents(); - - return () => { - cancelled = true; - }; - }, [monthDate, selectedCalendarId, eventsRefreshTrigger]); - - const monthLabel = useMemo(() => { - const label = monthDate.toLocaleDateString("ru-RU", { - month: "long", - year: "numeric", - }); - return label.charAt(0).toUpperCase() + label.slice(1); - }, [monthDate]); - - // Обработчики календарей - const handleCreateCalendar = () => { - onOpenCalendarModal({ name: "" }); - }; - - // Обработчики событий - const handleDayClick = (date: Date) => { - // Больше не блокируем при selectedCalendarId === null (разрешаем просмотр "Все события") - setSelectedDate(date); - const startDate = new Date(date); - startDate.setHours(10, 0, 0, 0); - const endDate = new Date(date); - endDate.setHours(11, 0, 0, 0); - - const newEvent = { - title: "", - description: "", - start: startDate.toISOString(), - end: endDate.toISOString(), - calendar: selectedCalendarId, - color_event: "#3498db", - }; - onOpenEventModal(newEvent, date); - }; - - const handleEventClick = async (event: any) => { - // Если это occurrence (повторяющееся событие), загружаем базовое событие - if (event.is_recurring && event.event_id) { - try { - const fullEvent = await apiClient.getEvent(event.event_id); - // Устанавливаем время из occurrence, но остальные данные из базового события - onOpenEventModal({ - ...fullEvent, - start: event.start, - end: event.end, - }); - } catch (err) { - console.error("Ошибка загрузки базового события:", err); - } - } else { - // Обычное событие - данные уже полные - onOpenEventModal(event); - } - }; - - const handleImportCalendar = async (event: React.ChangeEvent) => { - console.log('handleImportCalendar called', event.target.files); - - if (!selectedCalendarId || !event.target.files || event.target.files.length === 0) { - console.log('Early return:', { selectedCalendarId, hasFiles: !!event.target.files }); - return; - } - - const file = event.target.files[0]; - console.log('Importing file:', file.name, file.size); - - try { - const result = await apiClient.importCalendarFromICS(selectedCalendarId, file); - console.log('Import result:', result); - alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`); - // Триггерим обновление событий - setEventsRefreshTrigger(prev => prev + 1); - } catch (error: any) { - console.error('Failed to import calendar:', error); - alert(`Ошибка импорта: ${error.message}`); - } finally { - // Сбрасываем input для возможности повторного импорта того же файла - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } - }; - - const eventDays = useMemo(() => { - const days = new Set(); - events.forEach((ev) => { - if (!ev.start) return; - const eventStart = new Date(ev.start); - if (Number.isNaN(eventStart.getTime())) return; - - // Обрабатываем многодневные события - const eventEnd = ev.end ? new Date(ev.end) : eventStart; - - // Сбрасываем время для корректного сравнения - eventStart.setHours(0, 0, 0, 0); - eventEnd.setHours(0, 0, 0, 0); - - // Добавляем все дни от start до end включительно - const currentDay = new Date(eventStart); - while (currentDay <= eventEnd) { - days.add(formatDateKey(currentDay)); - currentDay.setDate(currentDay.getDate() + 1); - } - }); - return days; - }, [events]); - - const days = useMemo(() => { - const firstOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); - const weekDayIndex = (firstOfMonth.getDay() + 6) % 7; - const gridStart = new Date(firstOfMonth); - gridStart.setDate(firstOfMonth.getDate() - weekDayIndex); - - const today = new Date(); - - return Array.from({ length: 42 }, (_, i) => { - const date = new Date(gridStart); - date.setDate(gridStart.getDate() + i); - - const key = formatDateKey(date); - return { - key, - day: date.getDate(), - date, - inCurrentMonth: date.getMonth() === monthDate.getMonth(), - isToday: sameDate(date, today), - hasEvents: eventDays.has(key), - }; - }); - }, [monthDate, eventDays]); - - const weekEvents = useMemo(() => { - const weekStart = startOfWeekMonday(new Date()); - const weekEndExclusive = new Date(weekStart); - weekEndExclusive.setDate(weekEndExclusive.getDate() + 7); - - return [...events] - .filter((ev) => ev.start) - .filter((ev) => { - const start = new Date(ev.start || ""); - if (Number.isNaN(start.getTime())) return false; - return start >= weekStart && start < weekEndExclusive; - }) - .sort((a, b) => new Date(a.start || "").getTime() - new Date(b.start || "").getTime()) - .slice(0, 12); - }, [events]); - - return ( - <> -
-
-

Календарь

- -
- {calendars.length === 0 ? ( -
-

Календарей пока нет

- -
- ) : ( -
- - {selectedCalendarId && ( -
- - - {showCalendarMenu && ( - <> -
setShowCalendarMenu(false)} - /> -
-
- - - - { - console.log('File input onChange triggered', e.target.files); - setShowCalendarMenu(false); - handleImportCalendar(e); - }} - className="hidden" - /> - -
- -
-
- - )} -
- )} -
- )} -
- -
-
- Месяц -
- - {monthLabel} - -
-
-
- {weekdays.map((day) => ( -
- {day} -
- ))} -
-
- {days.map((day) => ( - - ))} -
-
- -
-

На этой неделе

- {loading ? ( -

Загрузка...

- ) : error ? ( -

{error}

- ) : weekEvents.length === 0 ? ( -

Событий нет

- ) : ( -
- {weekEvents.map((event) => { - const start = event.start ? new Date(event.start) : null; - const dateLabel = start && !Number.isNaN(start.getTime()) - ? start.toLocaleString("ru-RU", { - weekday: "short", - day: "2-digit", - month: "2-digit", - hour: "2-digit", - minute: "2-digit", - }) - : "Без даты"; - - return ( - - ); - })} -
- )} -
- - ); -} - -function Calendar({ - onOpenCalendarModal, - onOpenEventModal, - onOpenParticipantsModal, - eventsRefreshTrigger, - setEventsRefreshTrigger, - setSidebarEvents, - onCalendarChange -}: { - 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; - onCalendarChange: (calendarId: number | null) => void; -}) { - return ( - - ); -} - export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }: PageHeaderProps) { return (
@@ -882,16 +310,29 @@ export function AppShell({ children }: AppShellProps) { const [selectedDateForModal, setSelectedDateForModal] = useState(null); const [showEventDetailsModal, setShowEventDetailsModal] = useState(false); const [viewingEvent, setViewingEvent] = useState(null); - const [sidebarEvents, setSidebarEvents] = useState([]); // Для ViewDayEventsModal + const [sidebarEvents, setSidebarEvents] = useState([]); const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState(null); - // Обработчики модалов - const handleOpenCalendarModal = (calendar?: { id?: number; name: string }) => { + // Мемоизированные callback'и для предотвращения ререндеров CalendarCard + const handleSetSidebarEvents = useCallback((events: CalendarEvent[]) => { + setSidebarEvents(events); + }, []); + + const handleCalendarChange = useCallback((calendarId: number | null) => { + setCurrentSelectedCalendarId(calendarId); + }, []); + + const handleSetEventsRefreshTrigger = useCallback((value: number | ((prev: number) => number)) => { + setEventsRefreshTrigger(value); + }, []); + + // Обработчики модалов (мемоизированные для предотвращения ререндеров) + const handleOpenCalendarModal = useCallback((calendar?: { id?: number; name: string }) => { setEditingCalendar(calendar || { name: "" }); setShowCalendarModal(true); - }; + }, []); - const handleOpenEventModal = (event: any, date?: Date) => { + const handleOpenEventModal = useCallback((event: any, date?: Date) => { // Если передана дата (клик на дату), открываем модал просмотра дня if (date && !event.id) { setSelectedDateForModal(date); @@ -907,7 +348,7 @@ export function AppShell({ children }: AppShellProps) { setEditingEvent(event); setShowEventModal(true); } - }; + }, []); // Создание события из модала просмотра дня const handleCreateEventFromDay = useCallback(() => { @@ -971,10 +412,10 @@ export function AppShell({ children }: AppShellProps) { setEventsRefreshTrigger(prev => prev + 1); }, []); - const handleOpenParticipantsModal = (calendar: { id: number; name: string; user_role?: string }) => { + const handleOpenParticipantsModal = useCallback((calendar: { id: number; name: string; user_role?: string }) => { setParticipantsCalendar(calendar); setShowParticipantsModal(true); - }; + }, []); useEffect(() => { // Если загрузка завершена и пользователь не авторизован - редирект на логин @@ -1035,20 +476,19 @@ export function AppShell({ children }: AppShellProps) { } return ( - -
+
setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
{children}
-
@@ -1100,15 +540,17 @@ export function AppShell({ children }: AppShellProps) {
- + {isMobileCalendarOpen && ( + + )}
@@ -1187,6 +629,5 @@ export function AppShell({ children }: AppShellProps) { showParticipants={true} />
- ); } diff --git a/frontend/src/components/Providers.tsx b/frontend/src/components/Providers.tsx index 111347b8..2321eb3e 100644 --- a/frontend/src/components/Providers.tsx +++ b/frontend/src/components/Providers.tsx @@ -1,8 +1,15 @@ "use client"; import { UserProvider } from "@/contexts/UserContext"; +import { CalendarProvider } from "@/contexts/CalendarContext"; import { ReactNode } from "react"; export function Providers({ children }: { children: ReactNode }) { - return {children}; + return ( + + + {children} + + + ); } diff --git a/frontend/src/components/calendar/CalendarCard.tsx b/frontend/src/components/calendar/CalendarCard.tsx new file mode 100644 index 00000000..1e9887bc --- /dev/null +++ b/frontend/src/components/calendar/CalendarCard.tsx @@ -0,0 +1,466 @@ +"use client"; + +import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react"; +import { ChevronLeft, ChevronRight, Plus, Users } from "lucide-react"; +import { useCalendar } from "@/contexts/CalendarContext"; +import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents"; +import { calendarService, formatDateKey, type CalendarEvent } from "@/services/calendarService"; + +// Типы +interface CalendarCardProps { + 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: CalendarEvent[]) => void; + onCalendarChange: (calendarId: number | null) => void; +} + +// Константы +const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; + +// Утилиты +const sameDate = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + +/** + * Компонент мини-календаря в сайдбаре + */ +export const CalendarCard = memo(function CalendarCard({ + onOpenCalendarModal, + onOpenEventModal, + onOpenParticipantsModal, + eventsRefreshTrigger, + setEventsRefreshTrigger, + setSidebarEvents, + onCalendarChange, +}: CalendarCardProps) { + const { calendars, selectedCalendarId, setSelectedCalendarId } = useCalendar(); + + const [monthDate, setMonthDate] = useState(() => { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1); + }); + + const [showCalendarMenu, setShowCalendarMenu] = useState(false); + const [selectedDate, setSelectedDate] = useState(null); + const fileInputRef = useRef(null); + + // Загружаем события через хук + const { events, loading, error } = useCalendarEvents({ + monthDate, + selectedCalendarId, + eventsRefreshTrigger, + onEventsLoaded: setSidebarEvents, + }); + + // Синхронизация selectedCalendarId с родительским компонентом + const prevSelectedCalendarIdRef = useRef(undefined); + useEffect(() => { + if (prevSelectedCalendarIdRef.current !== selectedCalendarId) { + prevSelectedCalendarIdRef.current = selectedCalendarId; + onCalendarChange(selectedCalendarId); + } + }, [selectedCalendarId, onCalendarChange]); + + // Метка месяца + const monthLabel = useMemo(() => { + const label = monthDate.toLocaleDateString("ru-RU", { + month: "long", + year: "numeric", + }); + return label.charAt(0).toUpperCase() + label.slice(1); + }, [monthDate]); + + // Обработчики + const handleCreateCalendar = useCallback(() => { + onOpenCalendarModal({ name: "" }); + }, [onOpenCalendarModal]); + + const handleDayClick = useCallback((date: Date) => { + setSelectedDate(date); + const startDate = new Date(date); + startDate.setHours(10, 0, 0, 0); + const endDate = new Date(date); + endDate.setHours(11, 0, 0, 0); + + const newEvent = { + title: "", + description: "", + start: startDate.toISOString(), + end: endDate.toISOString(), + calendar: selectedCalendarId, + color_event: "#3498db", + }; + onOpenEventModal(newEvent, date); + }, [selectedCalendarId, onOpenEventModal]); + + const handleEventClick = useCallback(async (event: CalendarEvent) => { + // Если это occurrence (повторяющееся событие), загружаем базовое событие + if (event.is_recurring && event.event_id) { + try { + const fullEvent = await calendarService.loadFullEvent(event.event_id, event.start || undefined, event.end || undefined); + onOpenEventModal(fullEvent); + } catch (err) { + console.error("Ошибка загрузки базового события:", err); + } + } else { + onOpenEventModal(event); + } + }, [onOpenEventModal]); + + const handleImportCalendar = useCallback(async (event: React.ChangeEvent) => { + if (!selectedCalendarId || !event.target.files || event.target.files.length === 0) { + return; + } + + const file = event.target.files[0]; + + try { + const result = await calendarService.importFromICS(selectedCalendarId, file); + alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`); + setEventsRefreshTrigger(prev => prev + 1); + } catch (error: any) { + console.error('Failed to import calendar:', error); + alert(`Ошибка импорта: ${error.message}`); + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, [selectedCalendarId, setEventsRefreshTrigger]); + + const handleExportCalendar = useCallback(async () => { + if (!selectedCalendarId) return; + + try { + const blob = await calendarService.exportToICS(selectedCalendarId); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `calendar-${selectedCalendarId}.ics`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } catch (error) { + console.error('Failed to export calendar:', error); + alert('Не удалось экспортировать календарь'); + } + setShowCalendarMenu(false); + }, [selectedCalendarId]); + + // Дни с событиями + const eventDays = useMemo(() => { + const days = new Set(); + events.forEach((ev) => { + if (!ev.start) return; + const eventStart = new Date(ev.start); + if (Number.isNaN(eventStart.getTime())) return; + + const eventEnd = ev.end ? new Date(ev.end) : eventStart; + eventStart.setHours(0, 0, 0, 0); + eventEnd.setHours(0, 0, 0, 0); + + const currentDay = new Date(eventStart); + while (currentDay <= eventEnd) { + days.add(formatDateKey(currentDay)); + currentDay.setDate(currentDay.getDate() + 1); + } + }); + return days; + }, [events]); + + // Дни месяца для календарной сетки + const days = useMemo(() => { + const firstOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); + const weekDayIndex = (firstOfMonth.getDay() + 6) % 7; + const gridStart = new Date(firstOfMonth); + gridStart.setDate(firstOfMonth.getDate() - weekDayIndex); + + const today = new Date(); + + return Array.from({ length: 42 }, (_, i) => { + const date = new Date(gridStart); + date.setDate(gridStart.getDate() + i); + + const key = formatDateKey(date); + return { + key, + day: date.getDate(), + date, + inCurrentMonth: date.getMonth() === monthDate.getMonth(), + isToday: sameDate(date, today), + hasEvents: eventDays.has(key), + }; + }); + }, [monthDate, eventDays]); + + // События на этой неделе + const weekEvents = useMemo(() => { + const weekStart = new Date(); + weekStart.setHours(0, 0, 0, 0); + const dayIndex = (weekStart.getDay() + 6) % 7; + weekStart.setDate(weekStart.getDate() - dayIndex); + + const weekEndExclusive = new Date(weekStart); + weekEndExclusive.setDate(weekEndExclusive.getDate() + 7); + + return [...events] + .filter((ev) => ev.start) + .filter((ev) => { + const start = new Date(ev.start || ""); + if (Number.isNaN(start.getTime())) return false; + return start >= weekStart && start < weekEndExclusive; + }) + .sort((a, b) => new Date(a.start || "").getTime() - new Date(b.start || "").getTime()) + .slice(0, 12); + }, [events]); + + return ( + <> + {/* Селектор календаря */} +
+
+

Календарь

+ +
+ {calendars.length === 0 ? ( +
+

Календарей пока нет

+ +
+ ) : ( +
+ + {selectedCalendarId && ( +
+ + + {showCalendarMenu && ( + <> +
setShowCalendarMenu(false)} + /> +
+
+ + + + { + setShowCalendarMenu(false); + handleImportCalendar(e); + }} + className="hidden" + /> + +
+ +
+
+ + )} +
+ )} +
+ )} +
+ + {/* Мини-календарь */} +
+
+ Месяц +
+ + {monthLabel} + +
+
+
+ {weekdays.map((day) => ( +
+ {day} +
+ ))} +
+
+ {days.map((day) => ( + + ))} +
+
+ + {/* События на этой неделе */} +
+

На этой неделе

+ {loading ? ( +

Загрузка...

+ ) : error ? ( +

{error}

+ ) : weekEvents.length === 0 ? ( +

Событий нет

+ ) : ( +
+ {weekEvents.map((event) => { + const start = event.start ? new Date(event.start) : null; + const dateLabel = + start && !Number.isNaN(start.getTime()) + ? start.toLocaleString("ru-RU", { + weekday: "short", + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : "Без даты"; + + return ( + + ); + })} +
+ )} +
+ + ); +}); diff --git a/frontend/src/components/calendar/CalendarSidebar.tsx b/frontend/src/components/calendar/CalendarSidebar.tsx new file mode 100644 index 00000000..b73b52a7 --- /dev/null +++ b/frontend/src/components/calendar/CalendarSidebar.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { memo } from "react"; +import { CalendarCard } from "@/components/calendar/CalendarCard"; +import type { CalendarEvent } from "@/services/calendarService"; + +interface CalendarSidebarProps { + 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: CalendarEvent[]) => void; + onCalendarChange: (calendarId: number | null) => void; +} + +/** + * Обертка для календаря в правом сайдбаре (десктоп) + */ +export const CalendarSidebar = memo(function CalendarSidebar(props: CalendarSidebarProps) { + return ( + + ); +}); diff --git a/frontend/src/contexts/CalendarContext.tsx b/frontend/src/contexts/CalendarContext.tsx index 7867e997..34502c0a 100644 --- a/frontend/src/contexts/CalendarContext.tsx +++ b/frontend/src/contexts/CalendarContext.tsx @@ -36,7 +36,6 @@ export function CalendarProvider({ children }: { children: ReactNode }) { const result = await apiClient.getCalendars(); // API возвращает пагинированный ответ: {count, results} const cals = Array.isArray(result) ? result : (result?.results || []); - console.log("Загружено календарей:", cals.length, cals); setCalendars(cals); // По умолчанию показываем "Все события" (selectedCalendarId === null) @@ -49,7 +48,7 @@ export function CalendarProvider({ children }: { children: ReactNode }) { } finally { setLoading(false); } - }, [selectedCalendarId]); + }, []); useEffect(() => { loadCalendars(); diff --git a/frontend/src/hooks/calendar/useCalendarEvents.ts b/frontend/src/hooks/calendar/useCalendarEvents.ts new file mode 100644 index 00000000..f86a009f --- /dev/null +++ b/frontend/src/hooks/calendar/useCalendarEvents.ts @@ -0,0 +1,95 @@ +/** + * Хук для загрузки событий календаря + * Обрабатывает кэширование, загрузку и ошибки + */ + +import { useEffect, useState, useRef } from "react"; +import { calendarService, type CalendarEvent, startOfWeekMonday } from "@/services/calendarService"; + +interface UseCalendarEventsOptions { + monthDate: Date; + selectedCalendarId: number | null; + eventsRefreshTrigger: number; + onEventsLoaded?: (events: CalendarEvent[]) => void; +} + +interface UseCalendarEventsResult { + events: CalendarEvent[]; + loading: boolean; + error: string | null; +} + +export function useCalendarEvents({ + monthDate, + selectedCalendarId, + eventsRefreshTrigger, + onEventsLoaded, +}: UseCalendarEventsOptions): UseCalendarEventsResult { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const loadingRef = useRef(false); + + useEffect(() => { + let cancelled = false; + + // Вычисляем диапазон дат для загрузки + const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1); + const end = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1); + const weekStart = startOfWeekMonday(new Date()); + const weekEndExclusive = new Date(weekStart); + weekEndExclusive.setDate(weekEndExclusive.getDate() + 7); + + // Расширяем диапазон для включения текущей недели + const fetchStart = weekStart < start ? weekStart : start; + const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end; + + async function loadEvents() { + // Защита от множественных одновременных запросов + if (loadingRef.current) return; + loadingRef.current = true; + + try { + setLoading(true); + setError(null); + + const loadedEvents = await calendarService.loadEvents( + fetchStart, + fetchEnd, + selectedCalendarId, + eventsRefreshTrigger + ); + + if (!cancelled) { + setEvents(loadedEvents); + onEventsLoaded?.(loadedEvents); + } + } catch (err) { + if (!cancelled) { + const message = err instanceof Error ? err.message : String(err || ""); + const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message); + + if (!isNetworkLike) { + console.error("Ошибка загрузки событий календаря:", err); + } + + setError("Не удалось загрузить события"); + setEvents([]); + } + } finally { + if (!cancelled) { + setLoading(false); + loadingRef.current = false; + } + } + } + + loadEvents(); + + return () => { + cancelled = true; + }; + }, [monthDate, selectedCalendarId, eventsRefreshTrigger, onEventsLoaded]); + + return { events, loading, error }; +} diff --git a/frontend/src/services/calendarService.ts b/frontend/src/services/calendarService.ts new file mode 100644 index 00000000..78d9770b --- /dev/null +++ b/frontend/src/services/calendarService.ts @@ -0,0 +1,155 @@ +/** + * Сервис для работы с календарными событиями + * Включает кэширование для оптимизации запросов + */ + +import { apiClient } from "@/lib/api"; + +// Типы +export interface CalendarEvent { + id: number; + title: string; + start?: string | null; + end?: string | null; + allDay?: boolean; + color?: string | null; + color_event?: string | null; + rule?: number | null; + is_recurring?: boolean; + event_id?: number; + description?: string; + calendar?: number; +} + +interface EventsCache { + key: string; + events: CalendarEvent[]; + timestamp: number; +} + +// Константы +const CACHE_TTL = 30000; // 30 секунд + +// Глобальный кэш для событий календаря +let eventsCache: EventsCache | null = null; + +// Утилиты для работы с датами +const pad = (v: number) => String(v).padStart(2, "0"); + +export const formatDateKey = (date: Date): string => + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + +export const startOfWeekMonday = (date: Date): Date => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + const dayIndex = (d.getDay() + 6) % 7; // Пн = 0 + d.setDate(d.getDate() - dayIndex); + return d; +}; + +// Сервис для работы с событиями календаря +class CalendarService { + /** + * Загружает события календаря с кэшированием + * @param startDate Начальная дата периода + * @param endDate Конечная дата периода + * @param calendarId ID календаря (null - все календари) + * @param refreshTrigger Триггер для принудительного обновления + */ + async loadEvents( + startDate: Date, + endDate: Date, + calendarId: number | null, + refreshTrigger: number + ): Promise { + const cacheKey = `${formatDateKey(startDate)}_${formatDateKey(endDate)}_${calendarId}_${refreshTrigger}`; + + // Проверяем кэш + if (eventsCache && eventsCache.key === cacheKey && Date.now() - eventsCache.timestamp < CACHE_TTL) { + return eventsCache.events; + } + + // Загружаем события + const [eventsResult, occurrencesResult] = await Promise.all([ + apiClient.getCalendarEvents({ + start: formatDateKey(startDate), + end: formatDateKey(endDate), + calendar: calendarId || undefined, + }), + apiClient.getOccurrences({ + start: formatDateKey(startDate), + end: formatDateKey(endDate), + calendar: calendarId || undefined, + }), + ]); + + // Обрабатываем результаты + const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []); + const regularEvents = eventsList.filter((evt: any) => !evt.rule); + + const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []); + const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring); + + // Объединяем оба типа событий + const allEvents = [...regularEvents, ...recurringOccurrences]; + + // Сохраняем в кэш + eventsCache = { + key: cacheKey, + events: allEvents, + timestamp: Date.now(), + }; + + return allEvents; + } + + /** + * Очищает кэш событий + */ + clearCache(): void { + eventsCache = null; + } + + /** + * Загружает полное событие по ID (для повторяющихся событий) + */ + async loadFullEvent(eventId: number, occurrenceStart?: string, occurrenceEnd?: string): Promise { + const fullEvent = await apiClient.getEvent(eventId); + + // Если переданы время occurrence, используем их + if (occurrenceStart && occurrenceEnd) { + return { + ...fullEvent, + start: occurrenceStart, + end: occurrenceEnd, + }; + } + + return fullEvent; + } + + /** + * Импортирует календарь из ICS файла + */ + async importFromICS(calendarId: number, file: File): Promise<{ imported: number; skipped: number }> { + return apiClient.importCalendarFromICS(calendarId, file); + } + + /** + * Экспортирует календарь в ICS файл + */ + async exportToICS(calendarId: number): Promise { + return apiClient.exportCalendarToICS(calendarId); + } + + /** + * Удаляет событие + */ + async deleteEvent(eventId: number): Promise { + await apiClient.deleteEvent(eventId); + this.clearCache(); // Очищаем кэш после удаления + } +} + +// Экспортируем singleton +export const calendarService = new CalendarService();