Corp/frontend/src/components/AppShell.tsx
lizerk f28eda6929 feat: Улучшения страницы сотрудника и миграция на ChatMembership
Backend:
- Полная миграция на ChatMembership для всех типов чатов
- Удалены проверки participants M2M поля
- Добавлена обработка participants при создании чатов
- API методы для создания/редактирования/удаления кадровых событий

Frontend:
- Редизайн страницы пользователя с lucide-react иконами
- Добавлено редактирование профиля (ФИО, контакты, аватар)
- Функциональные кадровые события (создание/редактирование/удаление)
- История кадровых событий с цветовой кодировкой
- Исправлено создание дубликатов чатов (поиск по member_ids)
- Удалены визуальные статусные индикаторы (is_active ≠ online)
- Добавлен статус сотрудника из последнего action

Fixes:
- type 'private' вместо 'direct' для личных чатов
- ChatMembership проверки в user_can_access_chat()
- member_ids в ChatListSerializer для поиска чатов
2026-03-16 18:21:24 +03:00

746 lines
30 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.

"use client";
import { Bell, Building2, CalendarDays, ChevronDown, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users, Wallet, X, Sparkles } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useRef, useState, useCallback, useMemo } from "react";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { useNotifications } from "@/hooks/useApi";
import { getVerbCategory } from "@/lib/verbTranslations";
import { CalendarModal } from "@/components/CalendarModal";
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
import { NotificationCenter, NotificationPanel } from "@/components/NotificationCenter";
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;
};
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, category: "Новости" },
{ href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" },
{ href: "/employees", label: "Сотрудники", icon: Users },
{ href: "/departments", label: "Отделы", icon: Building2 },
{ href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" },
{ href: "/equipment", label: "Оборудование", icon: Monitor },
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки" },
{ href: "/documents", label: "Документы", icon: FileText, category: "Документы" },
{ href: "/finances", label: "Финансы", icon: Wallet },
];
function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const router = useRouter();
const { user, logout } = useUser();
const [searchQuery, setSearchQuery] = useState("");
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// Закрытие меню при клике вне
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]);
// Закрытие панели уведомлений при клике вне
useEffect(() => {
if (!isNotificationsOpen) return;
const handler = (e: MouseEvent) => {
const target = e.target as Node;
const notificationButton = document.querySelector('[aria-label="Уведомления"]');
const notificationPanel = document.querySelector('.notification-panel-mobile');
const insideButton = notificationButton && notificationButton.contains(target);
const insidePanel = notificationPanel && notificationPanel.contains(target);
if (!insideButton && !insidePanel) {
setIsNotificationsOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [isNotificationsOpen]);
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 (
<header className="sticky top-0 z-[40] border-b border-slate-100 bg-white/90 backdrop-blur">
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div className="flex h-8 lg:h-14 items-center justify-between gap-3">
<button
type="button"
onClick={onOpenLeftNav}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Открыть левое меню"
>
<Menu size={20} className="text-gray-700"/>
</button>
<button
type="button"
onClick={() => {
setIsSearchOpen((v) => {
if (!v) setTimeout(() => searchInputRef.current?.focus(), 50);
return !v;
});
setIsNotificationsOpen(false);
}}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Поиск"
>
<Search size={20} className="text-gray-700" />
</button>
<Link href="/" className="flex items-center justify-center">
<img src="/logo.png" alt="Логотип" className="mt-3 lg:mt-0 h-10 w-auto lg:h-11 bg-white lg:bg-transparent rounded pb-0.5 lg:pb-0" />
</Link>
<div className="lg:hidden">
<NotificationCenter variant="mobile" isOpen={isNotificationsOpen} onToggle={() => {
setIsNotificationsOpen((v) => !v);
setIsSearchOpen(false);
}} />
</div>
<button
type="button"
onClick={onOpenCalendar}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Открыть календарь"
>
<CalendarDays size={20} className="text-gray-700" />
</button>
<div className="hidden flex-1 items-center justify-center lg:flex">
<div className="relative w-full max-w-xl">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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="Поиск"
/>
</div>
</div>
<div className="ml-auto hidden items-center gap-1 sm:gap-2 lg:flex">
<NotificationCenter />
<div className="ml-1 relative h-10 w-10" id="user-menu-root">
<div
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white hover:bg-sky-500 cursor-pointer"
title={userName}
onClick={() => setUserMenuOpen((v) => !v)}
>
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
{/* Меню пользователя */}
{userMenuOpen && (
<div className="absolute right-0 top-12 z-[60] w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/settings'); }}
>Настройки</button>
<div className="my-1 border-t border-slate-100" />
<button
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
onClick={() => { setUserMenuOpen(false); logout(); }}
>Выйти</button>
</div>
)}
</div>
</div>
</div>
{/* Мобильная строка поиска */}
<div
className={`overflow-hidden transition-all duration-300 lg:hidden ${
isSearchOpen ? "max-h-20 pb-3" : "max-h-0"
}`}
>
<div className="relative">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitSearch();
setIsSearchOpen(false);
}
if (e.key === "Escape") {
setIsSearchOpen(false);
}
}}
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="Поиск по сайту..."
/>
</div>
</div>
{/* Мобильная панель уведомлений */}
<div
className={`notification-panel-mobile overflow-hidden transition-all duration-300 lg:hidden ${
isNotificationsOpen ? "max-h-[70vh] pb-3" : "max-h-0"
}`}
>
{isNotificationsOpen && (
<NotificationPanel onClose={() => setIsNotificationsOpen(false)} />
)}
</div>
</div>
</header>
);
}
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const pathname = usePathname();
const { notifications: notificationsData, markCategoryAsRead } = useNotifications();
const notifications = Array.isArray(notificationsData) ? notificationsData : [];
// Подсчет уведомлений по категориям
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {};
notifications.forEach((n: any) => {
// Пропускаем прочитанные
if (!n.verb || n.is_read) return;
const category = getVerbCategory(n.verb);
counts[category] = (counts[category] || 0) + 1;
});
return counts;
}, [notifications]);
const handleNavClick = async (category?: string) => {
// Помечаем уведомления категории как прочитанные
if (category && categoryCounts[category] > 0) {
await markCategoryAsRead(category);
}
// Вызываем callback для закрытия мобильного меню
if (onNavigate) {
onNavigate();
}
};
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 (
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="space-y-2 text-sm text-gray-700">
{navItems.map(({ href, label, icon: Icon, category }) => {
const count = category ? categoryCounts[category] || 0 : 0;
return (
<Link
key={href}
href={href}
className={navLinkClass(href)}
onClick={() => handleNavClick(category)}
>
<Icon size={18} className={navIconClass(href)} />
<span className="flex-1">{label}</span>
{count > 0 && (
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-sky-500 px-1.5 text-[10px] font-bold text-white">
{count > 99 ? '99+' : count}
</span>
)}
</Link>
);
})}
</div>
</div>
);
}
function LeftNav() {
return (
<aside className="hidden w-64 flex-shrink-0 lg:block lg:sticky lg:self-start lg:top-22 lg:max-h-[calc(100vh-5.5rem)] lg:overflow-y-auto">
<div className="space-y-4 lg:pb-2">
<LeftNavContent />
</div>
</aside>
);
}
export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }: PageHeaderProps) {
return (
<div className="flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-sky-600">{eyebrow}</p>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
</div>
{badge ? (
<div className="rounded-full bg-sky-50 px-4 py-2 text-xs font-medium text-sky-700 ring-1 ring-sky-100">{badge}</div>
) : null}
</div>
{subtitle ? <p className="text-sm text-gray-600">{subtitle}</p> : null}
</div>
);
}
export function AppShell({ children }: AppShellProps) {
const { user, loading, logout } = useUser();
const router = useRouter();
const pathname = usePathname();
const isMessageDialogPage = pathname.startsWith('/messages/') &&
pathname !== '/messages' &&
!pathname.includes('/settings');
// Вычисляем данные пользователя один раз на уровне AppShell
const userInitials = user
? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}`
: 'Г';
const userName = user
? `${user.last_name} ${user.first_name}`.trim()
: 'Гость';
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
const [isProfileExpanded, setIsProfileExpanded] = 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<any>(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<Date | null>(null);
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
const [viewingEvent, setViewingEvent] = useState<any>(null);
const [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(null);
// Мемоизированные 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 = useCallback((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 = useCallback((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 (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white flex items-center justify-center">
<div className="text-center">
<div className="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-sky-400 border-t-transparent"></div>
<p className="text-sm text-gray-500">Загрузка...</p>
</div>
</div>
);
}
// Если пользователь не авторизован, ничего не показываем (уже редиректим)
if (!user) {
return null;
}
return (
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} bg-gradient-to-b from-sky-50 via-white to-white text-gray-900 flex flex-col`}>
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
<div className="mx-auto flex w-full flex-1 min-h-0 max-w-6xl gap-6 px-4 py-4 sm:px-8 lg:py-8">
<LeftNav />
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>{children}</main>
<CalendarSidebar
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={handleSetEventsRefreshTrigger}
setSidebarEvents={handleSetSidebarEvents}
onCalendarChange={handleCalendarChange}
/>
</div>
<div className={`fixed inset-0 z-[100] lg:hidden ${isMobileLeftNavOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileLeftNavOpen ? "opacity-100" : "opacity-0"}`}
onClick={() => setIsMobileLeftNavOpen(false)}
aria-label="Закрыть левое меню"
/>
<div
className={`absolute inset-y-0 left-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${isMobileLeftNavOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Меню</p>
<button
type="button"
onClick={() => setIsMobileLeftNavOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть меню"
>
<X size={20} className="text-gray-700" />
</button>
</div>
{/* Профиль пользователя */}
<div className="mb-6 rounded-xl bg-gradient-to-br from-sky-50 to-sky-100 ring-1 ring-sky-100 overflow-hidden">
<button
onClick={() => setIsProfileExpanded(!isProfileExpanded)}
className="w-full flex items-center gap-3 p-4 hover:bg-sky-200/50 transition-colors"
>
<div className="h-12 w-12 overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white flex items-center justify-center flex-shrink-0">
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-semibold text-gray-900">{userName}</p>
</div>
<ChevronDown
size={20}
className={`text-gray-700 transition-transform flex-shrink-0 ${isProfileExpanded ? "rotate-180" : ""}`}
/>
</button>
{isProfileExpanded && (
<div className="border-t border-sky-200 p-2 space-y-2">
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
onClick={() => { setIsMobileLeftNavOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-white text-gray-700"
onClick={() => { setIsMobileLeftNavOpen(false); router.push('/settings'); }}
>Настройки</button>
<button
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-red-50 text-red-600"
onClick={() => { setIsMobileLeftNavOpen(false); logout(); }}
>Выйти</button>
</div>
)}
</div>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</div>
</div>
<div className={`fixed inset-0 z-[100] lg:hidden ${isMobileCalendarOpen ? "pointer-events-auto" : "pointer-events-none"}`}>
<button
type="button"
className={`absolute inset-0 bg-black/40 transition-opacity ${isMobileCalendarOpen ? "opacity-100" : "opacity-0"}`}
onClick={() => setIsMobileCalendarOpen(false)}
aria-label="Закрыть календарь"
/>
<div
className={`absolute inset-y-0 right-0 w-full overflow-y-auto bg-white p-4 transition-transform duration-300 ${isMobileCalendarOpen ? "translate-x-0" : "translate-x-full"
}`}
>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Календарь</p>
<button
type="button"
onClick={() => setIsMobileCalendarOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть календарь"
>
<X size={20} className="text-gray-700" />
</button>
</div>
{isMobileCalendarOpen && (
<CalendarCard
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={handleSetEventsRefreshTrigger}
setSidebarEvents={handleSetSidebarEvents}
onCalendarChange={handleCalendarChange}
/>
)}
</div>
</div>
{/* Модалы календаря - рендерятся на верхнем уровне */}
<CalendarModal
isOpen={showCalendarModal}
onClose={() => {
setShowCalendarModal(false);
setEditingCalendar(null);
}}
calendar={editingCalendar}
/>
{/* Модальное окно участников календаря */}
{participantsCalendar && (
<CalendarParticipantsModal
isOpen={showParticipantsModal}
onClose={() => {
setShowParticipantsModal(false);
setParticipantsCalendar(null);
}}
calendarId={participantsCalendar.id}
calendarName={participantsCalendar.name}
userRole={participantsCalendar.user_role}
/>
)}
{/* Модальное окно события */}
<EventModal
isOpen={showEventModal}
onClose={() => {
setShowEventModal(false);
setEditingEvent(null);
}}
event={editingEvent}
onSave={handleEventSaved}
showParticipants={true}
/>
{/* Модальное окно просмотра событий дня */}
<ViewDayEventsModal
isOpen={showDayEventsModal}
onClose={() => {
setShowDayEventsModal(false);
setSelectedDateForModal(null);
}}
date={selectedDateForModal}
events={sidebarEvents}
onEventClick={handleEventClickFromDay}
onCreateEvent={handleCreateEventFromDay}
/>
{/* Модальное окно просмотра деталей события */}
<ViewEventDetailsModal
isOpen={showEventDetailsModal}
onClose={() => {
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}
/>
</div>
);
}