"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 (
);
}
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const pathname = usePathname();
const navLinkClass = (href: string) =>
`flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700"
}`;
const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400");
return (
{navItems.map(({ href, label, icon: Icon }) => (
{label}
))}
);
}
function LeftNav() {
return (
);
}
function CalendarCard({
onOpenCalendarModal,
onOpenEventModal,
onOpenParticipantsModal,
eventsRefreshTrigger,
setEventsRefreshTrigger,
setSidebarEvents,
onCalendarChange
}: {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
setSidebarEvents: (events: any[]) => void;
onCalendarChange: (calendarId: number | null) => void;
}) {
const { calendars, selectedCalendarId, setSelectedCalendarId, loading: calendarsLoading, reloadCalendars } = useCalendar();
const [monthDate, setMonthDate] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const [events, setEvents] = useState([]);
// Синхронизация selectedCalendarId с родительским компонентом
useEffect(() => {
onCalendarChange(selectedCalendarId);
}, [selectedCalendarId, onCalendarChange]);
const [loading, setLoading] = useState(false);
const [showCalendarMenu, setShowCalendarMenu] = useState(false);
const [error, setError] = useState(null);
const [selectedDate, setSelectedDate] = useState(null);
const fileInputRef = useRef(null);
// Загрузка событий выбранного календаря
useEffect(() => {
let cancelled = false;
const start = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const end = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1);
const weekStart = startOfWeekMonday(new Date());
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setDate(weekEndExclusive.getDate() + 7);
const fetchStart = weekStart < start ? weekStart : start;
const fetchEnd = weekEndExclusive > end ? weekEndExclusive : end;
async function loadCalendarEvents() {
try {
setLoading(true);
setError(null);
// Загружаем обычные события и occurrences параллельно
// Если selectedCalendarId === null, загружаем все доступные события со всех календарей
const [eventsResult, occurrencesResult] = await Promise.all([
apiClient.getCalendarEvents({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
calendar: selectedCalendarId || undefined, // undefined означает все календари
}),
apiClient.getOccurrences({
start: formatDateKey(fetchStart),
end: formatDateKey(fetchEnd),
calendar: selectedCalendarId || undefined,
}),
]);
if (!cancelled) {
// Обычные события (без правил повторения)
const eventsList = Array.isArray(eventsResult) ? eventsResult : (eventsResult?.results || []);
const regularEvents = eventsList.filter((evt: any) => !evt.rule);
// Occurrences (развернутые повторяющиеся события)
const occurrencesList = Array.isArray(occurrencesResult) ? occurrencesResult : (occurrencesResult?.results || []);
// Фильтруем только повторяющиеся события (обычные события уже в regularEvents)
const recurringOccurrences = occurrencesList.filter((occ: any) => occ.is_recurring);
// Объединяем оба типа событий
const allEvents = [...regularEvents, ...recurringOccurrences];
setEvents(allEvents);
setSidebarEvents(allEvents); // Передаем события наружу для ViewDayEventsModal
}
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err || "");
const isNetworkLike = message === "NetworkError" || /fetch failed/i.test(message);
if (!isNetworkLike) {
console.error("Ошибка загрузки событий календаря:", err);
}
setError("Не удалось загрузить события");
setEvents([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadCalendarEvents();
return () => {
cancelled = true;
};
}, [monthDate, selectedCalendarId, eventsRefreshTrigger]);
const monthLabel = useMemo(() => {
const label = monthDate.toLocaleDateString("ru-RU", {
month: "long",
year: "numeric",
});
return label.charAt(0).toUpperCase() + label.slice(1);
}, [monthDate]);
// Обработчики календарей
const handleCreateCalendar = () => {
onOpenCalendarModal({ name: "" });
};
// Обработчики событий
const handleDayClick = (date: Date) => {
// Больше не блокируем при selectedCalendarId === null (разрешаем просмотр "Все события")
setSelectedDate(date);
const startDate = new Date(date);
startDate.setHours(10, 0, 0, 0);
const endDate = new Date(date);
endDate.setHours(11, 0, 0, 0);
const newEvent = {
title: "",
description: "",
start: startDate.toISOString(),
end: endDate.toISOString(),
calendar: selectedCalendarId,
color_event: "#3498db",
};
onOpenEventModal(newEvent, date);
};
const handleEventClick = async (event: any) => {
// Если это occurrence (повторяющееся событие), загружаем базовое событие
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await apiClient.getEvent(event.event_id);
// Устанавливаем время из occurrence, но остальные данные из базового события
onOpenEventModal({
...fullEvent,
start: event.start,
end: event.end,
});
} catch (err) {
console.error("Ошибка загрузки базового события:", err);
}
} else {
// Обычное событие - данные уже полные
onOpenEventModal(event);
}
};
const handleImportCalendar = async (event: React.ChangeEvent) => {
console.log('handleImportCalendar called', event.target.files);
if (!selectedCalendarId || !event.target.files || event.target.files.length === 0) {
console.log('Early return:', { selectedCalendarId, hasFiles: !!event.target.files });
return;
}
const file = event.target.files[0];
console.log('Importing file:', file.name, file.size);
try {
const result = await apiClient.importCalendarFromICS(selectedCalendarId, file);
console.log('Import result:', result);
alert(`Импорт завершен!\nИмпортировано: ${result.imported}\nПропущено: ${result.skipped}`);
// Триггерим обновление событий
setEventsRefreshTrigger(prev => prev + 1);
} catch (error: any) {
console.error('Failed to import calendar:', error);
alert(`Ошибка импорта: ${error.message}`);
} finally {
// Сбрасываем input для возможности повторного импорта того же файла
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const eventDays = useMemo(() => {
const days = new Set();
events.forEach((ev) => {
if (!ev.start) return;
const eventStart = new Date(ev.start);
if (Number.isNaN(eventStart.getTime())) return;
// Обрабатываем многодневные события
const eventEnd = ev.end ? new Date(ev.end) : eventStart;
// Сбрасываем время для корректного сравнения
eventStart.setHours(0, 0, 0, 0);
eventEnd.setHours(0, 0, 0, 0);
// Добавляем все дни от start до end включительно
const currentDay = new Date(eventStart);
while (currentDay <= eventEnd) {
days.add(formatDateKey(currentDay));
currentDay.setDate(currentDay.getDate() + 1);
}
});
return days;
}, [events]);
const days = useMemo(() => {
const firstOfMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
const weekDayIndex = (firstOfMonth.getDay() + 6) % 7;
const gridStart = new Date(firstOfMonth);
gridStart.setDate(firstOfMonth.getDate() - weekDayIndex);
const today = new Date();
return Array.from({ length: 42 }, (_, i) => {
const date = new Date(gridStart);
date.setDate(gridStart.getDate() + i);
const key = formatDateKey(date);
return {
key,
day: date.getDate(),
date,
inCurrentMonth: date.getMonth() === monthDate.getMonth(),
isToday: sameDate(date, today),
hasEvents: eventDays.has(key),
};
});
}, [monthDate, eventDays]);
const weekEvents = useMemo(() => {
const weekStart = startOfWeekMonday(new Date());
const weekEndExclusive = new Date(weekStart);
weekEndExclusive.setDate(weekEndExclusive.getDate() + 7);
return [...events]
.filter((ev) => ev.start)
.filter((ev) => {
const start = new Date(ev.start || "");
if (Number.isNaN(start.getTime())) return false;
return start >= weekStart && start < weekEndExclusive;
})
.sort((a, b) => new Date(a.start || "").getTime() - new Date(b.start || "").getTime())
.slice(0, 12);
}, [events]);
return (
<>
{calendars.length === 0 ? (
Календарей пока нет
) : (
{selectedCalendarId && (
{showCalendarMenu && (
<>
setShowCalendarMenu(false)}
/>
>
)}
)}
)}
Месяц
{monthLabel}
{weekdays.map((day) => (
{day}
))}
{days.map((day) => (
))}
На этой неделе
{loading ? (
Загрузка...
) : error ? (
{error}
) : weekEvents.length === 0 ? (
Событий нет
) : (
{weekEvents.map((event) => {
const start = event.start ? new Date(event.start) : null;
const dateLabel = start && !Number.isNaN(start.getTime())
? start.toLocaleString("ru-RU", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "Без даты";
return (
);
})}
)}
>
);
}
function Calendar({
onOpenCalendarModal,
onOpenEventModal,
onOpenParticipantsModal,
eventsRefreshTrigger,
setEventsRefreshTrigger,
setSidebarEvents,
onCalendarChange
}: {
onOpenCalendarModal: (calendar?: { id?: number; name: string }) => void;
onOpenEventModal: (event: any, date?: Date) => void;
onOpenParticipantsModal: (calendar: { id: number; name: string; user_role?: string }) => void;
eventsRefreshTrigger: number;
setEventsRefreshTrigger: (value: number | ((prev: number) => number)) => void;
setSidebarEvents: (events: any[]) => void;
onCalendarChange: (calendarId: number | null) => void;
}) {
return (
);
}
export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }: PageHeaderProps) {
return (
{badge ? (
{badge}
) : null}
{subtitle ?
{subtitle}
: null}
);
}
export function AppShell({ children }: AppShellProps) {
const { user, loading } = useUser();
const router = useRouter();
const pathname = usePathname();
const isMessageDialogPage = pathname.startsWith('/messages/') && pathname !== '/messages';
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
// Состояния модалов календаря
const [showCalendarModal, setShowCalendarModal] = useState(false);
const [showEventModal, setShowEventModal] = useState(false);
const [showParticipantsModal, setShowParticipantsModal] = useState(false);
const [editingCalendar, setEditingCalendar] = useState<{ id?: number; name: string } | null>(null);
const [editingEvent, setEditingEvent] = useState
(null);
const [participantsCalendar, setParticipantsCalendar] = useState<{ id: number; name: string; user_role?: string } | null>(null);
const [eventsRefreshTrigger, setEventsRefreshTrigger] = useState(0);
// Новые модалы для просмотра
const [showDayEventsModal, setShowDayEventsModal] = useState(false);
const [selectedDateForModal, setSelectedDateForModal] = useState(null);
const [showEventDetailsModal, setShowEventDetailsModal] = useState(false);
const [viewingEvent, setViewingEvent] = useState(null);
const [sidebarEvents, setSidebarEvents] = useState([]); // Для ViewDayEventsModal
const [currentSelectedCalendarId, setCurrentSelectedCalendarId] = useState(null);
// Обработчики модалов
const handleOpenCalendarModal = (calendar?: { id?: number; name: string }) => {
setEditingCalendar(calendar || { name: "" });
setShowCalendarModal(true);
};
const handleOpenEventModal = (event: any, date?: Date) => {
// Если передана дата (клик на дату), открываем модал просмотра дня
if (date && !event.id) {
setSelectedDateForModal(date);
setShowDayEventsModal(true);
}
// Если передано событие с id (клик на событие), открываем детали
else if (event.id) {
setViewingEvent(event);
setShowEventDetailsModal(true);
}
// Fallback - создание нового события
else {
setEditingEvent(event);
setShowEventModal(true);
}
};
// Создание события из модала просмотра дня
const handleCreateEventFromDay = useCallback(() => {
if (!currentSelectedCalendarId) {
alert("Сначала выберите календарь");
return;
}
if (!selectedDateForModal) return;
const startDate = new Date(selectedDateForModal);
startDate.setHours(10, 0, 0, 0);
const endDate = new Date(selectedDateForModal);
endDate.setHours(11, 0, 0, 0);
setEditingEvent({
title: "",
description: "",
start: startDate.toISOString(),
end: endDate.toISOString(),
calendar: currentSelectedCalendarId,
color_event: "#3498db",
});
setShowDayEventsModal(false);
setShowEventModal(true);
}, [currentSelectedCalendarId, selectedDateForModal]);
// Переход к редактированию из модала просмотра
const handleEditFromDetails = useCallback(() => {
setEditingEvent(viewingEvent);
setShowEventDetailsModal(false);
setShowEventModal(true);
}, [viewingEvent]);
// Клик на событие из модала просмотра дня
const handleEventClickFromDay = useCallback(async (event: any) => {
setShowDayEventsModal(false);
// Если это occurrence, загружаем базовое событие
if (event.is_recurring && event.event_id) {
try {
const fullEvent = await apiClient.getEvent(event.event_id);
setViewingEvent({
...fullEvent,
start: event.start,
end: event.end,
});
setShowEventDetailsModal(true);
} catch (err) {
console.error("Ошибка загрузки события:", err);
}
} else {
setViewingEvent(event);
setShowEventDetailsModal(true);
}
}, []);
const handleEventSaved = useCallback(() => {
// Обновляем список событий в CalendarCard
setEventsRefreshTrigger(prev => prev + 1);
}, []);
const handleOpenParticipantsModal = (calendar: { id: number; name: string; user_role?: string }) => {
setParticipantsCalendar(calendar);
setShowParticipantsModal(true);
};
useEffect(() => {
// Если загрузка завершена и пользователь не авторизован - редирект на логин
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
useEffect(() => {
setIsMobileLeftNavOpen(false);
setIsMobileCalendarOpen(false);
}, [pathname]);
useEffect(() => {
const onResize = () => {
if (window.innerWidth >= 1024) {
setIsMobileLeftNavOpen(false);
setIsMobileCalendarOpen(false);
}
};
window.addEventListener("resize", onResize);
onResize();
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
useEffect(() => {
if (isMobileLeftNavOpen || isMobileCalendarOpen) {
document.body.style.overflow = "hidden";
return;
}
document.body.style.overflow = "";
return () => {
document.body.style.overflow = "";
};
}, [isMobileLeftNavOpen, isMobileCalendarOpen]);
// Показываем загрузку, пока проверяем авторизацию
if (loading) {
return (
);
}
// Если пользователь не авторизован, ничего не показываем (уже редиректим)
if (!user) {
return null;
}
return (
setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
{children}
setIsMobileCalendarOpen(false)}
aria-label="Закрыть календарь"
/>
Календарь
setIsMobileCalendarOpen(false)}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100"
aria-label="Закрыть календарь"
>
{/* Модалы календаря - рендерятся на верхнем уровне */}
{
setShowCalendarModal(false);
setEditingCalendar(null);
}}
calendar={editingCalendar}
/>
{/* Модальное окно участников календаря */}
{participantsCalendar && (
{
setShowParticipantsModal(false);
setParticipantsCalendar(null);
}}
calendarId={participantsCalendar.id}
calendarName={participantsCalendar.name}
userRole={participantsCalendar.user_role}
/>
)}
{/* Модальное окно события */}
{
setShowEventModal(false);
setEditingEvent(null);
}}
event={editingEvent}
onSave={handleEventSaved}
showParticipants={true}
/>
{/* Модальное окно просмотра событий дня */}
{
setShowDayEventsModal(false);
setSelectedDateForModal(null);
}}
date={selectedDateForModal}
events={sidebarEvents}
onEventClick={handleEventClickFromDay}
onCreateEvent={handleCreateEventFromDay}
/>
{/* Модальное окно просмотра деталей события */}
{
setShowEventDetailsModal(false);
setViewingEvent(null);
}}
event={viewingEvent}
onEdit={handleEditFromDetails}
onDelete={async () => {
if (!viewingEvent?.id) return;
if (!confirm("Удалить это событие?")) return;
try {
await apiClient.deleteEvent(viewingEvent.id);
setShowEventDetailsModal(false);
setViewingEvent(null);
handleEventSaved(); // Обновляем список событий
} catch (err) {
console.error("Ошибка удаления события:", err);
alert("Не удалось удалить событие");
}
}}
showParticipants={true}
/>
);
}