- Добавлено поле color_event в тип SidebarCalendarEvent - Добавлен цветной индикатор (h-2 w-2 rounded-full) перед названием события - Используется fallback: color_event || color || #3498db - Индикатор отображается рядом с названием события Проблема: в списке 'На этой неделе' не было визуальных индикаторов цвета события. Решение: добавлен цветной кружок перед названием каждого события.
1193 lines
50 KiB
TypeScript
1193 lines
50 KiB
TypeScript
"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 (
|
||
<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-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>
|
||
|
||
<Link href="/" className="flex items-center justify-center">
|
||
<img src="/logo.png" alt="Логотип" className="h-10 w-auto lg:h-11" />
|
||
</Link>
|
||
|
||
<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">
|
||
<button className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100">
|
||
<Bell size={18} className="text-gray-600" />
|
||
</button>
|
||
<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>
|
||
{user?.is_active ? (
|
||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||
) : null}
|
||
{/* Меню пользователя */}
|
||
{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="pb-3 lg:hidden">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-gray-200 bg-gray-50 hover:bg-slate-100"
|
||
aria-label="Уведомления"
|
||
>
|
||
<Bell size={18} className="text-gray-600" />
|
||
</button>
|
||
|
||
<div className="relative flex-1">
|
||
<Search size={16} className="pointer-events-none absolute left-3 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-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="Поиск"
|
||
/>
|
||
</div>
|
||
|
||
<div className="relative h-10 w-10" id="user-menu-root-mobile">
|
||
<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>
|
||
{user?.is_active ? (
|
||
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
|
||
) : null}
|
||
{/* Меню пользователя */}
|
||
{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>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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 }) => (
|
||
<Link key={href} href={href} className={navLinkClass(href)} onClick={onNavigate}>
|
||
<Icon size={18} className={navIconClass(href)} />
|
||
{label}
|
||
</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>
|
||
);
|
||
}
|
||
|
||
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<SidebarCalendarEvent[]>([]);
|
||
|
||
// Синхронизация selectedCalendarId с родительским компонентом
|
||
useEffect(() => {
|
||
onCalendarChange(selectedCalendarId);
|
||
}, [selectedCalendarId, onCalendarChange]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [showCalendarMenu, setShowCalendarMenu] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||
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<string>();
|
||
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 (
|
||
<>
|
||
<div className="rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100">
|
||
<div className="mb-2 flex items-center justify-between">
|
||
<p className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">Календарь</p>
|
||
<button
|
||
onClick={handleCreateCalendar}
|
||
className="flex h-6 w-6 items-center justify-center rounded-full hover:bg-gray-100 transition"
|
||
title="Создать календарь"
|
||
>
|
||
<Plus size={14} className="text-gray-600" />
|
||
</button>
|
||
</div>
|
||
{calendars.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed border-gray-300 bg-gray-50 p-3">
|
||
<p className="mb-2 text-xs font-medium text-gray-700">Календарей пока нет</p>
|
||
<button
|
||
onClick={handleCreateCalendar}
|
||
className="w-full rounded-md bg-sky-500 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-600"
|
||
>
|
||
Создать первый календарь
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||
}}
|
||
className="flex-1 rounded-lg border border-gray-300 bg-white px-2.5 py-2 text-xs text-gray-800 outline-none transition focus:border-sky-500 focus:ring-2 focus:ring-sky-100"
|
||
>
|
||
<option value="">📅 Все события</option>
|
||
{calendars.map((cal) => (
|
||
<option key={cal.id} value={cal.id}>
|
||
{cal.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{selectedCalendarId && (
|
||
<div className="relative">
|
||
<button
|
||
onClick={() => setShowCalendarMenu(!showCalendarMenu)}
|
||
className="flex h-9 w-9 items-center justify-center rounded-lg hover:bg-gray-100 transition"
|
||
title="Меню календаря"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="1"></circle>
|
||
<circle cx="12" cy="5" r="1"></circle>
|
||
<circle cx="12" cy="19" r="1"></circle>
|
||
</svg>
|
||
</button>
|
||
|
||
{showCalendarMenu && (
|
||
<>
|
||
<div
|
||
className="fixed inset-0 z-[50]"
|
||
onClick={() => setShowCalendarMenu(false)}
|
||
/>
|
||
<div className="absolute right-0 top-full mt-1 w-48 rounded-lg bg-white shadow-lg ring-1 ring-black/5 z-[60]">
|
||
<div className="py-1">
|
||
<button
|
||
onClick={() => {
|
||
const cal = calendars.find(c => c.id === selectedCalendarId);
|
||
if (cal) {
|
||
onOpenParticipantsModal({ id: cal.id, name: cal.name, user_role: (cal as any).user_role });
|
||
}
|
||
setShowCalendarMenu(false);
|
||
}}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
||
>
|
||
<Users size={16} />
|
||
Участники
|
||
</button>
|
||
<button
|
||
onClick={async () => {
|
||
try {
|
||
const blob = await apiClient.exportCalendarToICS(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);
|
||
}}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||
<polyline points="7 10 12 15 17 10"></polyline>
|
||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||
</svg>
|
||
Экспорт .ics
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
console.log('Import button clicked', fileInputRef.current);
|
||
fileInputRef.current?.click();
|
||
console.log('File input clicked');
|
||
}}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||
<polyline points="17 8 12 3 7 8"></polyline>
|
||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||
</svg>
|
||
Импорт .ics
|
||
</button>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".ics"
|
||
onChange={(e) => {
|
||
console.log('File input onChange triggered', e.target.files);
|
||
setShowCalendarMenu(false);
|
||
handleImportCalendar(e);
|
||
}}
|
||
className="hidden"
|
||
/>
|
||
|
||
<div className="my-1 border-t border-gray-100"></div>
|
||
<button
|
||
onClick={() => {
|
||
const cal = calendars.find(c => c.id === selectedCalendarId);
|
||
if (cal) {
|
||
onOpenCalendarModal(cal);
|
||
}
|
||
setShowCalendarMenu(false);
|
||
}}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition"
|
||
>
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
|
||
<circle cx="12" cy="12" r="3"></circle>
|
||
</svg>
|
||
Настройки
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
|
||
<span>Месяц</span>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1))}
|
||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-slate-100"
|
||
aria-label="Предыдущий месяц"
|
||
>
|
||
<ChevronLeft size={16} className="text-gray-600" />
|
||
</button>
|
||
<span className="min-w-28 text-center text-xs font-normal text-gray-500">{monthLabel}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMonthDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1))}
|
||
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-slate-100"
|
||
aria-label="Следующий месяц"
|
||
>
|
||
<ChevronRight size={16} className="text-gray-600" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
|
||
{weekdays.map((day) => (
|
||
<div key={day} className="py-1 font-medium">
|
||
{day}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
|
||
{days.map((day) => (
|
||
<button
|
||
key={day.key}
|
||
type="button"
|
||
onClick={() => day.inCurrentMonth && handleDayClick(day.date)}
|
||
disabled={!day.inCurrentMonth}
|
||
className={`relative flex h-9 items-center justify-center rounded-full transition ${day.isToday
|
||
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
|
||
: day.inCurrentMonth
|
||
? "hover:bg-sky-50 cursor-pointer"
|
||
: "text-gray-300 cursor-default"
|
||
}`}
|
||
>
|
||
{day.day}
|
||
{day.hasEvents && day.inCurrentMonth ? (
|
||
<span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-sky-500" />
|
||
) : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">На этой неделе</p>
|
||
{loading ? (
|
||
<p className="text-xs text-gray-500">Загрузка...</p>
|
||
) : error ? (
|
||
<p className="text-xs text-red-600">{error}</p>
|
||
) : weekEvents.length === 0 ? (
|
||
<p className="text-xs text-gray-500">Событий нет</p>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{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 (
|
||
<button
|
||
key={`${event.id}-${event.start || ""}`}
|
||
type="button"
|
||
onClick={() => handleEventClick(event)}
|
||
className="w-full rounded-lg bg-gray-50 px-2.5 py-2 text-left transition hover:bg-gray-100"
|
||
>
|
||
<div className="flex items-center gap-1">
|
||
<div
|
||
className="h-2 w-2 rounded-full flex-shrink-0"
|
||
style={{ backgroundColor: event.color_event || event.color || "#3498db" }}
|
||
/>
|
||
<p className="truncate text-xs font-medium text-gray-800">{event.title}</p>
|
||
{(event.is_recurring || event.rule) && (
|
||
<span className="text-[10px] text-sky-600 flex-shrink-0" title="Повторяющееся событие">⟲</span>
|
||
)}
|
||
</div>
|
||
<p className="mt-0.5 text-[11px] text-gray-500">{dateLabel}</p>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
|
||
<div className="sticky top-22 space-y-4 lg:pb-2 max-h-[calc(100vh-5.5rem)] overflow-y-auto">
|
||
<CalendarCard
|
||
onOpenCalendarModal={onOpenCalendarModal}
|
||
onOpenEventModal={onOpenEventModal}
|
||
onOpenParticipantsModal={onOpenParticipantsModal}
|
||
eventsRefreshTrigger={eventsRefreshTrigger}
|
||
setEventsRefreshTrigger={setEventsRefreshTrigger}
|
||
setSidebarEvents={setSidebarEvents}
|
||
onCalendarChange={onCalendarChange}
|
||
/>
|
||
</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 } = 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<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<any[]>([]); // Для ViewDayEventsModal
|
||
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(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 (
|
||
<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 (
|
||
<CalendarProvider>
|
||
<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-6 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>
|
||
<Calendar
|
||
onOpenCalendarModal={handleOpenCalendarModal}
|
||
onOpenEventModal={handleOpenEventModal}
|
||
onOpenParticipantsModal={handleOpenParticipantsModal}
|
||
eventsRefreshTrigger={eventsRefreshTrigger}
|
||
setEventsRefreshTrigger={setEventsRefreshTrigger}
|
||
setSidebarEvents={setSidebarEvents}
|
||
onCalendarChange={setCurrentSelectedCalendarId}
|
||
/>
|
||
</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>
|
||
<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>
|
||
<CalendarCard
|
||
onOpenCalendarModal={handleOpenCalendarModal}
|
||
onOpenEventModal={handleOpenEventModal}
|
||
onOpenParticipantsModal={handleOpenParticipantsModal}
|
||
eventsRefreshTrigger={eventsRefreshTrigger}
|
||
setEventsRefreshTrigger={setEventsRefreshTrigger}
|
||
setSidebarEvents={setSidebarEvents}
|
||
onCalendarChange={setCurrentSelectedCalendarId}
|
||
/>
|
||
</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>
|
||
</CalendarProvider>
|
||
);
|
||
}
|