/** * calendarWidget.js * Модуль управления календарём событий с FullCalendar * Поддерживает: компания + отделы пользователя, повторяющиеся события, недельный список * * @module calendarWidget * @version 2.0.0 */ import { getCalendarEvents, invalidateCalendarEvents, } from "../api/calendarApi.js"; import { getMyDepartments } from "../api/departmentsApi.js"; import { getAccessToken, authHeaders as getAuthHeaders, getCookie, } from "../utils/authUtils.js"; import { formatDate, ymdLocal, fmtDate, fmtTime } from "../utils/dateUtils.js"; import { CALENDAR_TYPES, CALENDAR_COLORS, createLegacyDeptId, } from "../constants/calendarTypes.js"; import { API_URLS } from "../constants/apiUrls.js"; import { resolveEventPayload } from "../utils/calendarTypeResolver.js"; import { extractNumericPk, eventsUrl, addRange, isDateOnly, toDate, pick, startOfWeek, endOfWeek, overlaps, truncate, setWeekdaysFromMask, fmtWhen, DIGITS_RE, dayMs, hourMs, } from "./calendarWidget/helpers.js"; import { fetchJSON, apiGet, apiDelete } from "./calendarWidget/apiClient.js"; import { renderVertical, updateWeekLists as updateWeekListsRenderer, } from "./calendarWidget/weekListRenderer.js"; /** * Инициализация виджета календаря * @param {Object} options - Параметры инициализации * @param {string} options.deskContainerId - ID контейнера десктопного календаря (по умолчанию 'calendarRight') * @param {string} options.mobContainerId - ID контейнера мобильного календаря (по умолчанию 'calendarRightMobile') * @param {string} options.apiEventsUrl - URL API событий (по умолчанию '/api/v1/calendar/events/') * @param {string} options.apiMyDeptsUrl - URL API отделов пользователя (по умолчанию '/api/v1/departments/my-departments/') * @param {string} options.defaultColor - Цвет событий по умолчанию (по умолчанию '#0d6efd') * @returns {Object} API виджета { updateWeekLists, refetchEvents, getDepartments } */ export function initCalendarWidget(options = {}) { if (!window.FullCalendar) { return null; // Тихий выход, если FullCalendar не загружен } // Инициализация цветовой палитры initColorPicker(); /* ===== Конфигурация ===== */ const config = { deskContainerId: options.deskContainerId || "calendarRight", mobContainerId: options.mobContainerId || "calendarRightMobile", apiEventsUrl: options.apiEventsUrl || API_URLS.EVENTS, apiMyDeptsUrl: options.apiMyDeptsUrl || API_URLS.MY_DEPARTMENTS, defaultColor: options.defaultColor || CALENDAR_COLORS.DEFAULT, }; const API_EVENTS = config.apiEventsUrl; const API_MY_DEPTS = config.apiMyDeptsUrl; const DEFAULT_EVENT_COLOR = config.defaultColor; const holder = document.querySelector(".rightbar-card") || document.querySelector("#rightbarOffcanvas"); // Используем утилиты авторизации из authUtils const globalToken = getAccessToken(); function authHeaders() { return getAuthHeaders(); } // Используем ymdLocal, eventsUrl, addRange, extractNumericPk из helpers.js // Используем DIGITS_RE, dayMs, hourMs из helpers.js // Обратная совместимость: data-dept-id="1,2,3" const legacyDeptIds = (holder?.dataset.deptId || "") .split(",") .map((s) => s.trim()) .filter((s) => DIGITS_RE.test(s)); /* ===== Элементы UI ===== */ const chooserBtn = document.getElementById("calendarChooserBtn"); const chooserMenu = document.getElementById("calendarChooserMenu"); const chooserBtnMobile = document.getElementById("calendarChooserBtnMobile"); const chooserMenuMobile = document.getElementById( "calendarChooserMenuMobile", ); const eventTargetLabel = document.getElementById("eventTargetLabel"); // Поля модала для переключений const allDayChk = document.getElementById("allDayChk"); const recurrenceSelect = document.getElementById("recurrenceSelect"); const weeklyBlock = document.getElementById("weeklyBlock"); const recurrenceInterval = document.getElementById("recurrenceInterval"); const recurrenceUntil = document.getElementById("recurrenceUntil"); const recurrenceCount = document.getElementById("recurrenceCount"); /* ===== Состояние ===== */ let departments = []; // [{id, name}] где id — всегда числовой PK const state = { type: "company", deptId: null, employeeId: null }; // Защита от гонок при загрузке отделов let deptsLoadSeq = 0; // Используем isDateOnly, toDate, pick, startOfWeek, endOfWeek, overlaps, truncate из helpers.js // Используем fetchJSON из apiClient.js // Используем setWeekdaysFromMask, fmtWhen из helpers.js // Используем apiGet, apiDelete из apiClient.js // Узлы модала (если разметка подключена) const detailsModalEl = document.getElementById("eventDetailsModal"); // Вынесем модал в , иначе позиционирование ломается, если родитель с transform if (detailsModalEl && detailsModalEl.parentElement !== document.body) { document.body.appendChild(detailsModalEl); } // Гарантируем вертикальное центрирование (если в HTML забыли класс) detailsModalEl ?.querySelector(".modal-dialog") ?.classList.add("modal-dialog-centered"); const detailsModal = detailsModalEl ? new bootstrap.Modal(detailsModalEl) : null; const $dt = { title: document.getElementById("detailTitle"), when: document.getElementById("detailWhen"), loc: document.getElementById("detailLocation"), desc: document.getElementById("detailDescription"), scope: document.getElementById("detailScope"), scopeWrapper: document.getElementById("detailScopeWrapper"), rec: document.getElementById("detailRecurrence"), dot: document.getElementById("detailColorDot"), createdBy: document.getElementById("detailCreatedBy"), createdByWrapper: document.getElementById("detailCreatedByWrapper"), createdAt: document.getElementById("detailCreatedAt"), createdAtWrapper: document.getElementById("detailCreatedAtWrapper"), calendar: document.getElementById("detailCalendar"), calendarWrapper: document.getElementById("detailCalendarWrapper"), btnEdit: document.getElementById("btnEditEvent"), btnDel: document.getElementById("btnDeleteEvent"), }; let currentDetail = null; function fillDetails(ev) { if (!$dt.title) return; // Модала нет — тихо выходим $dt.title.textContent = ev.title || "Событие"; $dt.when.textContent = fmtWhen(ev); $dt.loc.textContent = ev.location || "—"; $dt.desc.textContent = ev.description || "—"; // Повторение $dt.rec.textContent = ev.recurrence_interval ? `${ev.recurrence} ×${ev.recurrence_interval}` : ev.recurrence || "—"; const col = ev.color || DEFAULT_EVENT_COLOR; if ($dt.dot) $dt.dot.style.backgroundColor = col; // Информация о создателе if ($dt.createdBy && $dt.createdByWrapper) { if (ev.created_by_name) { $dt.createdBy.textContent = ev.created_by_name; $dt.createdByWrapper.style.display = ""; } else { $dt.createdByWrapper.style.display = "none"; } } // Дата создания if ($dt.createdAt && $dt.createdAtWrapper) { if (ev.created_at) { const createdDate = new Date(ev.created_at); $dt.createdAt.textContent = createdDate.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", }); $dt.createdAtWrapper.style.display = ""; } else { $dt.createdAtWrapper.style.display = "none"; } } // Информация о календаре // Приоритет: новый календарь > legacy (department/employee) if ($dt.calendar && $dt.calendarWrapper) { if (ev.calendar_title) { // Событие из нового календаря $dt.calendar.textContent = ev.calendar_title; $dt.calendarWrapper.style.display = ""; // Скрываем старое поле "Контекст" if ($dt.scopeWrapper) $dt.scopeWrapper.style.display = "none"; } else { // Legacy событие - скрываем новое поле "Календарь" $dt.calendarWrapper.style.display = "none"; // Показываем старое поле "Контекст" if ($dt.scope && $dt.scopeWrapper) { if (ev.department) { $dt.scope.textContent = `Отдел: ${ev.department.name || ev.department}`; } else if (ev.employee) { $dt.scope.textContent = "Личный календарь"; } else { $dt.scope.textContent = "Компания"; } $dt.scopeWrapper.style.display = ""; } } } } async function openEventDetailsById(eventId) { console.log("[CalendarWidget] openEventDetailsById called with:", eventId); try { const url = `${API_EVENTS}${eventId}/`; console.log("[CalendarWidget] Fetching event details from:", url); const data = await apiGet(url); console.log("[CalendarWidget] Event details loaded:", data); currentDetail = data; fillDetails(data); if (detailsModal) { console.log("[CalendarWidget] Showing details modal"); detailsModal.show(); } else { console.error("[CalendarWidget] detailsModal is not initialized!"); } } catch (err) { console.error("[CalendarWidget] Load details error:", err); if (err.status === 404) { alert("Событие не найдено. Возможно, оно было удалено."); // Очистить параметр event_id из URL, если он есть const url = new URL(window.location); if (url.searchParams.has("event_id")) { url.searchParams.delete("event_id"); window.history.replaceState({}, "", url); } } else if (err.status === 403) { alert("Недостаточно прав для просмотра деталей события."); } else { alert("Не удалось загрузить событие."); } } } /* ===== Загрузка отделов (только числовой PK) ===== */ async function loadDepartments() { const mySeq = ++deptsLoadSeq; // Метка версии вызова let raw = []; try { // Используем кешированный API вместо прямого fetch raw = await getMyDepartments(); } catch (e) { console.error("[CalendarWidget] Failed to load departments:", e); raw = []; } // Маппинг → {id, name} let mapped = (Array.isArray(raw) ? raw : []) .map((d) => { const pk = extractNumericPk(d); if (!pk) return null; const name = d.name ?? d.title ?? d.code ?? `Отдел #${pk}`; return { id: String(pk), name }; }) .filter(Boolean); // Если API вернул пусто — fallback к data-dept-id if (!mapped.length && legacyDeptIds.length) { mapped = legacyDeptIds.map((pk) => ({ id: String(pk), name: `Отдел #${pk}`, })); } // ✅ Дедуп по id const uniq = new Map(); for (const d of mapped) { const key = String(d.id); if (!uniq.has(key)) uniq.set(key, d); } const nextDepartments = Array.from(uniq.values()); // Если наш вызов устарел (есть более поздний) — не трогаем DOM if (mySeq !== deptsLoadSeq) return departments; departments = nextDepartments; // Очистка пунктов отделов в обоих dropdown (десктоп и мобильный) [chooserMenu, chooserMenuMobile].forEach((menu) => { if (menu) menu .querySelectorAll('[data-cal="dept"]') .forEach((n) => n.closest("li")?.remove()); }); if (!departments.length) { setChooserLabel(); return departments; } // Заполнение dropdown отделами (десктоп и мобильный) [chooserMenu, chooserMenuMobile].forEach((menu) => { if (!menu) return; // Убедимся, что есть разделитель menu.querySelector(".dropdown-divider") || (() => { const li = document.createElement("li"); li.innerHTML = ''; menu.appendChild(li); })(); // Вставка пунктов const frag = document.createDocumentFragment(); departments.forEach((d) => { const li = document.createElement("li"); li.innerHTML = ``; frag.appendChild(li); }); menu.appendChild(frag); }); // Валидация текущего выбора if ( state.type === "dept" && !departments.some((d) => String(d.id) === String(state.deptId)) ) { state.type = "company"; state.deptId = null; } setChooserLabel(); return departments; } function currentDeptLabel() { if (state.type === "personal") return "Личный"; if (state.type !== "dept") return "Компания"; const d = departments.find((x) => String(x.id) === String(state.deptId)); return d?.name || `Отдел #${state.deptId}`; } function setChooserLabel() { let label = "Компания"; if (state.type === "personal") { label = "Личный"; } else if (state.type === "dept") { label = currentDeptLabel(); } if (chooserBtn) chooserBtn.textContent = label; if (chooserBtnMobile) chooserBtnMobile.textContent = label; if (eventTargetLabel) eventTargetLabel.textContent = label; } /* ===== Обработчик выбора из дропдауна (десктоп и мобильный) ===== */ function handleChooserClick(e) { const btn = e.target.closest("[data-cal]"); if (!btn) return; const type = btn.dataset.cal; if (type === "company") { state.type = "company"; state.deptId = null; state.employeeId = null; } else if (type === "personal") { state.type = "personal"; state.deptId = null; // Получаем employee_id из meta-тега или атрибута const userMeta = document.querySelector('meta[name="user-id"]'); state.employeeId = userMeta ? userMeta.content : null; if (!state.employeeId) { alert("Не удалось определить ID пользователя"); state.type = "company"; return; } } else { const id = btn.dataset.id; if (!DIGITS_RE.test(String(id || ""))) { alert("Некорректный идентификатор отдела"); return; } state.type = "dept"; state.deptId = id; state.employeeId = null; } setChooserLabel(); [deskCalendar, mobCalendar].forEach((cal) => cal?.refetchEvents()); updateWeekLists(); } if (chooserMenu) chooserMenu.addEventListener("click", handleChooserClick); if (chooserMenuMobile) chooserMenuMobile.addEventListener("click", handleChooserClick); /* ===== Комбинированная загрузка событий (occurrences) ===== */ // События для текущего контекста (используется календарём) async function fetchEventsCombined(start, end) { try { const params = { start: ymdLocal(start), end: ymdLocal(end), }; // Добавляем employee_id для личного календаря if (state.type === "personal" && state.employeeId) { params.employee_id = state.employeeId; } // Добавляем department_id для отдела else if (state.type === "dept" && state.deptId) { params.department_id = state.deptId; } // Используем кешированный API return await getCalendarEvents(params); } catch (e) { console.error("[CalendarWidget] Fetch events failed", e); return []; } } // ✅ ВСЕ доступные календари: компания + все отделы пользователя + личный async function fetchEventsAllCalendars(start, end) { // При первом вызове убедимся, что отделы загружены if (!departments || departments.length === 0) { try { await loadDepartments(); } catch (_) {} } const startStr = ymdLocal(start); const endStr = ymdLocal(end); // Получаем employee_id текущего пользователя const userMeta = document.querySelector('meta[name="user-id"]'); const currentEmployeeId = userMeta ? userMeta.content : null; // Формируем список источников с подписью и цветом const sources = [ { params: { start: startStr, end: endStr }, label: "Компания", type: "company", id: null, color: CALENDAR_COLORS.COMPANY, // Цвет компании }, ...departments.map((d) => ({ params: { start: startStr, end: endStr, department_id: d.id }, label: d.name, type: "dept", id: d.id, color: CALENDAR_COLORS.DEPARTMENT, // Цвет отдела })), ]; // Добавляем личный календарь, если есть employee_id if (currentEmployeeId) { sources.push({ params: { start: startStr, end: endStr, employee_id: currentEmployeeId, }, label: "Личный", type: "personal", id: currentEmployeeId, color: CALENDAR_COLORS.PERSONAL, // Цвет личного календаря }); } // Загружаем все источники параллельно (с кешированием!) const chunks = await Promise.all( sources.map((s) => getCalendarEvents(s.params) .then((arr) => (arr || []).map((ev) => ({ ...ev, __source: { label: s.label, type: s.type, id: s.id }, __calendar: { color: s.color }, // Добавляем цвет календаря })), ) .catch((err) => { console.error( `[CalendarWidget] Failed to load events for ${s.label}:`, err, ); return []; }), ), ); const merged = chunks.flat(); // Дедуп по устойчивому ключу const seen = new Set(); const out = []; for (const ev of merged) { const key = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id ?? null; const sk = key != null ? String(key) : null; if (!sk || !seen.has(sk)) { if (sk) seen.add(sk); out.push(ev); } } return out; } function normalizeEvent(ev) { const id = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id; const title = pick(ev, ["title", "name", "summary", "text"]) || ""; const rs = pick(ev, [ "start", "start_date", "date_start", "date", "date_from", ]); const re = pick(ev, ["end", "end_date", "date_end", "date_to"]); const allDay = "allDay" in ev ? !!ev.allDay : "all_day" in ev ? !!ev.all_day : isDateOnly(rs) && (!re || isDateOnly(re)); const s = toDate(rs); let e = toDate(re); if (!s) return null; if (!e) e = new Date(s.getTime() + (allDay ? dayMs : hourMs)); const color = ev.color || ev.bgColor || null; const recurrence = ev.recurrence || ev.recurrence_display || null; const location = ev.location || ev.place || ""; const description = ev.description || ev.details || ev.note || ""; const sourceLabel = ev.__source?.label || (ev.department?.name ?? ev.department) || "Компания"; return { id, title, start: s, end: e, allDay, color, recurrence, location, description, sourceLabel, }; } // Используем renderVertical и updateWeekLists из weekListRenderer.js // Обёртка для updateWeekLists с передачей нужных параметров async function updateWeekLists() { await updateWeekListsRenderer( fetchEventsAllCalendars, normalizeEvent, openEventDetailsById, DEFAULT_EVENT_COLOR, ); } /* ===== Контекстное меню для событий ===== */ const contextMenu = document.getElementById("calendarContextMenu"); const contextMenuView = document.getElementById("contextMenuView"); const contextMenuEdit = document.getElementById("contextMenuEdit"); const contextMenuDelete = document.getElementById("contextMenuDelete"); let contextMenuEventId = null; let contextMenuLongPressTimer = null; // Функция показа контекстного меню function showContextMenu(x, y, eventId) { if (!contextMenu) return; contextMenuEventId = eventId; // Проверяем права доступа checkEventPermissions(eventId).then((perms) => { // Показываем/скрываем кнопки в зависимости от прав if (contextMenuEdit) { contextMenuEdit.classList.toggle("d-none", !perms.can_edit); } if (contextMenuDelete) { contextMenuDelete.classList.toggle("d-none", !perms.can_delete); } // Скрываем разделитель если нет прав на удаление const divider = contextMenu.querySelector( '.dropdown-divider[data-requires-permission="delete"]', ); if (divider) { divider.classList.toggle("d-none", !perms.can_delete); } // Сначала показываем меню для получения его размеров contextMenu.style.display = "block"; contextMenu.style.visibility = "hidden"; // Получаем размеры меню и окна const menuRect = contextMenu.getBoundingClientRect(); const menuWidth = menuRect.width; const menuHeight = menuRect.height; const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; // Корректируем позицию по горизонтали let finalX = x; if (x + menuWidth > windowWidth) { // Меню выходит за правый край - сдвигаем влево finalX = windowWidth - menuWidth - 10; // 10px отступ от края } if (finalX < 10) { finalX = 10; // Минимальный отступ слева } // Корректируем позицию по вертикали let finalY = y; if (y + menuHeight > windowHeight) { // Меню выходит за нижний край - показываем выше курсора finalY = y - menuHeight; // Если и сверху не помещается, прижимаем к нижнему краю if (finalY < 10) { finalY = windowHeight - menuHeight - 10; } } if (finalY < 10) { finalY = 10; // Минимальный отступ сверху } // Применяем скорректированную позицию contextMenu.style.left = finalX + "px"; contextMenu.style.top = finalY + "px"; contextMenu.style.visibility = "visible"; }); } // Функция скрытия контекстного меню function hideContextMenu() { if (contextMenu) { contextMenu.style.display = "none"; } contextMenuEventId = null; } // Проверка прав на событие async function checkEventPermissions(eventId) { try { const response = await fetch(`${API_EVENTS}${eventId}/permissions/`, { headers: authHeaders(), }); if (response.ok) { return await response.json(); } } catch (e) { console.error("Failed to check permissions:", e); } return { can_view: true, can_edit: false, can_delete: false }; } // Обработчики кнопок контекстного меню if (contextMenuView) { contextMenuView.addEventListener("click", () => { if (contextMenuEventId) { openEventDetailsById(contextMenuEventId); } hideContextMenu(); }); } if (contextMenuEdit) { contextMenuEdit.addEventListener("click", async () => { if (contextMenuEventId) { try { const data = await apiGet(`${API_EVENTS}${contextMenuEventId}/`); currentDetail = data; // Переиспользуем логику редактирования из старой кнопки "Редактировать" editEvent(data); } catch (err) { console.error("Load event for edit error", err); alert("Не удалось загрузить событие для редактирования."); } } hideContextMenu(); }); } if (contextMenuDelete) { contextMenuDelete.addEventListener("click", async () => { if (contextMenuEventId) { if (!confirm("Удалить это событие без возможности восстановления?")) { hideContextMenu(); return; } try { await apiDelete(`${API_EVENTS}${contextMenuEventId}/`); invalidateCalendarEvents(); try { deskCalendar?.refetchEvents?.(); mobCalendar?.refetchEvents?.(); } catch (_) {} updateWeekLists(); } catch (err) { console.error("Delete event error", err); alert( err.status === 403 ? "Недостаточно прав для удаления." : "Не удалось удалить событие.", ); } } hideContextMenu(); }); } // Закрытие контекстного меню при клике вне его document.addEventListener("click", (e) => { if (contextMenu && !contextMenu.contains(e.target)) { hideContextMenu(); } }); // Функция редактирования события (вынесена из обработчика кнопки) function editEvent(data) { const form = document.getElementById("eventForm"); if (!form) return; form.dataset.mode = "edit"; form.dataset.eventId = data.id; // Базовые поля form.querySelector('[name="title"]').value = data.title || ""; form.querySelector('[name="location"]').value = data.location || ""; form.querySelector('[name="color"]').value = data.color || DEFAULT_EVENT_COLOR; form.querySelector('[name="all_day"]').checked = !!data.all_day; // Описание const descEl = form.querySelector('[name="description"]'); if (descEl) descEl.value = data.description || ""; // Дата/время const startIso = data.start || (data.start_date ? `${data.start_date}T${(data.start_time || "00:00").slice(0, 5)}` : ""); const endIso = data.end || (data.end_date ? `${data.end_date}T${(data.end_time || "00:00").slice(0, 5)}` : ""); const startEl = form.querySelector('[name="start"]'); const endEl = form.querySelector('[name="end"]'); if (startEl) startEl.value = (startIso || "").slice(0, 16); if (endEl) endEl.value = (endIso || "").slice(0, 16); // Повторы const recSel = form.querySelector('[name="recurrence"]'); if (recSel) recSel.value = data.recurrence || "one_time"; const recInt = form.querySelector('[name="recurrence_interval"]'); if (recInt) recInt.value = data.recurrence_interval || 1; // Until / count const untilEl = form.querySelector('[name="recurrence_until"]'); if (untilEl) untilEl.value = (data.recurrence_until || "").slice(0, 10); const countEl = form.querySelector('[name="recurrence_count"]'); if (countEl) countEl.value = data.recurrence_count ?? ""; // Weekly дни из маски if (data.weekdays_mask != null) setWeekdaysFromMask(data.weekdays_mask); // Синхронизация UI try { typeof syncByRecurrence === "function" && syncByRecurrence(); } catch (_) {} // Отдел (если есть select) const deptSel = form.querySelector('[name="department_id"]'); if (deptSel && data.department) { const val = data.department.id ?? data.department; if (val != null) deptSel.value = String(val); } // Заполняем dropdown календарей populateCalendarSelect(); // При редактировании выбираем текущий календарь события const select = document.querySelector('select[name="target_calendar"]'); if (select && data) { // Определяем текущий календарь let targetValue = null; if (data.calendar_id) { targetValue = data.calendar_id.toString(); } else if (data.employee_id) { targetValue = "personal"; } else if (data.department_id) { targetValue = `dept-${data.department_id}`; } else { targetValue = "company"; } // Устанавливаем значение в dropdown select.value = targetValue; } // Открываем форму редактирования const createModalEl = document.getElementById("eventCreateModal"); if (createModalEl) { const createModal = bootstrap.Modal.getOrCreateInstance(createModalEl); createModal.show(); } else { const offcanvasEl = document.getElementById("rightbarOffcanvas"); if (offcanvasEl) bootstrap.Offcanvas.getOrCreateInstance(offcanvasEl).show(); } form.querySelector('[name="title"]')?.focus(); } /* ===== Функция заполнения dropdown календарей ===== */ function populateCalendarSelect() { const select = document.getElementById("targetCalendarSelect"); if (!select) return; // Очищаем select (кроме первого placeholder option) while (select.options.length > 1) { select.remove(1); } // Получаем календари из calendarIntegration let allCalendars = []; if ( window.calendarIntegration && typeof window.calendarIntegration.getCalendars === "function" ) { allCalendars = window.calendarIntegration.getCalendars(); console.log( "[CalendarWidget] Loading calendars for modal:", allCalendars.length, ); } if (allCalendars.length === 0) { console.warn("[CalendarWidget] No calendars found, using fallback"); // Fallback: базовые календари const opt1 = document.createElement("option"); opt1.value = CALENDAR_TYPES.COMPANY; opt1.textContent = "📅 Компания (общие события)"; opt1.selected = true; select.appendChild(opt1); const opt2 = document.createElement("option"); opt2.value = CALENDAR_TYPES.PERSONAL; opt2.textContent = "👤 Личный календарь"; select.appendChild(opt2); return; } // Разделяем на legacy и новые календари const legacyCalendars = allCalendars.filter((cal) => String(cal.id).startsWith("legacy-"), ); const newCalendars = allCalendars.filter( (cal) => !String(cal.id).startsWith("legacy-"), ); console.log("[CalendarWidget] Calendar breakdown:", { total: allCalendars.length, legacy: legacyCalendars.length, new: newCalendars.length, }); // Добавляем legacy календари legacyCalendars.forEach((cal, index) => { const opt = document.createElement("option"); opt.value = cal.id; // Иконки для распознавания типа календаря let icon = "📅"; if (cal.id === CALENDAR_TYPES.LEGACY_PERSONAL) icon = "👤"; else if (String(cal.id).includes("dept-")) icon = "🏢"; opt.textContent = `${icon} ${cal.title || cal.name}`; opt.selected = index === 0; // Первый legacy календарь выбран по умолчанию select.appendChild(opt); }); // Добавляем новые календари с вложенной структурой (папки) if (newCalendars.length > 0) { // Группируем календари по папкам (владельцу) const folders = { global: [], // Глобальные (is_global = true) personal: [], // Личные (is_personal = true) departments: {}, // Отделы (is_department = true, по owner_department_id) }; newCalendars.forEach((cal) => { if (cal.is_global) { folders.global.push(cal); } else if (cal.is_personal) { folders.personal.push(cal); } else if (cal.is_department && cal.owner_department_id) { const deptId = cal.owner_department_id; if (!folders.departments[deptId]) { folders.departments[deptId] = { name: cal.owner_department_name || `Отдел ${deptId}`, calendars: [], }; } folders.departments[deptId].calendars.push(cal); } }); console.log("[CalendarWidget] Grouped calendars into folders:", { global: folders.global.length, personal: folders.personal.length, departments: Object.keys(folders.departments).length, folderDetails: { global: folders.global.map((c) => c.title), personal: folders.personal.map((c) => c.title), departments: Object.entries(folders.departments).map( ([id, dept]) => ({ id, name: dept.name, calendars: dept.calendars.map((c) => c.title), }), ), }, }); // Добавляем глобальные календари if (folders.global.length > 0) { const optgroup = document.createElement("optgroup"); optgroup.label = "📂 Компания"; folders.global.forEach((cal) => { const opt = document.createElement("option"); opt.value = cal.id; opt.textContent = ` 🗓️ ${cal.title || cal.name}`; optgroup.appendChild(opt); }); select.appendChild(optgroup); } // Добавляем личные календари if (folders.personal.length > 0) { const optgroup = document.createElement("optgroup"); optgroup.label = "📂 Личные"; folders.personal.forEach((cal) => { const opt = document.createElement("option"); opt.value = cal.id; opt.textContent = ` 🗓️ ${cal.title || cal.name}`; optgroup.appendChild(opt); }); select.appendChild(optgroup); } // Добавляем календари отделов Object.keys(folders.departments).forEach((deptId) => { const dept = folders.departments[deptId]; const optgroup = document.createElement("optgroup"); optgroup.label = `📂 ${dept.name}`; dept.calendars.forEach((cal) => { const opt = document.createElement("option"); opt.value = cal.id; opt.textContent = ` 🗓️ ${cal.title || cal.name}`; optgroup.appendChild(opt); }); select.appendChild(optgroup); }); } } /* ===== Обработчики событий календаря ===== */ const oc = document.getElementById("rightbarOffcanvas"); const setHeadH = () => { const h = oc?.querySelector(".offcanvas-header")?.offsetHeight || 56; if (oc) oc.style.setProperty("--rb-offcanvas-head", h + "px"); }; setHeadH(); window.addEventListener("resize", setHeadH); oc?.addEventListener("shown.bs.offcanvas", () => { setHeadH(); mobCalendar?.updateSize(); }); /* ===== FullCalendar init ===== */ const fcOpts = { locale: "ru", initialView: "dayGridMonth", headerToolbar: { left: "prev,next today", center: "title", right: "" }, height: "auto", displayEventTime: false, selectable: true, // Позволяет выбирать даты // Улучшенное отображение накладывающихся событий dayMaxEvents: 5, // Максимум 5 событий видимых в ячейке (остальные в "+N еще") dayMaxEventRows: 5, // Максимум 5 рядов событий (включая многодневные) moreLinkClick: "popover", // Показывать popover при клике на "+N еще" eventOverlap: false, // ВАЖНО: false заставляет FullCalendar размещать события в разные ряды slotEventOverlap: false, // Дополнительно для timeGrid // Улучшенный порядок событий eventOrder: ["start", "-duration", "title"], // Сортировка: раньше начало, длиннее, по алфавиту // Настройки отображения eventDisplay: "block", // block для dayGrid (не auto!) eventTimeFormat: { hour: "2-digit", minute: "2-digit", meridiem: false, }, // Отладка: проверяем, что FullCalendar получает eventDidMount: (info) => { const ev = info.event; if (ev.allDay && ev.end && ev.start) { const days = Math.round((ev.end - ev.start) / (1000 * 60 * 60 * 24)); if (days > 1) { console.log("EVENT MOUNTED:", { title: ev.title, allDay: ev.allDay, days: days, element_top: info.el.style.top, element_classes: info.el.className, }); } } }, events: async (info, success, failure) => { try { let raw; // Используем новую систему множественных календарей, если доступна if (window.calendarIntegration?.fetchEventsForVisibleCalendars) { console.log( "[CalendarWidget] Using calendar integration for event loading", ); raw = await window.calendarIntegration.fetchEventsForVisibleCalendars( info.start, info.end, ); } else { // Fallback на старую систему console.log("[CalendarWidget] Using legacy fetchEventsCombined"); raw = await fetchEventsCombined(info.start, info.end); } // 🔍 DEBUG: Анализируем события window.lastEvents = raw; const multiDay = raw?.filter((ev) => { const start = new Date(ev.start); const end = new Date(ev.end); const days = Math.round((end - start) / (1000 * 60 * 60 * 24)); return days > 1; }) || []; console.log("🔍 EVENTS ANALYSIS:", { total: raw?.length || 0, multiDay: multiDay.length, multiDayDetails: multiDay.map((ev) => ({ title: ev.title, start: ev.start, end: ev.end, allDay: ev.allDay, days: Math.round( (new Date(ev.end) - new Date(ev.start)) / (1000 * 60 * 60 * 24), ), })), }); if (multiDay.length > 0 && multiDay.some((ev) => !ev.allDay)) { console.warn( "⚠️ ПРОБЛЕМА: Найдены многодневные события с allDay=false!", ); console.log( "Эти события НЕ БУДУТ растягиваться в dayGridMonth виде:", ); console.table( multiDay .filter((ev) => !ev.allDay) .map((ev) => ({ title: ev.title, start: ev.start, end: ev.end, allDay: ev.allDay, })), ); console.log( "� РЕШЕНИЕ: Отредактируйте эти события и уберите время (сделайте целодневными)", ); } // Нормализуем id, даты и цвет для FullCalendar const mapped = (Array.isArray(raw) ? raw : []).map((ev) => { const id = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id; const eventColor = ev.color || ev.bgColor || null; const calendarColor = ev.__calendar?.color || null; // Преобразуем строковые даты в Date объекты для FullCalendar const start = ev.start ? toDate(ev.start) : null; const end = ev.end ? toDate(ev.end) : null; // � FIX: Многодневные события с временем автоматически делаем allDay // для корректного отображения в dayGridMonth виде let allDay = ev.allDay; if (start && end) { const durationDays = Math.round( (end - start) / (1000 * 60 * 60 * 24), ); if (durationDays > 1 && !allDay) { console.log( `� Конвертирую "${ev.title}" в allDay (длительность: ${durationDays} дней)`, ); allDay = true; } } return { ...ev, ...(id != null ? { id } : {}), ...(start ? { start } : {}), // Используем преобразованную дату ...(end ? { end } : {}), // Используем преобразованную дату allDay, // Используем вычисленное значение allDay (может быть изменено для многодневных) ...(eventColor ? { backgroundColor: eventColor, borderColor: eventColor } : {}), extendedProps: { ...(ev.extendedProps || {}), ...ev, // Копируем все свойства события calendar_color: calendarColor, // Добавляем цвет календаря }, }; }); // 🔍 DEBUG: Проверяем финальные события после конвертации console.log("🔍 FINAL MAPPED EVENTS:", { total: mapped.length, multiDayAllDay: mapped.filter((ev) => { const durationDays = Math.round( (new Date(ev.end) - new Date(ev.start)) / (1000 * 60 * 60 * 24), ); return durationDays > 1 && ev.allDay; }).length, multiDayNotAllDay: mapped.filter((ev) => { const durationDays = Math.round( (new Date(ev.end) - new Date(ev.start)) / (1000 * 60 * 60 * 24), ); return durationDays > 1 && !ev.allDay; }).length, }); success(mapped); } catch (e) { console.error("Calendar fetch error:", e); failure(e); } }, eventContent: (arg) => { const el = document.createElement("div"); el.className = "fc-event-main"; el.textContent = arg.event.title || ""; el.title = arg.event.title || ""; return { domNodes: [el] }; }, eventDidMount: (info) => { // Получаем цвет события и цвет календаря const eventColor = info.event.backgroundColor || info.event.extendedProps?.color || "#0d6efd"; const calendarColor = info.event.extendedProps?.calendar_color || null; const recurrence = info.event.extendedProps?.recurrence || null; // Применяем современный стиль с двойным цветом const eventEl = info.el; // Проверяем, является ли событие многодневным const isMultiDay = !info.event.allDay && info.event.start && info.event.end && (info.event.end.getDate() !== info.event.start.getDate() || info.event.end.getMonth() !== info.event.start.getMonth() || info.event.end.getFullYear() !== info.event.start.getFullYear()); // Проверяем тип сегмента многодневного события const isStart = info.isStart; const isEnd = info.isEnd; if (calendarColor) { // Если есть цвет календаря - показываем оба цвета eventEl.setAttribute("data-has-calendar-color", "true"); eventEl.style.borderLeft = `4px solid ${calendarColor}`; // Левая граница = цвет календаря eventEl.style.backgroundColor = eventColor + "CC"; // 80% прозрачности, фон = цвет события eventEl.style.borderColor = eventColor; } else { // Если нет цвета календаря - используем только цвет события eventEl.style.backgroundColor = eventColor; eventEl.style.borderColor = eventColor; eventEl.style.borderLeft = `3px solid ${eventColor}`; } // Улучшаем читаемость текста eventEl.style.color = "#ffffff"; eventEl.style.textShadow = "0 1px 2px rgba(0,0,0,0.2)"; // Особый стиль для ежегодных событий if (recurrence === "annual") { eventEl.setAttribute("data-recurrence", "annual"); } // Hover эффект (только если не применяются автоматически) if (!eventEl.classList.contains("fc-event")) { eventEl.addEventListener("mouseenter", () => { eventEl.style.transform = "translateY(-1px)"; eventEl.style.transition = "all 0.2s ease"; eventEl.style.zIndex = "100"; }); eventEl.addEventListener("mouseleave", () => { eventEl.style.transform = "translateY(0)"; eventEl.style.transition = "all 0.2s ease"; }); } // Добавляем обработчик правой кнопки мыши info.el.addEventListener("contextmenu", (e) => { e.preventDefault(); const eid = info.event.id || info.event.extendedProps?.id; if (eid) { showContextMenu(e.pageX, e.pageY, eid); } }); // Добавляем обработчик долгого нажатия для мобильных устройств info.el.addEventListener("touchstart", (e) => { contextMenuLongPressTimer = setTimeout(() => { const eid = info.event.id || info.event.extendedProps?.id; if (eid) { const touch = e.touches[0]; showContextMenu(touch.pageX, touch.pageY, eid); } }, 500); // 500ms для долгого нажатия }); info.el.addEventListener("touchend", () => { if (contextMenuLongPressTimer) { clearTimeout(contextMenuLongPressTimer); contextMenuLongPressTimer = null; } }); info.el.addEventListener("touchmove", () => { if (contextMenuLongPressTimer) { clearTimeout(contextMenuLongPressTimer); contextMenuLongPressTimer = null; } }); }, // Обработчик клика по дате для создания нового события dateClick: (info) => { // Открываем модал создания события const form = document.getElementById("eventForm"); if (!form) return; // Очищаем форму form.reset(); form.dataset.mode = "create"; delete form.dataset.eventId; // Устанавливаем выбранную дату const clickedDate = info.dateStr; // YYYY-MM-DD const startEl = form.querySelector('[name="start"]'); const endEl = form.querySelector('[name="end"]'); if (startEl) { // Устанавливаем дату начала (с текущим временем) const now = new Date(); const timeStr = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; startEl.value = `${clickedDate}T${timeStr}`; } if (endEl) { // Устанавливаем дату окончания (на час позже) const now = new Date(); now.setHours(now.getHours() + 1); const timeStr = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; endEl.value = `${clickedDate}T${timeStr}`; } // Синхронизируем UI try { typeof syncByRecurrence === "function" && syncByRecurrence(); typeof syncByAllDay === "function" && syncByAllDay(); typeof syncUntilCount === "function" && syncUntilCount(); } catch (_) {} // Заполняем чекбоксы календарей populateCalendarSelect(); // Открываем модал const createModalEl = document.getElementById("eventCreateModal"); if (createModalEl) { const createModal = bootstrap.Modal.getOrCreateInstance(createModalEl); createModal.show(); } }, }; let deskCalendar = null, mobCalendar = null; const desk = document.getElementById(config.deskContainerId); if (desk) { deskCalendar = new FullCalendar.Calendar(desk, fcOpts); deskCalendar.on("loading", (l) => { if (!l) updateWeekLists(); }); deskCalendar.on("eventClick", (info) => { info.jsEvent?.preventDefault?.(); const eid = info.event.id || info.event.extendedProps?.id; if (eid) openEventDetailsById(eid); }); deskCalendar.render(); } const mob = document.getElementById(config.mobContainerId); if (mob) { mobCalendar = new FullCalendar.Calendar(mob, fcOpts); mobCalendar.on("loading", (l) => { if (!l) updateWeekLists(); }); mobCalendar.on("eventClick", (info) => { info.jsEvent?.preventDefault?.(); const eid = info.event.id || info.event.extendedProps?.id; if (eid) openEventDetailsById(eid); }); mobCalendar.render(); } // Загрузим отделы и обновим подпись селектора loadDepartments().then(() => setChooserLabel()); setTimeout(() => updateWeekLists(), 100); [deskCalendar, mobCalendar].forEach((cal) => cal?.on("datesSet", () => updateWeekLists()), ); /* ===== UX: переключалки формы ===== */ function syncByRecurrence() { const r = recurrenceSelect?.value || "one_time"; // Weekly block - показываем только для weekly if (r === "weekly") { weeklyBlock?.classList.remove("d-none"); } else { weeklyBlock?.classList.add("d-none"); } // Recurrence end block - показываем если не one_time const recurrenceEndBlock = document.getElementById("recurrenceEndBlock"); if (r !== "one_time") { if (recurrenceEndBlock) recurrenceEndBlock.style.display = "block"; if (recurrenceInterval) recurrenceInterval.disabled = false; } else { if (recurrenceEndBlock) recurrenceEndBlock.style.display = "none"; if (recurrenceInterval) { recurrenceInterval.value = "1"; recurrenceInterval.disabled = true; } } } function syncByAllDay() { const isAllDay = allDayChk?.checked; const startEl = document.querySelector('input[name="start"]'); const endEl = document.querySelector('input[name="end"]'); if (!startEl || !endEl) return; // Если all-day — время можно не указывать, но поля не блокируем } function syncUntilCount() { const hasUntil = !!recurrenceUntil?.value; if (hasUntil) { recurrenceCount.value = ""; recurrenceCount.disabled = true; } else { recurrenceCount.disabled = false; } } recurrenceSelect?.addEventListener("change", syncByRecurrence); allDayChk?.addEventListener("change", syncByAllDay); recurrenceUntil?.addEventListener("change", syncUntilCount); syncByRecurrence(); syncByAllDay(); syncUntilCount(); /* ===== Цвет: палитра и синхронизация с input[type=color] ===== */ function initColorPicker() { const form = document.getElementById("eventForm"); if (!form) return; const colorInput = form.querySelector('input[name="color"]'); const palette = document.getElementById("colorPalette"); if (!colorInput || !palette) return; // Значение по умолчанию if (!colorInput.value) colorInput.value = DEFAULT_EVENT_COLOR; function highlightActive() { const val = (colorInput.value || "").toLowerCase(); palette.querySelectorAll("[data-color]").forEach((btn) => { btn.classList.toggle( "active", (btn.dataset.color || "").toLowerCase() === val, ); }); } palette.querySelectorAll("[data-color]").forEach((btn) => { btn.addEventListener("click", () => { colorInput.value = btn.dataset.color || DEFAULT_EVENT_COLOR; colorInput.dispatchEvent(new Event("input", { bubbles: true })); highlightActive(); }); }); colorInput.addEventListener("input", highlightActive); highlightActive(); } /* ===== Создание/редактирование события (POST/PATCH только с Bearer) ===== */ const form = document.getElementById("eventForm"); form?.addEventListener("submit", async (e) => { e.preventDefault(); const fd = new FormData(form); const title = (fd.get("title") || "").toString().trim(); let start = fd.get("start")?.toString() || ""; let end = fd.get("end")?.toString() || ""; const allDay = !!fd.get("all_day"); if (!title) { alert("Укажите заголовок"); return; } // Разбираем datetime-local → даты/время const nowIso = new Date().toISOString().slice(0, 16); const sDate = (start || nowIso).slice(0, 10); const eDate = (end && end.trim() ? end : start || "").slice(0, 10) || sDate; const sTime = start && start.length >= 16 ? start.slice(11, 16) : ""; const eTime = end && end.length >= 16 ? end.slice(11, 16) : ""; // Повторяемость const recurrence = (fd.get("recurrence") || "one_time").toString(); const recurrence_interval = Math.max( 1, parseInt(fd.get("recurrence_interval") || "1", 10), ); const recurrence_until = (fd.get("recurrence_until") || "") .toString() .trim(); const recurrence_count_raw = (fd.get("recurrence_count") || "") .toString() .trim(); const recurrence_count = recurrence_count_raw ? Math.max(1, parseInt(recurrence_count_raw, 10)) : null; // Weekly weekdays → массив чисел const weekdays = Array.from( document.querySelectorAll('input[name="weekdays"]:checked'), ).map((el) => parseInt(el.value, 10)); const colorVal = (fd.get("color") || "").toString().trim() || DEFAULT_EVENT_COLOR; // Базовый payload const payload = { title, start_date: sDate, end_date: eDate, all_day: allDay, recurrence, recurrence_interval, color: colorVal, location: (fd.get("location") || "").toString().trim() || "", description: (fd.get("description") || "").toString().trim() || "", }; // Время (если all_day=false ИЛИ пользователь указал оба времени) if (!allDay || (sTime && eTime)) { if (sTime && eTime) { payload.start_time = sTime; // "HH:MM" payload.end_time = eTime; // "HH:MM" payload.all_day = false; } else if (recurrence === "hourly") { alert("Ежечасное событие требует и время начала, и время окончания."); return; } } // Ограничители серии if (recurrence_until && recurrence_count) { alert( 'Нельзя одновременно задавать "Повторять до" и "Кол-во повторов". Укажите что-то одно.', ); return; } if (recurrence_until) payload.recurrence_until = recurrence_until; if (recurrence_count) payload.recurrence_count = recurrence_count; // Weekly: передаём weekdays если выбраны if (recurrence === "weekly" && weekdays.length) { payload.weekdays = weekdays; } // Получаем выбранный календарь из dropdown const selectEl = document.querySelector('select[name="target_calendar"]'); const targetCalendar = selectEl?.value; if (!targetCalendar) { alert("Выберите календарь для создания события"); return; } const postHeaders = { "Content-Type": "application/json", ...authHeaders(), }; // Проверка токена if (!globalToken) { alert("Требуется авторизация. Войдите заново."); return; } try { const isEdit = form.dataset.mode === "edit" && form.dataset.eventId; // Используем утилиту для определения payload const eventPayload = resolveEventPayload(targetCalendar, payload); if (isEdit) { // При редактировании - обновляем событие const url = API_EVENTS + String(form.dataset.eventId) + "/"; await fetchJSON(url, { method: "PATCH", headers: postHeaders, body: JSON.stringify(eventPayload), }); } else { // При создании - создаем новое событие await fetchJSON(API_EVENTS, { method: "POST", headers: postHeaders, body: JSON.stringify(eventPayload), }); } document.querySelector("#eventCreateModal .btn-close")?.click(); form.reset(); delete form.dataset.mode; delete form.dataset.eventId; syncByRecurrence(); syncByAllDay(); syncUntilCount(); invalidateCalendarEvents(); [deskCalendar, mobCalendar].forEach((cal) => cal?.refetchEvents()); updateWeekLists(); } catch (err) { console.error("Create event error", err); console.error("Payload sent:", payload); console.error("Response data:", err?.data); if (err && err.status === 403) { const where = state?.type === "dept" ? `отделе «${currentDeptLabel()}»` : "компании"; alert( "Недостаточно прав для создания события в " + where + ".\n" + "Попросите руководителя назначить вам роль «Управлять календарём отдела».", ); } else if (err && err.status === 400) { // Детальная информация о валидации const errors = err?.data?.errors || err?.data; const detail = err?.data?.detail || ""; let errorMsg = "Ошибка валидации события:\n\n"; if (detail) { errorMsg += detail + "\n\n"; } if (typeof errors === "object" && errors !== null) { for (const [field, messages] of Object.entries(errors)) { const msgs = Array.isArray(messages) ? messages : [messages]; errorMsg += `${field}: ${msgs.join(", ")}\n`; } } alert( errorMsg || "Не удалось сохранить событие. Проверьте введенные данные.", ); } else { const detail = err?.data?.detail || (typeof err?.data === "string" ? err.data : ""); alert( "Не удалось создать событие: " + (detail || JSON.stringify(err?.data || {})), ); } } }); // API возвращаем для внешнего использования return { updateWeekLists, refetchEvents: () => { deskCalendar?.refetchEvents(); mobCalendar?.refetchEvents(); }, getDepartments: () => departments, getState: () => ({ ...state }), openEventById: (eventId) => openEventDetailsById(eventId), }; } // Автоинициализация при загрузке DOM if (typeof document !== "undefined") { document.addEventListener("DOMContentLoaded", () => { window.calendarWidget = initCalendarWidget(); // Обработчик кнопки "Больше опций" - меняем текст при раскрытии const moreOptionsBtn = document.querySelector( '[data-bs-target="#moreOptions"]', ); const moreOptionsCollapse = document.getElementById("moreOptions"); if (moreOptionsBtn && moreOptionsCollapse) { moreOptionsCollapse.addEventListener("show.bs.collapse", () => { moreOptionsBtn.querySelector("span").textContent = "Меньше опций"; }); moreOptionsCollapse.addEventListener("hide.bs.collapse", () => { moreOptionsBtn.querySelector("span").textContent = "Больше опций"; }); } // Проверяем URL на наличие параметра event_id для открытия модала const urlParams = new URLSearchParams(window.location.search); const eventIdFromUrl = urlParams.get("event_id"); if (eventIdFromUrl && window.calendarWidget) { // Небольшая задержка, чтобы календарь успел загрузиться setTimeout(() => { window.calendarWidget.openEventById(eventIdFromUrl); }, 500); } }); }