861 lines
35 KiB
TypeScript
861 lines
35 KiB
TypeScript
"use client";
|
||
|
||
import { Calendar, dateFnsLocalizer, View, ToolbarProps } from "react-big-calendar";
|
||
import { format, parse, startOfWeek, getDay } from "date-fns";
|
||
import { ru } from "date-fns/locale";
|
||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||
import { apiClient } from "@/lib/api";
|
||
import { useCalendar } from "@/contexts/CalendarContext";
|
||
import { Plus, ChevronDown, ChevronLeft, ChevronRight, Users, Pencil, Trash2 } from "lucide-react";
|
||
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 { DEFAULT_EVENT_COLOR, resolveEventColor } from "@/lib/calendar-event-colors";
|
||
import { calendarService } from "@/services/calendarService";
|
||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||
import "../app/calendar/calendar.css";
|
||
|
||
// Настройка локализации для react-big-calendar
|
||
const locales = {
|
||
ru: ru,
|
||
};
|
||
|
||
const localizer = dateFnsLocalizer({
|
||
format,
|
||
parse,
|
||
startOfWeek: () => startOfWeek(new Date(), { locale: ru }),
|
||
getDay,
|
||
locales,
|
||
});
|
||
|
||
// Типы для событий
|
||
type CalendarEvent = {
|
||
id: number;
|
||
title: string;
|
||
description?: string;
|
||
start: Date;
|
||
end: Date;
|
||
calendar: number;
|
||
color_event?: string;
|
||
rule?: number;
|
||
rule_description?: string;
|
||
isOccurrence?: boolean; // Флаг для повторяющихся событий
|
||
event_id?: number; // ID базового события для occurrence
|
||
allDay?: boolean; // Флаг целодневного события
|
||
can_edit?: boolean;
|
||
can_delete?: boolean;
|
||
};
|
||
|
||
// Кастомные сообщения на русском
|
||
const messages = {
|
||
allDay: "Весь день",
|
||
previous: "Назад",
|
||
next: "Вперёд",
|
||
today: "Сегодня",
|
||
month: "Месяц",
|
||
week: "Неделя",
|
||
day: "День",
|
||
agenda: "Список",
|
||
date: "Дата",
|
||
time: "Время",
|
||
event: "Событие",
|
||
noEventsInRange: "В этом диапазоне нет событий",
|
||
showMore: (total: number) => `+${total} ещё`,
|
||
};
|
||
|
||
// Кастомный тулбар с улучшенным UI
|
||
const CustomToolbar = ({ label, onNavigate, onView, view, date }: ToolbarProps<CalendarEvent, object>) => {
|
||
const viewLabels = {
|
||
month: "Месяц",
|
||
week: "Неделя",
|
||
day: "День",
|
||
agenda: "Список",
|
||
};
|
||
|
||
// Форматируем дату в именительном падеже (Февраль 2026 вместо февраля 2026)
|
||
const formattedLabel = format(date, "LLLL yyyy", { locale: ru });
|
||
const capitalizedLabel = formattedLabel.charAt(0).toUpperCase() + formattedLabel.slice(1);
|
||
|
||
return (
|
||
<div className="mb-4 space-y-3">
|
||
{/* Навигация */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => onNavigate("TODAY")}
|
||
className="app-action-secondary rounded-xl px-4 py-2"
|
||
>
|
||
Сегодня
|
||
</button>
|
||
<button
|
||
onClick={() => onNavigate("PREV")}
|
||
className="app-action-secondary flex h-10 w-10 items-center justify-center rounded-xl p-0"
|
||
title="Назад"
|
||
>
|
||
<ChevronLeft size={18} />
|
||
</button>
|
||
<button
|
||
onClick={() => onNavigate("NEXT")}
|
||
className="app-action-secondary flex h-10 w-10 items-center justify-center rounded-xl p-0"
|
||
title="Вперёд"
|
||
>
|
||
<ChevronRight size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Текущая дата */}
|
||
<span className="text-lg font-semibold text-[var(--foreground)]">{capitalizedLabel}</span>
|
||
</div>
|
||
|
||
{/* Выбор вида */}
|
||
<div className="relative">
|
||
<select
|
||
value={view}
|
||
onChange={(e) => onView(e.target.value as View)}
|
||
className="app-select w-full appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
|
||
>
|
||
<option value="month">{viewLabels.month}</option>
|
||
<option value="week">{viewLabels.week}</option>
|
||
<option value="day">{viewLabels.day}</option>
|
||
<option value="agenda">{viewLabels.agenda}</option>
|
||
</select>
|
||
<ChevronDown size={16} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export function BigCalendar() {
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const searchParams = useSearchParams();
|
||
const {
|
||
calendars,
|
||
selectedCalendarId,
|
||
setSelectedCalendarId,
|
||
calendarScope,
|
||
setCalendarScope,
|
||
canUseAllCalendarsMode,
|
||
reloadCalendars,
|
||
} = useCalendar();
|
||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [currentView, setCurrentView] = useState<View>("month");
|
||
const [currentDate, setCurrentDate] = useState(new Date());
|
||
|
||
// Модальные окна
|
||
const [showEventModal, setShowEventModal] = useState(false);
|
||
const [editingEvent, setEditingEvent] = useState<Partial<CalendarEvent> | null>(null);
|
||
const [showCalendarModal, setShowCalendarModal] = useState(false);
|
||
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
|
||
const [showCalendarActions, setShowCalendarActions] = useState(false);
|
||
const [showParticipantsModal, setShowParticipantsModal] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Новые модалы для просмотра
|
||
const [showDayEventsModal, setShowDayEventsModal] = useState(false);
|
||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
|
||
const [viewingEvent, setViewingEvent] = useState<CalendarEvent | null>(null);
|
||
const eventsRequestIdRef = useRef(0);
|
||
const linkedEventId = Number(searchParams.get("event") || "");
|
||
const linkedCalendarId = Number(searchParams.get("calendar") || "");
|
||
const linkedCalendarName = searchParams.get("calendarName") || "";
|
||
const linkedCalendarType = searchParams.get("calendarType") || "";
|
||
const linkedContextType = searchParams.get("contextType") || "";
|
||
|
||
const selectedCalendar = useMemo(() => {
|
||
const calendar = calendars.find((item) => item.id === selectedCalendarId);
|
||
if (calendar) return calendar;
|
||
if (selectedCalendarId && linkedCalendarName) {
|
||
return {
|
||
id: selectedCalendarId,
|
||
name: linkedCalendarName,
|
||
slug: `linked-calendar-${selectedCalendarId}`,
|
||
type: linkedCalendarType || null,
|
||
context_type: linkedContextType || null,
|
||
can_create_events: true,
|
||
can_edit_calendar: false,
|
||
can_manage_participants: false,
|
||
};
|
||
}
|
||
return null;
|
||
}, [calendars, linkedCalendarName, linkedCalendarType, linkedContextType, selectedCalendarId]);
|
||
|
||
const calendarOptions = useMemo(() => {
|
||
if (!selectedCalendar) return calendars;
|
||
if (calendars.some((item) => item.id === selectedCalendar.id)) {
|
||
return calendars;
|
||
}
|
||
return [...calendars, selectedCalendar];
|
||
}, [calendars, selectedCalendar]);
|
||
|
||
const canOpenParticipantsModal = Boolean(
|
||
selectedCalendar && (
|
||
selectedCalendar.can_manage_participants ||
|
||
selectedCalendar.type === "department" ||
|
||
selectedCalendar.context_type === "department"
|
||
)
|
||
);
|
||
const isDepartmentCalendar = Boolean(
|
||
selectedCalendar && (
|
||
selectedCalendar.type === "department" ||
|
||
selectedCalendar.context_type === "department"
|
||
)
|
||
);
|
||
|
||
const clearEventParam = useCallback(() => {
|
||
if (!searchParams.get("event")) return;
|
||
const nextParams = new URLSearchParams(searchParams.toString());
|
||
nextParams.delete("event");
|
||
router.replace(nextParams.toString() ? `${pathname}?${nextParams.toString()}` : pathname, { scroll: false });
|
||
}, [pathname, router, searchParams]);
|
||
|
||
useEffect(() => {
|
||
if (!linkedCalendarId) return;
|
||
setSelectedCalendarId(linkedCalendarId);
|
||
}, [linkedCalendarId, setSelectedCalendarId]);
|
||
|
||
// Загрузка событий
|
||
const loadEvents = useCallback(async () => {
|
||
const requestId = ++eventsRequestIdRef.current;
|
||
const requestedCalendarId = selectedCalendarId;
|
||
|
||
try {
|
||
setLoading(true);
|
||
|
||
// Вычисляем диапазон для загрузки (месяц ±1 неделя)
|
||
const start = new Date(currentDate);
|
||
start.setDate(1);
|
||
start.setDate(start.getDate() - 7);
|
||
|
||
const end = new Date(currentDate);
|
||
end.setMonth(end.getMonth() + 1);
|
||
end.setDate(7);
|
||
|
||
const startStr = start.toISOString().split("T")[0];
|
||
const endStr = end.toISOString().split("T")[0];
|
||
|
||
// Загружаем обычные события и occurrences параллельно
|
||
// Если selectedCalendarId === null, загружаем все доступные события со всех календарей
|
||
const [eventsResult, occurrencesResult] = await Promise.all([
|
||
apiClient.getCalendarEvents({
|
||
calendar: requestedCalendarId || undefined,
|
||
scope: canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined,
|
||
start: startStr,
|
||
end: endStr,
|
||
}),
|
||
apiClient.getOccurrences({
|
||
calendar: requestedCalendarId || undefined,
|
||
scope: canUseAllCalendarsMode && calendarScope === "all" ? "all" : undefined,
|
||
start: startStr,
|
||
end: endStr,
|
||
}),
|
||
]);
|
||
|
||
// Обработка обычных событий (без правил повторения)
|
||
const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []);
|
||
const regularEvents = eventsList
|
||
.filter((evt: any) => !evt.rule) // Только события без правил
|
||
.map((evt: any) => {
|
||
const startDate = new Date(evt.start);
|
||
const endDate = new Date(evt.end);
|
||
|
||
return {
|
||
...evt,
|
||
start: startDate,
|
||
end: endDate,
|
||
allDay: false, // Явно указываем что событие с временем
|
||
};
|
||
});
|
||
|
||
// Обработка occurrences (повторяющихся событий)
|
||
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
|
||
|
||
const occurrenceEvents = occurrencesList
|
||
.filter((occ: any) => occ && occ.is_recurring) // Только повторяющиеся (обычные события уже в regularEvents)
|
||
.map((occ: any) => ({
|
||
id: occ.id,
|
||
title: occ.title.startsWith('⟲') ? occ.title : `⟲ ${occ.title}`, // Добавляем индикатор повторения
|
||
description: occ.description,
|
||
start: new Date(occ.start),
|
||
end: new Date(occ.end),
|
||
allDay: false, // Явно указываем что событие с временем
|
||
calendar: occ.calendar, // Используем calendar из occurrence (важно для "Все события")
|
||
color_event: resolveEventColor(occ.color_event),
|
||
event_id: occ.event_id,
|
||
rule: occ.rule,
|
||
can_edit: occ.can_edit,
|
||
can_delete: occ.can_delete,
|
||
isOccurrence: true, // Помечаем как occurrence для корректного отображения
|
||
}));
|
||
|
||
// Объединяем обычные события и occurrences
|
||
let nextEvents = [...regularEvents, ...occurrenceEvents];
|
||
|
||
if (requestedCalendarId !== null) {
|
||
nextEvents = nextEvents.filter((event) => event.calendar === requestedCalendarId);
|
||
}
|
||
|
||
// Если параллельно ушёл более новый запрос, его ответ приоритетнее.
|
||
if (requestId !== eventsRequestIdRef.current) {
|
||
return;
|
||
}
|
||
|
||
setEvents(nextEvents);
|
||
} catch (err) {
|
||
if (requestId !== eventsRequestIdRef.current) {
|
||
return;
|
||
}
|
||
console.error("Ошибка загрузки событий:", err);
|
||
} finally {
|
||
if (requestId === eventsRequestIdRef.current) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, [calendarScope, canUseAllCalendarsMode, currentDate, selectedCalendarId]);
|
||
|
||
useEffect(() => {
|
||
loadEvents();
|
||
}, [loadEvents]);
|
||
|
||
useEffect(() => {
|
||
if (!linkedEventId || viewingEvent?.id === linkedEventId) return;
|
||
|
||
let cancelled = false;
|
||
|
||
apiClient.getEvent(linkedEventId)
|
||
.then((event) => {
|
||
if (cancelled) return;
|
||
|
||
setViewingEvent({
|
||
...event,
|
||
start: new Date(event.start),
|
||
end: new Date(event.end),
|
||
});
|
||
setCurrentDate(new Date(event.start));
|
||
setShowEventDetailsModal(true);
|
||
})
|
||
.catch((error) => {
|
||
console.error("Ошибка deep-link события календаря:", error);
|
||
});
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [linkedEventId, viewingEvent?.id]);
|
||
|
||
// Обработчики календаря
|
||
const handleSelectSlot = useCallback(
|
||
({ start }: { start: Date; end: Date }) => {
|
||
// Открываем модальное окно просмотра событий в этот день
|
||
setSelectedDate(start);
|
||
setShowDayEventsModal(true);
|
||
},
|
||
[]
|
||
);
|
||
|
||
// Создание события из модала просмотра дня
|
||
const handleCreateEventFromDay = useCallback(() => {
|
||
if (!selectedCalendarId) {
|
||
alert("Сначала выберите календарь");
|
||
return;
|
||
}
|
||
if (selectedCalendar?.can_create_events === false) {
|
||
alert("В выбранный календарь нельзя добавлять события");
|
||
return;
|
||
}
|
||
|
||
if (!selectedDate) return;
|
||
|
||
// Устанавливаем время по умолчанию 10:00 - 11:00
|
||
const startDate = new Date(selectedDate);
|
||
startDate.setHours(10, 0, 0, 0);
|
||
|
||
const endDate = new Date(selectedDate);
|
||
endDate.setHours(11, 0, 0, 0);
|
||
|
||
setEditingEvent({
|
||
title: "",
|
||
description: "",
|
||
start: startDate,
|
||
end: endDate,
|
||
calendar: selectedCalendarId,
|
||
color_event: DEFAULT_EVENT_COLOR,
|
||
});
|
||
|
||
// Закрываем модал просмотра дня и открываем модал создания
|
||
setShowDayEventsModal(false);
|
||
setShowEventModal(true);
|
||
}, [selectedCalendar?.can_create_events, selectedCalendarId, selectedDate]);
|
||
|
||
const handleSelectEvent = useCallback(async (calEvent: CalendarEvent) => {
|
||
// Если это occurrence (повторяющееся событие), загружаем базовое событие
|
||
if (calEvent.isOccurrence && calEvent.event_id) {
|
||
try {
|
||
const fullEvent = await apiClient.getEvent(calEvent.event_id);
|
||
// Устанавливаем время из occurrence, но остальные данные из базового события
|
||
setViewingEvent({
|
||
...fullEvent,
|
||
start: calEvent.start,
|
||
end: calEvent.end,
|
||
});
|
||
setShowEventDetailsModal(true);
|
||
} catch (err) {
|
||
console.error("Ошибка загрузки базового события:", err);
|
||
}
|
||
} else {
|
||
// Обычное событие - открываем детали
|
||
setViewingEvent(calEvent);
|
||
setShowEventDetailsModal(true);
|
||
}
|
||
}, []);
|
||
|
||
// Переход к редактированию из модала просмотра
|
||
const handleEditFromDetails = useCallback(() => {
|
||
setEditingEvent(viewingEvent);
|
||
setShowEventDetailsModal(false);
|
||
setShowEventModal(true);
|
||
}, [viewingEvent]);
|
||
|
||
const handleCreateCalendar = useCallback(() => {
|
||
setEditingCalendar({ name: "" });
|
||
setShowCalendarModal(true);
|
||
setShowCalendarActions(false);
|
||
}, []);
|
||
|
||
const handleEditCalendar = useCallback(() => {
|
||
if (!selectedCalendar?.can_edit_calendar || isDepartmentCalendar) {
|
||
return;
|
||
}
|
||
setEditingCalendar({ id: selectedCalendar.id, name: selectedCalendar.name });
|
||
setShowCalendarModal(true);
|
||
setShowCalendarActions(false);
|
||
}, [isDepartmentCalendar, selectedCalendar]);
|
||
|
||
const handleDeleteCalendar = useCallback(async () => {
|
||
if (!selectedCalendar?.id || !selectedCalendar.can_edit_calendar || isDepartmentCalendar) {
|
||
return;
|
||
}
|
||
if (!confirm(`Удалить календарь "${selectedCalendar.name}"?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await apiClient.deleteCalendar(selectedCalendar.id);
|
||
calendarService.clearCache();
|
||
setSelectedCalendarId(null);
|
||
setShowCalendarActions(false);
|
||
await reloadCalendars();
|
||
loadEvents();
|
||
} catch (error) {
|
||
console.error("Не удалось удалить календарь:", error);
|
||
alert("Не удалось удалить календарь");
|
||
}
|
||
}, [isDepartmentCalendar, loadEvents, reloadCalendars, selectedCalendar, setSelectedCalendarId]);
|
||
|
||
const handleExportCalendar = useCallback(async () => {
|
||
if (!selectedCalendar?.id) return;
|
||
|
||
try {
|
||
const blob = await calendarService.exportToICS(selectedCalendar.id);
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `calendar-${selectedCalendar.id}.ics`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
window.URL.revokeObjectURL(url);
|
||
document.body.removeChild(a);
|
||
} catch (error) {
|
||
console.error("Не удалось экспортировать календарь:", error);
|
||
alert("Не удалось экспортировать календарь");
|
||
} finally {
|
||
setShowCalendarActions(false);
|
||
}
|
||
}, [selectedCalendar]);
|
||
|
||
const handleImportCalendar = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (!selectedCalendar?.id || !event.target.files || event.target.files.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const file = event.target.files[0];
|
||
try {
|
||
const result = await calendarService.importFromICS(selectedCalendar.id, file);
|
||
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
|
||
loadEvents();
|
||
} catch (error: any) {
|
||
console.error("Не удалось импортировать календарь:", error);
|
||
alert(`Ошибка импорта: ${error.message}`);
|
||
} finally {
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = "";
|
||
}
|
||
setShowCalendarActions(false);
|
||
}
|
||
}, [loadEvents, selectedCalendar]);
|
||
|
||
// Клик на событие из модала просмотра дня
|
||
const handleEventClickFromDay = useCallback(async (event: any) => {
|
||
setShowDayEventsModal(false);
|
||
|
||
// Если это occurrence, загружаем базовое событие
|
||
if (event.isOccurrence && event.event_id) {
|
||
try {
|
||
const fullEvent = await apiClient.getEvent(event.event_id);
|
||
setViewingEvent({
|
||
...fullEvent,
|
||
start: event.start,
|
||
end: event.end,
|
||
});
|
||
setShowEventDetailsModal(true);
|
||
} catch (err) {
|
||
console.error("Ошибка загрузки события:", err);
|
||
}
|
||
} else {
|
||
setViewingEvent(event);
|
||
setShowEventDetailsModal(true);
|
||
}
|
||
}, []);
|
||
|
||
|
||
// Стилизация событий по цвету
|
||
const eventStyleGetter = (event: CalendarEvent) => {
|
||
const backgroundColor = resolveEventColor(event.color_event);
|
||
return {
|
||
style: {
|
||
backgroundColor,
|
||
borderRadius: "6px",
|
||
opacity: 0.9,
|
||
color: "white",
|
||
border: "none",
|
||
display: "block",
|
||
},
|
||
};
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="app-surface rounded-2xl">
|
||
{/* Панель выбора календаря */}
|
||
<div className="app-divider relative z-20 border-b px-6 py-5">
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||
<div className="space-y-1">
|
||
<p className="app-card-caption">Календарь</p>
|
||
<p className="app-text-muted text-sm">
|
||
{selectedCalendar
|
||
? `События календаря «${selectedCalendar.name}»`
|
||
: "Просмотр всех доступных событий"}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 self-end">
|
||
{loading && (
|
||
<div className="app-text-muted flex items-center gap-2 text-sm">
|
||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--border-subtle)] border-t-[var(--accent-primary)]"></div>
|
||
<span>Загрузка...</span>
|
||
</div>
|
||
)}
|
||
|
||
{calendars.length > 0 && (
|
||
<div className="relative z-30">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCalendarActions((prev) => !prev)}
|
||
className="app-action-ghost flex h-8 w-8 items-center justify-center rounded-md"
|
||
title="Действия с календарем"
|
||
aria-label="Действия с календарем"
|
||
aria-expanded={showCalendarActions}
|
||
aria-haspopup="menu"
|
||
>
|
||
<ChevronRight
|
||
size={15}
|
||
className={`transition-transform duration-200 ${showCalendarActions ? "rotate-90" : ""}`}
|
||
/>
|
||
</button>
|
||
|
||
{showCalendarActions && (
|
||
<>
|
||
<div
|
||
className="fixed inset-0 z-[50]"
|
||
onClick={() => setShowCalendarActions(false)}
|
||
/>
|
||
<div className="app-menu pointer-events-auto absolute right-0 top-full z-[80] mt-1 w-52 rounded-xl">
|
||
<div className="py-1">
|
||
<button
|
||
type="button"
|
||
onClick={handleCreateCalendar}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||
>
|
||
<Plus size={16} />
|
||
Создать календарь
|
||
</button>
|
||
|
||
{selectedCalendar ? (
|
||
<>
|
||
<div className="app-divider my-1 border-t"></div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (canOpenParticipantsModal) {
|
||
setShowParticipantsModal(true);
|
||
}
|
||
setShowCalendarActions(false);
|
||
}}
|
||
disabled={!canOpenParticipantsModal}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||
>
|
||
<Users size={16} />
|
||
Участники
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleExportCalendar}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||
>
|
||
<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
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={!selectedCalendar.can_edit_calendar}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||
>
|
||
<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={handleImportCalendar}
|
||
className="hidden"
|
||
/>
|
||
|
||
{!isDepartmentCalendar ? (
|
||
<>
|
||
<div className="app-divider my-1 border-t"></div>
|
||
<button
|
||
type="button"
|
||
onClick={handleEditCalendar}
|
||
disabled={!selectedCalendar.can_edit_calendar}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||
>
|
||
<Pencil size={16} />
|
||
Редактировать
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleDeleteCalendar()}
|
||
disabled={!selectedCalendar.can_edit_calendar}
|
||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
|
||
>
|
||
<Trash2 size={16} />
|
||
Удалить
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{calendars.length === 0 ? (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<p className="app-text-muted text-sm">Календарей нет.</p>
|
||
<button
|
||
onClick={() => {
|
||
setEditingCalendar({ name: "" });
|
||
setShowCalendarModal(true);
|
||
}}
|
||
className="app-link-accent text-sm font-medium"
|
||
>
|
||
Создать календарь
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||
<div className="flex min-w-0 flex-1 flex-col gap-3 lg:flex-row lg:items-center">
|
||
<label className="app-text-muted shrink-0 text-sm font-medium">Календарь</label>
|
||
<div className="relative min-w-0 flex-1">
|
||
<select
|
||
value={selectedCalendarId === null ? "" : selectedCalendarId}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
setSelectedCalendarId(value === "" ? null : Number(value));
|
||
}}
|
||
className="app-select w-full min-w-0 appearance-none rounded-xl py-2.5 pl-3 pr-10 text-sm"
|
||
>
|
||
<option value="">📅 Все события</option>
|
||
{calendarOptions.map((cal) => (
|
||
<option key={cal.id} value={cal.id}>
|
||
{cal.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<ChevronDown size={16} className="app-text-muted pointer-events-none absolute right-3 top-1/2 -translate-y-1/2" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{canUseAllCalendarsMode && (
|
||
<div className="grid min-w-[12rem] grid-cols-2 rounded-xl border border-[var(--border-strong)] bg-[var(--surface-secondary)] p-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setCalendarScope("accessible")}
|
||
className={`min-w-0 rounded-lg px-3 py-1.5 text-center text-xs font-medium transition ${
|
||
calendarScope === "accessible"
|
||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||
: "app-text-muted hover:text-[var(--foreground)]"
|
||
}`}
|
||
>
|
||
Доступные
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setCalendarScope("all")}
|
||
className={`min-w-0 rounded-lg px-3 py-1.5 text-center text-xs font-medium transition ${
|
||
calendarScope === "all"
|
||
? "bg-[var(--surface-primary)] text-[var(--foreground)] shadow-sm"
|
||
: "app-text-muted hover:text-[var(--foreground)]"
|
||
}`}
|
||
>
|
||
Все
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Календарь */}
|
||
<div className="relative z-0 overflow-hidden px-6 pb-6 pt-5" style={{ height: "750px" }}>
|
||
<div className="calendar-shell h-full rounded-[1.4rem]">
|
||
<Calendar
|
||
localizer={localizer}
|
||
events={events}
|
||
startAccessor="start"
|
||
endAccessor="end"
|
||
allDayAccessor={(event: CalendarEvent) => event.allDay || false}
|
||
messages={messages}
|
||
culture="ru"
|
||
view={currentView}
|
||
onView={(view: View) => setCurrentView(view)}
|
||
date={currentDate}
|
||
onNavigate={(date: Date) => setCurrentDate(date)}
|
||
onSelectSlot={handleSelectSlot}
|
||
onSelectEvent={handleSelectEvent}
|
||
selectable
|
||
eventPropGetter={eventStyleGetter}
|
||
popup
|
||
components={{
|
||
toolbar: CustomToolbar,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{/* Модальное окно события */}
|
||
<EventModal
|
||
isOpen={showEventModal}
|
||
onClose={() => {
|
||
setShowEventModal(false);
|
||
setEditingEvent(null);
|
||
}}
|
||
event={editingEvent}
|
||
onSave={() => {
|
||
loadEvents(); // ✅ Обновляем список событий
|
||
}}
|
||
showParticipants={true}
|
||
/>
|
||
|
||
{/* Модальное окно просмотра событий дня */}
|
||
<ViewDayEventsModal
|
||
isOpen={showDayEventsModal}
|
||
onClose={() => {
|
||
setShowDayEventsModal(false);
|
||
setSelectedDate(null);
|
||
}}
|
||
date={selectedDate}
|
||
events={events}
|
||
onEventClick={handleEventClickFromDay}
|
||
onCreateEvent={handleCreateEventFromDay}
|
||
/>
|
||
|
||
{/* Модальное окно просмотра деталей события */}
|
||
<ViewEventDetailsModal
|
||
isOpen={showEventDetailsModal}
|
||
onClose={() => {
|
||
setShowEventDetailsModal(false);
|
||
setViewingEvent(null);
|
||
clearEventParam();
|
||
}}
|
||
event={viewingEvent}
|
||
onEdit={handleEditFromDetails}
|
||
onDelete={async () => {
|
||
if (!viewingEvent?.id) return;
|
||
if (!confirm("Удалить это событие?")) return;
|
||
|
||
try {
|
||
await apiClient.deleteEvent(viewingEvent.id);
|
||
setShowEventDetailsModal(false);
|
||
setViewingEvent(null);
|
||
loadEvents();
|
||
} catch (err) {
|
||
console.error("Ошибка удаления события:", err);
|
||
alert("Не удалось удалить событие");
|
||
}
|
||
}}
|
||
showParticipants={true}
|
||
/>
|
||
|
||
{/* Модальное окно управления календарем */}
|
||
<CalendarModal
|
||
isOpen={showCalendarModal}
|
||
onClose={() => {
|
||
setShowCalendarModal(false);
|
||
setEditingCalendar(null);
|
||
}}
|
||
calendar={editingCalendar}
|
||
/>
|
||
|
||
{selectedCalendar && (
|
||
<CalendarParticipantsModal
|
||
isOpen={showParticipantsModal}
|
||
onClose={() => setShowParticipantsModal(false)}
|
||
calendarId={selectedCalendar.id}
|
||
calendarName={selectedCalendar.name}
|
||
userRole={(selectedCalendar as any).user_role}
|
||
canManageParticipants={selectedCalendar.can_manage_participants}
|
||
calendarType={selectedCalendar.type}
|
||
contextType={selectedCalendar.context_type}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|