Corp/backend/static/js/components/calendarWidget.js
Игорь Надеин ef00a1ed65 fix: исправить ReferenceError - recurrence is not defined
Проблема:
- В eventDidMount использовалась переменная recurrence без объявления
- Ошибка: ReferenceError: recurrence is not defined at line 1145

Решение:
- Добавлена строка: const recurrence = info.event.extendedProps?.recurrence || null;
- Переменная теперь корректно извлекается из extendedProps события

Результат:
✅ Ошибка исправлена
✅ События рендерятся без ошибок
✅ Ежегодные события корректно помечаются атрибутом data-recurrence
2026-02-11 13:24:29 +03:00

1572 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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";
/**
* Инициализация виджета календаря
* @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");
// Вынесем модал в <body>, иначе позиционирование ломается, если родитель с 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"),
rec: document.getElementById("detailRecurrence"),
dot: document.getElementById("detailColorDot"),
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.scope.textContent = ev.department
? `Отдел: ${ev.department.name || ev.department}`
: "Компания";
$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;
}
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 = '<hr class="dropdown-divider">';
menu.appendChild(li);
})();
// Вставка пунктов
const frag = document.createDocumentFragment();
departments.forEach((d) => {
const li = document.createElement("li");
li.innerHTML = `<button class="dropdown-item" type="button" data-cal="dept" data-id="${d.id}">${d.name}</button>`;
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,
},
...departments.map((d) => ({
params: { start: startStr, end: endStr, department_id: d.id },
label: d.name,
type: "dept",
id: d.id,
})),
];
// Добавляем личный календарь, если есть employee_id
if (currentEmployeeId) {
sources.push({
params: {
start: startStr,
end: endStr,
employee_id: currentEmployeeId,
},
label: "Личный",
type: "personal",
id: currentEmployeeId,
});
}
// Загружаем все источники параллельно (с кешированием!)
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 },
})),
)
.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,
};
}
function renderVertical(container, rangeLabel, events, ws, we) {
if (!container) return;
container.innerHTML = "";
if (rangeLabel) {
const df = new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "long",
});
rangeLabel.textContent = `${df.format(ws)} — ${df.format(new Date(we.getTime() - dayMs))}`;
}
const dfDow = new Intl.DateTimeFormat("ru-RU", { weekday: "short" });
const dfMD = new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
month: "2-digit",
});
const list = events
.map(normalizeEvent)
.filter(Boolean)
.filter((ev) => overlaps(ev, ws, we))
.sort((a, b) => a.start - b.start);
if (!list.length) {
const e = document.createElement("div");
e.className = "week-vertical empty";
e.textContent = "Нет событий на этой неделе";
container.appendChild(e);
return;
}
list.forEach((ev) => {
const row = document.createElement("div");
row.className = "week-row";
// Сохраняем ID события для обработчика клика (уже нормализовано в ev.id)
if (ev.id) {
row.dataset.eventId = String(ev.id);
row.style.cursor = "pointer";
row.addEventListener("click", () => {
console.log("[CalendarWidget] Opening event details:", ev.id);
openEventDetailsById(ev.id);
});
}
// Цветовая точка слева от заголовка
const dot = document.createElement("span");
dot.className = "color-dot";
dot.style.backgroundColor = ev.color || DEFAULT_EVENT_COLOR;
const badge = document.createElement("div");
badge.className = "date-badge";
const dow = dfDow.format(ev.start),
md = dfMD.format(ev.start);
badge.innerHTML = `<div class="dow">${dow}</div><div class="md">${md}</div>`;
const cont = document.createElement("div");
cont.className = "content";
const title = document.createElement("div");
title.className = "title";
title.textContent = ev.title || "Без названия";
title.title = ev.title || "";
// Строка с источником, локацией и кратким описанием
const meta = document.createElement("div");
meta.className = "meta";
const dateSpan =
ev.end - ev.start > dayMs
? `${dfMD.format(ev.start)} — ${dfMD.format(new Date(ev.end - 1))}`
: `${md}`;
const parts = [ev.sourceLabel || "—"];
if (ev.location) parts.push(ev.location);
if (ev.description) parts.push(truncate(ev.description, 20));
meta.textContent = `${dateSpan} • ${parts.join(" • ")}`;
const head = document.createElement("div");
head.style.display = "flex";
head.style.alignItems = "center";
head.style.gap = "8px";
head.append(dot, title);
cont.append(head, meta);
row.append(badge, cont);
container.appendChild(row);
});
}
async function updateWeekLists() {
const now = new Date();
const ws = startOfWeek(now),
we = endOfWeek(now);
const from = new Date(ws.getTime() - 3 * dayMs),
to = new Date(we.getTime() + 3 * dayMs);
const items = await fetchEventsAllCalendars(from, to);
renderVertical(
document.getElementById("weekList"),
document.getElementById("weekRange"),
items,
ws,
we,
);
renderVertical(
document.getElementById("weekListMobile"),
document.getElementById("weekRangeMobile"),
items,
ws,
we,
);
}
/* ===== Контекстное меню для событий ===== */
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);
}
// Заполняем чекбоксы календарей
populateCalendarCheckboxes();
// При редактировании отмечаем только текущий календарь события
const checkboxes = document.querySelectorAll(
'input[name="target_calendars"]',
);
if (checkboxes.length > 0 && data) {
// Сначала снимаем все галочки
checkboxes.forEach((cb) => (cb.checked = false));
// Отмечаем текущий календарь
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";
}
// Ищем и отмечаем нужный чекбокс
checkboxes.forEach((cb) => {
if (cb.value === targetValue) {
cb.checked = true;
}
});
}
// Открываем форму редактирования
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();
}
/* ===== Функция заполнения чекбоксов календарей ===== */
function populateCalendarCheckboxes() {
const container = document.getElementById("targetCalendarCheckboxes");
if (!container) return;
// Очищаем контейнер
container.innerHTML = "";
let checkboxIndex = 0;
// Функция создания чекбокса
function createCheckbox(value, label, checked = false, color = null) {
const div = document.createElement("div");
div.className = "form-check";
const input = document.createElement("input");
input.className = "form-check-input";
input.type = "checkbox";
input.value = value;
input.id = `cal-checkbox-${checkboxIndex++}`;
input.name = "target_calendars";
input.checked = checked;
const labelEl = document.createElement("label");
labelEl.className = "form-check-label";
labelEl.htmlFor = input.id;
// Добавляем цветовой индикатор, если указан цвет
if (color) {
const colorSpan = document.createElement("span");
colorSpan.style.display = "inline-block";
colorSpan.style.width = "12px";
colorSpan.style.height = "12px";
colorSpan.style.backgroundColor = color;
colorSpan.style.borderRadius = "2px";
colorSpan.style.marginRight = "6px";
colorSpan.style.verticalAlign = "middle";
labelEl.appendChild(colorSpan);
}
labelEl.appendChild(document.createTextNode(label));
div.appendChild(input);
div.appendChild(labelEl);
return div;
}
// Добавляем базовые чекбоксы (legacy) - используем константы
container.appendChild(
createCheckbox(
CALENDAR_TYPES.COMPANY,
"Компания (общие события)",
true,
CALENDAR_COLORS.COMPANY
),
);
container.appendChild(
createCheckbox(
CALENDAR_TYPES.PERSONAL,
"Личный календарь",
false,
CALENDAR_COLORS.PERSONAL
),
);
// Добавляем отделы, если есть
if (window.departments && Array.isArray(window.departments)) {
window.departments.forEach((dept) => {
container.appendChild(
createCheckbox(
`dept-${dept.id}`,
`Отдел: ${dept.name}`,
false,
CALENDAR_COLORS.DEPARTMENT,
),
);
});
}
// Добавляем разделитель, если есть новые календари
if (window.calendarIntegration?.calendars) {
const newCalendars = window.calendarIntegration.calendars.filter(
(cal) => !cal.id.toString().startsWith("legacy-"),
);
if (newCalendars.length > 0) {
// Разделительная линия
const hr = document.createElement("hr");
hr.className = "my-2";
container.appendChild(hr);
const header = document.createElement("small");
header.className = "text-muted d-block mb-1";
header.textContent = "Мои календари:";
container.appendChild(header);
// Добавляем новые календари
newCalendars.forEach((cal) => {
container.appendChild(
createCheckbox(
cal.id,
`📅 ${cal.name}`,
false,
cal.color || "#6c757d",
),
);
});
}
}
}
/* ===== Обработчики событий календаря ===== */
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: true, // Автоматически показывать "+N еще" когда много событий
dayMaxEventRows: 4, // Максимум 4 строки событий на день
moreLinkClick: "popover", // Показывать popover при клике на "+N еще"
eventMaxStack: 3, // Максимум 3 события в стеке
// Улучшенный порядок событий
eventOrder: ["start", "-duration", "title"], // Сортировка: раньше начало, длиннее, по алфавиту
// Настройки отображения
eventDisplay: "block", // Блочное отображение для лучшей видимости
eventTimeFormat: {
hour: "2-digit",
minute: "2-digit",
meridiem: false,
},
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);
}
// Нормализуем 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;
return {
...ev,
...(id != null ? { id } : {}),
...(eventColor
? { backgroundColor: eventColor, borderColor: eventColor }
: {}),
extendedProps: {
...(ev.extendedProps || {}),
...ev, // Копируем все свойства события
calendar_color: calendarColor, // Добавляем цвет календаря
},
};
});
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;
if (calendarColor) {
// Если есть цвет календаря - показываем оба цвета
// Левая граница = цвет календаря, фон = цвет события (полупрозрачный)
eventEl.style.borderLeft = `4px solid ${calendarColor}`;
eventEl.style.backgroundColor = eventColor + "CC"; // 80% прозрачности
eventEl.style.borderColor = eventColor;
// Добавляем тень для объёма
eventEl.style.boxShadow =
"0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)";
} else {
// Если нет цвета календаря - используем только цвет события
eventEl.style.backgroundColor = eventColor;
eventEl.style.borderColor = eventColor;
eventEl.style.borderLeft = `3px solid ${eventColor}`;
eventEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)";
}
// Улучшаем читаемость текста
eventEl.style.color = "#ffffff";
eventEl.style.textShadow = "0 1px 2px rgba(0,0,0,0.2)";
// Закругляем углы для современного вида
eventEl.style.borderRadius = "4px";
eventEl.style.overflow = "hidden";
// Добавляем отступы
eventEl.style.paddingLeft = "6px";
eventEl.style.paddingRight = "4px";
// Hover эффект
eventEl.addEventListener("mouseenter", () => {
eventEl.style.transform = "translateY(-1px)";
eventEl.style.boxShadow =
"0 4px 8px rgba(0,0,0,0.15), 0 2px 4px rgba(0,0,0,0.12)";
eventEl.style.transition = "all 0.2s ease";
eventEl.style.zIndex = "100";
});
eventEl.addEventListener("mouseleave", () => {
eventEl.style.transform = "translateY(0)";
if (calendarColor) {
eventEl.style.boxShadow =
"0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)";
} else {
eventEl.style.boxShadow = "0 1px 2px rgba(0,0,0,0.1)";
}
eventEl.style.transition = "all 0.2s ease";
});
// Особый стиль для ежегодных событий
if (recurrence === "annual") {
eventEl.setAttribute("data-recurrence", "annual");
}
// Добавляем обработчик правой кнопки мыши
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 (_) {}
// Заполняем чекбоксы календарей
populateCalendarCheckboxes();
// Открываем модал
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
if (r === "weekly") {
weeklyBlock?.classList.remove("d-none");
} else {
weeklyBlock?.classList.add("d-none");
}
// Hourly требует времени → снимаем all-day и блокируем чекбокс
if (r === "hourly") {
allDayChk.checked = false;
allDayChk.disabled = true;
} else {
allDayChk.disabled = false;
}
}
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;
}
// Получаем выбранные календари из чекбоксов
const selectedCalendars = [];
const checkboxes = document.querySelectorAll(
'input[name="target_calendars"]:checked',
);
checkboxes.forEach((cb) => selectedCalendars.push(cb.value));
if (selectedCalendars.length === 0) {
alert("Выберите хотя бы один календарь для создания события");
return;
}
const postHeaders = {
"Content-Type": "application/json",
...authHeaders(),
};
// Проверка токена
if (!globalToken) {
alert("Требуется авторизация. Войдите заново.");
return;
}
try {
const isEdit = form.dataset.mode === "edit" && form.dataset.eventId;
if (isEdit) {
// При редактировании - обновляем только первый выбранный календарь
const targetCalendar = selectedCalendars[0];
// Используем утилиту для определения payload
const eventPayload = resolveEventPayload(targetCalendar, payload);
const url = API_EVENTS + String(form.dataset.eventId) + "/";
await fetchJSON(url, {
method: "PATCH",
headers: postHeaders,
body: JSON.stringify(eventPayload),
});
} else {
// При создании - создаем событие для каждого выбранного календаря
const createPromises = selectedCalendars.map((targetCalendar) => {
// Используем утилиту для определения payload
const eventPayload = resolveEventPayload(targetCalendar, payload);
return fetchJSON(API_EVENTS, {
method: "POST",
headers: postHeaders,
body: JSON.stringify(eventPayload),
});
});
// Ждем создания всех событий
await Promise.all(createPromises);
}
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();
// Проверяем 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);
}
});
}