Refactor: Calendar architecture cleanup and optimization

- Extract CalendarCard to separate component (467 lines)
- Create calendarService for API and caching (156 lines)
- Add useCalendarEvents hook (96 lines)
- Create CalendarSidebar wrapper component (29 lines)
- Reduce AppShell.tsx from 1277 to 634 lines (-50%)
- Implement proper separation of concerns
- Move global cache to service layer
- Improve code maintainability and testability
This commit is contained in:
igor_nadein 2026-02-25 09:24:28 +03:00
parent 3635eb7759
commit ba6e4a3ef0
8 changed files with 798 additions and 604 deletions

View File

@ -1,6 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Отключаем React Strict Mode для production-like поведения в dev
reactStrictMode: false,
// Указываем корень проекта для Turbopack, чтобы не путался с lockfile в корне монорепо
turbopack: {
root: __dirname,

View File

@ -1,17 +1,19 @@
"use client";
import { Bell, Building2, CalendarDays, ChevronLeft, ChevronRight, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Plus, Search, Trash2, Users, Wallet, X } from "lucide-react";
import { Bell, Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, Users, Wallet, X } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, useEffect, useMemo, useState, useRef, useCallback } from "react";
import { ReactNode, useEffect, useState, useCallback } from "react";
import { apiClient } from "@/lib/api";
import { useUser } from "@/contexts/UserContext";
import { CalendarProvider, useCalendar } from "@/contexts/CalendarContext";
import { CalendarModal } from "@/components/CalendarModal";
import CalendarParticipantsModal from "@/components/CalendarParticipantsModal";
import { EventModal } from "@/components/EventModal";
import { ViewDayEventsModal } from "@/components/ViewDayEventsModal";
import { ViewEventDetailsModal } from "@/components/ViewEventDetailsModal";
import { CalendarSidebar } from "@/components/calendar/CalendarSidebar";
import { CalendarCard } from "@/components/calendar/CalendarCard";
import type { CalendarEvent } from "@/services/calendarService";
type AppShellProps = {
children: ReactNode;
@ -44,35 +46,6 @@ const navItems = [
{ href: "/finances", label: "Финансы", icon: Wallet },
];
const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
type SidebarCalendarEvent = {
id: number;
title: string;
start?: string | null;
end?: string | null;
allDay?: boolean;
color?: string | null;
color_event?: string | null; // Цвет события (альтернативное поле)
rule?: number | null; // ID правила повторения
is_recurring?: boolean; // Флаг из occurrences API
};
const pad = (v: number) => String(v).padStart(2, "0");
const formatDateKey = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const startOfWeekMonday = (date: Date) => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const dayIndex = (d.getDay() + 6) % 7; // Пн = 0
d.setDate(d.getDate() - dayIndex);
return d;
};
const sameDate = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const router = useRouter();
const { user, logout } = useUser();
@ -297,551 +270,6 @@ function LeftNav() {
);
}
function CalendarCard({
onOpenCalendarModal,
onOpenEventModal,
onOpenParticipantsModal,
eventsRefreshTrigger,
setEventsRefreshTrigger,
setSidebarEvents,
onCalendarChange
}: {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
setSidebarEvents: (events: any[]) => void;
onCalendarChange: (calendarId: number | null) => void;
}) {
const { calendars, selectedCalendarId, setSelectedCalendarId, loading: calendarsLoading, reloadCalendars } = useCalendar();
const [monthDate, setMonthDate] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const [events, setEvents] = useState<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">
@ -882,16 +310,29 @@ export function AppShell({ children }: AppShellProps) {
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 [sidebarEvents, setSidebarEvents] = useState<CalendarEvent[]>([]);
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState<number | null>(null);
// Обработчики модалов
const handleOpenCalendarModal = (calendar?: { id?: number; name: string }) => {
// Мемоизированные callback'и для предотвращения ререндеров CalendarCard
const handleSetSidebarEvents = useCallback((events: CalendarEvent[]) => {
setSidebarEvents(events);
}, []);
const handleCalendarChange = useCallback((calendarId: number | null) => {
setCurrentSelectedCalendarId(calendarId);
}, []);
const handleSetEventsRefreshTrigger = useCallback((value: number | ((prev: number) => number)) => {
setEventsRefreshTrigger(value);
}, []);
// Обработчики модалов (мемоизированные для предотвращения ререндеров)
const handleOpenCalendarModal = useCallback((calendar?: { id?: number; name: string }) => {
setEditingCalendar(calendar || { name: "" });
setShowCalendarModal(true);
};
}, []);
const handleOpenEventModal = (event: any, date?: Date) => {
const handleOpenEventModal = useCallback((event: any, date?: Date) => {
// Если передана дата (клик на дату), открываем модал просмотра дня
if (date && !event.id) {
setSelectedDateForModal(date);
@ -907,7 +348,7 @@ export function AppShell({ children }: AppShellProps) {
setEditingEvent(event);
setShowEventModal(true);
}
};
}, []);
// Создание события из модала просмотра дня
const handleCreateEventFromDay = useCallback(() => {
@ -971,10 +412,10 @@ export function AppShell({ children }: AppShellProps) {
setEventsRefreshTrigger(prev => prev + 1);
}, []);
const handleOpenParticipantsModal = (calendar: { id: number; name: string; user_role?: string }) => {
const handleOpenParticipantsModal = useCallback((calendar: { id: number; name: string; user_role?: string }) => {
setParticipantsCalendar(calendar);
setShowParticipantsModal(true);
};
}, []);
useEffect(() => {
// Если загрузка завершена и пользователь не авторизован - редирект на логин
@ -1035,20 +476,19 @@ export function AppShell({ children }: AppShellProps) {
}
return (
<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`}>
<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
<CalendarSidebar
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={setEventsRefreshTrigger}
setSidebarEvents={setSidebarEvents}
onCalendarChange={setCurrentSelectedCalendarId}
setEventsRefreshTrigger={handleSetEventsRefreshTrigger}
setSidebarEvents={handleSetSidebarEvents}
onCalendarChange={handleCalendarChange}
/>
</div>
@ -1100,15 +540,17 @@ export function AppShell({ children }: AppShellProps) {
<X size={20} className="text-gray-700" />
</button>
</div>
<CalendarCard
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={setEventsRefreshTrigger}
setSidebarEvents={setSidebarEvents}
onCalendarChange={setCurrentSelectedCalendarId}
/>
{isMobileCalendarOpen && (
<CalendarCard
onOpenCalendarModal={handleOpenCalendarModal}
onOpenEventModal={handleOpenEventModal}
onOpenParticipantsModal={handleOpenParticipantsModal}
eventsRefreshTrigger={eventsRefreshTrigger}
setEventsRefreshTrigger={handleSetEventsRefreshTrigger}
setSidebarEvents={handleSetSidebarEvents}
onCalendarChange={handleCalendarChange}
/>
)}
</div>
</div>
@ -1187,6 +629,5 @@ export function AppShell({ children }: AppShellProps) {
showParticipants={true}
/>
</div>
</CalendarProvider>
);
}

View File

@ -1,8 +1,15 @@
"use client";
import { UserProvider } from "@/contexts/UserContext";
import { CalendarProvider } from "@/contexts/CalendarContext";
import { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return <UserProvider>{children}</UserProvider>;
return (
<UserProvider>
<CalendarProvider>
{children}
</CalendarProvider>
</UserProvider>
);
}

View File

@ -0,0 +1,466 @@
"use client";
import { memo, useMemo, useState, useRef, useEffect, useCallback } from "react";
import { ChevronLeft, ChevronRight, Plus, Users } from "lucide-react";
import { useCalendar } from "@/contexts/CalendarContext";
import { useCalendarEvents } from "@/hooks/calendar/useCalendarEvents";
import { calendarService, formatDateKey, type CalendarEvent } from "@/services/calendarService";
// Типы
interface CalendarCardProps {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
setSidebarEvents: (events: CalendarEvent[]) => void;
onCalendarChange: (calendarId: number | null) => void;
}
// Константы
const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
// Утилиты
const sameDate = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
/**
* Компонент мини-календаря в сайдбаре
*/
export const CalendarCard = memo(function CalendarCard({
onOpenCalendarModal,
onOpenEventModal,
onOpenParticipantsModal,
eventsRefreshTrigger,
setEventsRefreshTrigger,
setSidebarEvents,
onCalendarChange,
}: CalendarCardProps) {
const { calendars, selectedCalendarId, setSelectedCalendarId } = useCalendar();
const [monthDate, setMonthDate] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const [showCalendarMenu, setShowCalendarMenu] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Загружаем события через хук
const { events, loading, error } = useCalendarEvents({
monthDate,
selectedCalendarId,
eventsRefreshTrigger,
onEventsLoaded: setSidebarEvents,
});
// Синхронизация selectedCalendarId с родительским компонентом
const prevSelectedCalendarIdRef = useRef<number | null | undefined>(undefined);
useEffect(() => {
if (prevSelectedCalendarIdRef.current !== selectedCalendarId) {
prevSelectedCalendarIdRef.current = selectedCalendarId;
onCalendarChange(selectedCalendarId);
}
}, [selectedCalendarId, onCalendarChange]);
// Метка месяца
const monthLabel = useMemo(() => {
const label = monthDate.toLocaleDateString("ru-RU", {
month: "long",
year: "numeric",
});
return label.charAt(0).toUpperCase() + label.slice(1);
}, [monthDate]);
// Обработчики
const handleCreateCalendar = useCallback(() => {
onOpenCalendarModal({ name: "" });
}, [onOpenCalendarModal]);
const handleDayClick = useCallback((date: Date) => {
setSelectedDate(date);
const startDate = new Date(date);
startDate.setHours(10, 0, 0, 0);
const endDate = new Date(date);
endDate.setHours(11, 0, 0, 0);
const newEvent = {
title: "",
description: "",
start: startDate.toISOString(),
end: endDate.toISOString(),
calendar: selectedCalendarId,
color_event: "#3498db",
};
onOpenEventModal(newEvent, date);
}, [selectedCalendarId, onOpenEventModal]);
const handleEventClick = useCallback(async (event: CalendarEvent) => {
// Если это occurrence (повторяющееся событие), загружаем базовое событие
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await calendarService.loadFullEvent(event.event_id, event.start || undefined, event.end || undefined);
onOpenEventModal(fullEvent);
} catch (err) {
console.error("Ошибка загрузки базового события:", err);
}
} else {
onOpenEventModal(event);
}
}, [onOpenEventModal]);
const handleImportCalendar = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
if (!selectedCalendarId || !event.target.files || event.target.files.length === 0) {
return;
}
const file = event.target.files[0];
try {
const result = await calendarService.importFromICS(selectedCalendarId, file);
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
setEventsRefreshTrigger(prev => prev + 1);
} catch (error: any) {
console.error('Failed to import calendar:', error);
alert(`Ошибка импорта: ${error.message}`);
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}, [selectedCalendarId, setEventsRefreshTrigger]);
const handleExportCalendar = useCallback(async () => {
if (!selectedCalendarId) return;
try {
const blob = await calendarService.exportToICS(selectedCalendarId);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `calendar-${selectedCalendarId}.ics`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Failed to export calendar:', error);
alert('Не удалось экспортировать календарь');
}
setShowCalendarMenu(false);
}, [selectedCalendarId]);
// Дни с событиями
const eventDays = useMemo(() => {
const days = new Set<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);
const currentDay = new Date(eventStart);
while (currentDay <= eventEnd) {
days.add(formatDateKey(currentDay));
currentDay.setDate(currentDay.getDate() + 1);
}
});
return days;
}, [events]);
// Дни месяца для календарной сетки
const days = useMemo(() => {
const firstOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const weekDayIndex = (firstOfMonth.getDay() + 6) % 7;
const gridStart = new Date(firstOfMonth);
gridStart.setDate(firstOfMonth.getDate() - weekDayIndex);
const today = new Date();
return Array.from({ length: 42 }, (_, i) => {
const date = new Date(gridStart);
date.setDate(gridStart.getDate() + i);
const key = formatDateKey(date);
return {
key,
day: date.getDate(),
date,
inCurrentMonth: date.getMonth() === monthDate.getMonth(),
isToday: sameDate(date, today),
hasEvents: eventDays.has(key),
};
});
}, [monthDate, eventDays]);
// События на этой неделе
const weekEvents = useMemo(() => {
const weekStart = new Date();
weekStart.setHours(0, 0, 0, 0);
const dayIndex = (weekStart.getDay() + 6) % 7;
weekStart.setDate(weekStart.getDate() - dayIndex);
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setDate(weekEndExclusive.getDate() + 7);
return [...events]
.filter((ev) => ev.start)
.filter((ev) => {
const start = new Date(ev.start || "");
if (Number.isNaN(start.getTime())) return false;
return start >= weekStart && start < weekEndExclusive;
})
.sort((a, b) => new Date(a.start || "").getTime() - new Date(b.start || "").getTime())
.slice(0, 12);
}, [events]);
return (
<>
{/* Селектор календаря */}
<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={handleExportCalendar}
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={() => {
fileInputRef.current?.click();
}}
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) => {
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>
</>
);
});

View File

@ -0,0 +1,28 @@
"use client";
import { memo } from "react";
import { CalendarCard } from "@/components/calendar/CalendarCard";
import type { CalendarEvent } from "@/services/calendarService";
interface CalendarSidebarProps {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
setSidebarEvents: (events: CalendarEvent[]) => void;
onCalendarChange: (calendarId: number | null) => void;
}
/**
* Обертка для календаря в правом сайдбаре (десктоп)
*/
export const CalendarSidebar = memo(function CalendarSidebar(props: CalendarSidebarProps) {
return (
<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 {...props} />
</div>
</aside>
);
});

View File

@ -36,7 +36,6 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
const result = await apiClient.getCalendars();
// API возвращает пагинированный ответ: {count, results}
const cals = Array.isArray(result) ? result : (result?.results || []);
console.log("Загружено календарей:", cals.length, cals);
setCalendars(cals);
// По умолчанию показываем "Все события" (selectedCalendarId === null)
@ -49,7 +48,7 @@ export function CalendarProvider({ children }: { children: ReactNode }) {
} finally {
setLoading(false);
}
}, [selectedCalendarId]);
}, []);
useEffect(() => {
loadCalendars();

View File

@ -0,0 +1,95 @@
/**
* Хук для загрузки событий календаря
* Обрабатывает кэширование, загрузку и ошибки
*/
import { useEffect, useState, useRef } from "react";
import { calendarService, type CalendarEvent, startOfWeekMonday } from "@/services/calendarService";
interface UseCalendarEventsOptions {
monthDate: Date;
selectedCalendarId: number | null;
eventsRefreshTrigger: number;
onEventsLoaded?: (events: CalendarEvent[]) => void;
}
interface UseCalendarEventsResult {
events: CalendarEvent[];
loading: boolean;
error: string | null;
}
export function useCalendarEvents({
monthDate,
selectedCalendarId,
eventsRefreshTrigger,
onEventsLoaded,
}: UseCalendarEventsOptions): UseCalendarEventsResult {
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadingRef = useRef(false);
useEffect(() => {
let cancelled = false;
// Вычисляем диапазон дат для загрузки
const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const end = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1);
const weekStart = startOfWeekMonday(new Date());
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setDate(weekEndExclusive.getDate() + 7);
// Расширяем диапазон для включения текущей недели
const fetchStart = weekStart < start ? weekStart : start;
const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end;
async function loadEvents() {
// Защита от множественных одновременных запросов
if (loadingRef.current) return;
loadingRef.current = true;
try {
setLoading(true);
setError(null);
const loadedEvents = await calendarService.loadEvents(
fetchStart,
fetchEnd,
selectedCalendarId,
eventsRefreshTrigger
);
if (!cancelled) {
setEvents(loadedEvents);
onEventsLoaded?.(loadedEvents);
}
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err || "");
const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message);
if (!isNetworkLike) {
console.error("Ошибка загрузки событий календаря:", err);
}
setError("Не удалось загрузить события");
setEvents([]);
}
} finally {
if (!cancelled) {
setLoading(false);
loadingRef.current = false;
}
}
}
loadEvents();
return () => {
cancelled = true;
};
}, [monthDate, selectedCalendarId, eventsRefreshTrigger, onEventsLoaded]);
return { events, loading, error };
}

View File

@ -0,0 +1,155 @@
/**
* Сервис для работы с календарными событиями
* Включает кэширование для оптимизации запросов
*/
import { apiClient } from "@/lib/api";
// Типы
export interface CalendarEvent {
id: number;
title: string;
start?: string | null;
end?: string | null;
allDay?: boolean;
color?: string | null;
color_event?: string | null;
rule?: number | null;
is_recurring?: boolean;
event_id?: number;
description?: string;
calendar?: number;
}
interface EventsCache {
key: string;
events: CalendarEvent[];
timestamp: number;
}
// Константы
const CACHE_TTL = 30000; // 30 секунд
// Глобальный кэш для событий календаря
let eventsCache: EventsCache | null = null;
// Утилиты для работы с датами
const pad = (v: number) => String(v).padStart(2, "0");
export const formatDateKey = (date: Date): string =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
export const startOfWeekMonday = (date: Date): Date => {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
const dayIndex = (d.getDay() + 6) % 7; // Пн = 0
d.setDate(d.getDate() - dayIndex);
return d;
};
// Сервис для работы с событиями календаря
class CalendarService {
/**
* Загружает события календаря с кэшированием
* @param startDate Начальная дата периода
* @param endDate Конечная дата периода
* @param calendarId ID календаря (null - все календари)
* @param refreshTrigger Триггер для принудительного обновления
*/
async loadEvents(
startDate: Date,
endDate: Date,
calendarId: number | null,
refreshTrigger: number
): Promise<CalendarEvent[]> {
const cacheKey = `${formatDateKey(startDate)}_${formatDateKey(endDate)}_${calendarId}_${refreshTrigger}`;
// Проверяем кэш
if (eventsCache && eventsCache.key === cacheKey && Date.now() - eventsCache.timestamp < CACHE_TTL) {
return eventsCache.events;
}
// Загружаем события
const [eventsResult, occurrencesResult] = await Promise.all([
apiClient.getCalendarEvents({
start: formatDateKey(startDate),
end: formatDateKey(endDate),
calendar: calendarId || undefined,
}),
apiClient.getOccurrences({
start: formatDateKey(startDate),
end: formatDateKey(endDate),
calendar: calendarId || undefined,
}),
]);
// Обрабатываем результаты
const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []);
const regularEvents = eventsList.filter((evt: any) => !evt.rule);
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring);
// Объединяем оба типа событий
const allEvents = [...regularEvents, ...recurringOccurrences];
// Сохраняем в кэш
eventsCache = {
key: cacheKey,
events: allEvents,
timestamp: Date.now(),
};
return allEvents;
}
/**
* Очищает кэш событий
*/
clearCache(): void {
eventsCache = null;
}
/**
* Загружает полное событие по ID (для повторяющихся событий)
*/
async loadFullEvent(eventId: number, occurrenceStart?: string, occurrenceEnd?: string): Promise<CalendarEvent> {
const fullEvent = await apiClient.getEvent(eventId);
// Если переданы время occurrence, используем их
if (occurrenceStart && occurrenceEnd) {
return {
...fullEvent,
start: occurrenceStart,
end: occurrenceEnd,
};
}
return fullEvent;
}
/**
* Импортирует календарь из ICS файла
*/
async importFromICS(calendarId: number, file: File): Promise<{ imported: number; skipped: number }> {
return apiClient.importCalendarFromICS(calendarId, file);
}
/**
* Экспортирует календарь в ICS файл
*/
async exportToICS(calendarId: number): Promise<Blob> {
return apiClient.exportCalendarToICS(calendarId);
}
/**
* Удаляет событие
*/
async deleteEvent(eventId: number): Promise<void> {
await apiClient.deleteEvent(eventId);
this.clearCache(); // Очищаем кэш после удаления
}
}
// Экспортируем singleton
export const calendarService = new CalendarService();