"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 Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { ReactNode, useEffect, useMemo, useState, useRef, 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"; type AppShellProps = { children: ReactNode; }; type PageHeaderProps = { title: string; subtitle?: string; badge?: string; eyebrow?: string; }; type HeaderProps = { onOpenLeftNav: () => void; onOpenCalendar: () => void; }; type LeftNavContentProps = { onNavigate?: () => void; }; const navItems = [ { href: "/", label: "Лента", icon: HomeIcon }, { href: "/messages", label: "Сообщения", icon: MessageSquare }, { href: "/calendar", label: "Календарь", icon: CalendarDays }, { href: "/employees", label: "Сотрудники", icon: Users }, { href: "/departments", label: "Отделы", icon: Building2 }, { href: "/requests", label: "Заявления", icon: FileSignature }, { href: "/documents", label: "Документы", icon: FileText }, { 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(); const [searchQuery, setSearchQuery] = useState(""); const [userMenuOpen, setUserMenuOpen] = useState(false); // Закрытие меню при клике вне useEffect(() => { if (!userMenuOpen) return; const handler = (e: MouseEvent) => { const target = e.target as Node; const desktop = document.getElementById("user-menu-root"); const mobile = document.getElementById("user-menu-root-mobile"); const insideDesktop = desktop && desktop.contains(target); const insideMobile = mobile && mobile.contains(target); if (!insideDesktop && !insideMobile) { setUserMenuOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [userMenuOpen]); const userInitials = user ? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}` : 'Г'; const userName = user ? `${user.last_name} ${user.first_name}`.trim() : 'Гость'; const submitSearch = () => { const query = searchQuery.trim(); if (!query) return; router.push(`/search?q=${encodeURIComponent(query)}`); }; return (
Логотип
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }} className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" placeholder="Поиск" />
setUserMenuOpen((v) => !v)} > {user?.avatar ? ( {userName} ) : ( userInitials )}
{user?.is_active ? ( ) : null} {/* Меню пользователя */} {userMenuOpen && (
)}
setSearchQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }} className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-10 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" placeholder="Поиск" />
setUserMenuOpen((v) => !v)} > {user?.avatar ? ( {userName} ) : ( userInitials )}
{user?.is_active ? ( ) : null} {/* Меню пользователя */} {userMenuOpen && (
)}
); } function LeftNavContent({ onNavigate }: LeftNavContentProps) { const pathname = usePathname(); const navLinkClass = (href: string) => `flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700" }`; const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400"); return (
{navItems.map(({ href, label, icon: Icon }) => ( {label} ))}
); } function LeftNav() { return ( ); } 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 (

{eyebrow}

{title}

{badge ? (
{badge}
) : null}
{subtitle ?

{subtitle}

: null}
); } export function AppShell({ children }: AppShellProps) { const { user, loading } = useUser(); const router = useRouter(); const pathname = usePathname(); const isMessageDialogPage = pathname.startsWith('/messages/') && pathname !== '/messages'; const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false); const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false); // Состояния модалов календаря const [showCalendarModal, setShowCalendarModal] = useState(false); const [showEventModal, setShowEventModal] = useState(false); const [showParticipantsModal, setShowParticipantsModal] = useState(false); const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null); 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 [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState(null); // Обработчики модалов const handleOpenCalendarModal = (calendar?: { id?: number; name: string }) => { setEditingCalendar(calendar || { name: "" }); setShowCalendarModal(true); }; const handleOpenEventModal = (event: any, date?: Date) => { // Если передана дата (клик на дату), открываем модал просмотра дня 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 (!currentSelectedCalendarId) { alert("Сначала выберите календарь"); return; } 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: currentSelectedCalendarId, color_event: "#3498db", }); setShowDayEventsModal(false); setShowEventModal(true); }, [currentSelectedCalendarId, 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); }, []); const handleOpenParticipantsModal = (calendar: { id: number; name: string; user_role?: string }) => { setParticipantsCalendar(calendar); setShowParticipantsModal(true); }; useEffect(() => { // Если загрузка завершена и пользователь не авторизован - редирект на логин if (!loading && !user) { router.push('/login'); } }, [user, loading, router]); useEffect(() => { setIsMobileLeftNavOpen(false); setIsMobileCalendarOpen(false); }, [pathname]); useEffect(() => { const onResize = () => { if (window.innerWidth >= 1024) { setIsMobileLeftNavOpen(false); setIsMobileCalendarOpen(false); } }; window.addEventListener("resize", onResize); onResize(); return () => { window.removeEventListener("resize", onResize); }; }, []); useEffect(() => { if (isMobileLeftNavOpen || isMobileCalendarOpen) { document.body.style.overflow = "hidden"; return; } document.body.style.overflow = ""; return () => { document.body.style.overflow = ""; }; }, [isMobileLeftNavOpen, isMobileCalendarOpen]); // Показываем загрузку, пока проверяем авторизацию if (loading) { return (

Загрузка...

); } // Если пользователь не авторизован, ничего не показываем (уже редиректим) if (!user) { return null; } return (
setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
{children}
setIsMobileLeftNavOpen(false)} />
{/* Модалы календаря - рендерятся на верхнем уровне */} { setShowCalendarModal(false); setEditingCalendar(null); }} calendar={editingCalendar} /> {/* Модальное окно участников календаря */} {participantsCalendar && ( { setShowParticipantsModal(false); setParticipantsCalendar(null); }} calendarId={participantsCalendar.id} calendarName={participantsCalendar.name} userRole={participantsCalendar.user_role} /> )} {/* Модальное окно события */} { setShowEventModal(false); setEditingEvent(null); }} event={editingEvent} 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} /> ); }