Corp/backend/static/js/components/calendarWidget.js
Игорь Надеин 281f0b2bbc feat: современное отображение событий календаря с множественным выбором
Реализовано:

1. Множественный выбор календарей через чекбоксы
   - Заменил <select> на чекбоксы для выбора нескольких календарей
   - События создаются одновременно во всех выбранных календарях
   - При редактировании - обновляется только первый выбранный
   - Цветовые индикаторы для каждого календаря

2. Современный дизайн событий
   - Двухцветная схема: левая граница = цвет календаря, фон = цвет события
   - Полупрозрачный фон события (80%) для лучшей видимости
   - Тени и hover эффекты для объёма
   - Закругленные углы (4px)
   - Плавные анимации (transform + transition)

3. Улучшенное отображение накладывающихся событий
   - dayMaxEvents: автоматически показывать '+N еще'
   - dayMaxEventRows: 4 - максимум 4 строки на день
   - moreLinkClick: 'popover' - красивый popover для остальных
   - eventMaxStack: 3 - до 3 событий в стеке
   - eventOrder: сортировка по началу, длительности, алфавиту

4. Новый CSS файл calendar-improvements.css
   - Современные стили для FullCalendar
   - Улучшенный popover с градиентом
   - Стили для выходных дней (розоватый фон)
   - Hover эффекты на события
   - Анимация fadeInEvent при загрузке
   - Улучшенная читаемость текущего дня
   - Адаптивность для мобильных устройств
   - Кастомные scrollbar для списков

5. Передача цвета календаря в события
   - calendar_color добавляется в extendedProps
   - Используется __calendar из интеграции
   - Отдельная обработка eventColor и calendarColor

6. Визуальные улучшения
   - Текст события: font-weight 500, line-height 1.3
   - Отступы: paddingLeft 6px (с цветом календаря)
   - Тень текста для читаемости на любом фоне
   - Паттерн для ежегодных событий (диагональные полосы)
   - Индикатор текущего дня: синий круг с белым текстом

Результат:
✅ События визуально различимы по календарям (левая граница)
✅ Сохранён индивидуальный цвет события (основной фон)
✅ Множество событий в день отображается компактно (+N еще)
✅ Современный Material Design стиль
✅ Плавные анимации и hover эффекты
✅ Улучшенная читаемость и UX
2026-02-11 12:52:25 +03:00

1821 lines
65 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";
/**
* Инициализация виджета календаря
* @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/v1/calendar/events/",
apiMyDeptsUrl:
options.apiMyDeptsUrl || "/api/v1/departments/my-departments/",
defaultColor: options.defaultColor || "#0d6efd",
};
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");
/* ===== Регулярки и константы ===== */
const DIGITS_RE = /^\d+$/;
const dayMs = 86400000;
const hourMs = 3600000;
/* ===== Токен из meta ===== */
function getAccessToken() {
const meta = document.querySelector('meta[name="api-access"]');
const m = (meta && meta.getAttribute("content")) || "";
if (m) return m.trim();
try {
return localStorage.getItem("api.access") || "";
} catch (_) {
return "";
}
}
// Сохраняем токен глобально при загрузке, чтобы избежать timing issues
const globalToken = getAccessToken();
function authHeaders() {
const headers = {};
if (globalToken) {
headers.Authorization = "Bearer " + globalToken;
}
// Добавляем CSRF токен для POST/PUT/PATCH/DELETE запросов
const csrfToken =
document.querySelector("[name=csrfmiddlewaretoken]")?.value ||
document.querySelector('meta[name="csrf-token"]')?.content ||
getCookie("csrftoken");
if (csrfToken) {
headers["X-CSRFToken"] = csrfToken;
}
return headers;
}
// Функция для получения cookie
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
/* ===== Только ЧИСЛОВОЙ PK отдела ===== */
function extractNumericPk(d) {
const cands = [d?.pk, d?.id, d?.department_id];
for (const v of cands) {
const s = String(v ?? "").trim();
if (s && DIGITS_RE.test(s)) return s;
}
return null;
}
// YYYY-MM-DD в ЛОКАЛИ (без TZ-сдвига)
function ymdLocal(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
// Базовый URL событий: для отдела добавляем department_id, для личного - employee_id
const eventsUrl = (deptId = null, employeeId = null) => {
const u = new URL(API_EVENTS, location.origin);
if (employeeId != null) {
u.searchParams.set("employee_id", String(employeeId));
} else if (deptId != null) {
u.searchParams.set("department_id", String(deptId));
}
return u.pathname + (u.search ? "?" + u.searchParams.toString() : "");
};
// Корректно вешаем диапазон дат на любой URL
function addRange(url, start, end) {
const u = new URL(url, location.origin);
u.searchParams.set("start", ymdLocal(start));
u.searchParams.set("end", ymdLocal(end));
return u.pathname + "?" + u.searchParams.toString();
}
// Обратная совместимость: 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;
/* ===== Utils ===== */
const isDateOnly = (v) =>
typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v);
const toDate = (v) => {
if (!v) return null;
if (v instanceof Date) return v;
if (typeof v === "number") return new Date(v);
if (typeof v === "string") {
const s = v.trim();
if (isDateOnly(s)) {
const [y, m, d] = s.split("-").map(Number);
return new Date(y, m - 1, d);
}
const t = Date.parse(s);
if (!isNaN(t)) return new Date(t);
}
return null;
};
const pick = (o, ks) => {
for (const k of ks) if (o && o[k] != null && o[k] !== "") return o[k];
return null;
};
const startOfWeek = (d) => {
const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const day = (x.getDay() + 6) % 7;
x.setDate(x.getDate() - day);
x.setHours(0, 0, 0, 0);
return x;
};
const endOfWeek = (d) => {
const s = startOfWeek(d);
const e = new Date(s);
e.setDate(s.getDate() + 7);
return e;
};
const overlaps = (ev, ws, we) => ev.start < we && ev.end > ws;
const truncate = (s, n = 20) => {
const t = (s ?? "").toString();
return t.length > n ? t.slice(0, n - 1) + "…" : t;
};
async function fetchJSON(url, opts = {}) {
const headers = {
Accept: "application/json",
...authHeaders(),
...(opts.headers || {}),
};
const r = await fetch(url, { headers, ...opts });
if (r.status === 401) {
console.warn("401 от API — нужен валидный access токен");
return [];
}
if (!r.ok) {
let text = await r.text();
try {
text = JSON.parse(text);
} catch {}
throw { status: r.status, data: text };
}
const data = await r.json();
return Array.isArray(data)
? data
: data.results || data.items || data.events || [];
}
function setWeekdaysFromMask(mask) {
try {
const m = Number(mask) || 0;
for (let i = 0; i <= 6; i++) {
const el = document.getElementById("wd" + i);
if (el) el.checked = !!(m & (1 << i));
}
} catch (_) {}
}
/* ===== Helpers для форматирования и API детальной карточки ===== */
function pad(n) {
return (n < 10 ? "0" : "") + n;
}
function fmtDate(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function fmtTime(d) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fmtWhen(ev) {
if (!ev) return "—";
const allDay = !!ev.all_day;
// Аккуратно парсим дату/дату-время (без UTC-сдвигов на простых датах)
const sd = toDate(ev.start || ev.start_date);
const ed = ev.end || ev.end_date ? toDate(ev.end || ev.end_date) : null;
if (allDay) {
if (ed && fmtDate(sd) !== fmtDate(ed))
return `${fmtDate(sd)} — ${fmtDate(ed)} (весь день)`;
return `${fmtDate(sd)} (весь день)`;
}
if (ed)
return `${fmtDate(sd)} ${fmtTime(sd)} — ${fmtDate(ed)} ${fmtTime(ed)}`;
return `${fmtDate(sd)} ${fmtTime(sd)}`;
}
async function apiGet(url) {
const r = await fetch(url, {
headers: { Accept: "application/json", ...authHeaders() },
});
const data = await r.json().catch(() => null);
if (!r.ok) {
const err = new Error("GET failed");
err.status = r.status;
err.data = data;
throw err;
}
return data;
}
async function apiDelete(url) {
const r = await fetch(url, {
method: "DELETE",
headers: { ...authHeaders() },
});
if (!r.ok) {
const data = await r.json().catch(() => null);
const err = new Error("DELETE failed");
err.status = r.status;
err.data = data;
throw err;
}
}
// Узлы модала (если разметка подключена)
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("company", "Компания (общие события)", true, "#0d6efd"),
);
container.appendChild(
createCheckbox("personal", "Личный календарь", false, "#198754"),
);
// Добавляем отделы, если есть
if (window.departments && Array.isArray(window.departments)) {
window.departments.forEach((dept) => {
container.appendChild(
createCheckbox(
`dept-${dept.id}`,
`Отдел: ${dept.name}`,
false,
"#dc3545",
),
);
});
}
// Добавляем разделитель, если есть новые календари
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 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 (info.event.extendedProps?.recurrence === "annual") {
eventEl.style.backgroundImage = `repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(255,255,255,0.1) 10px,
rgba(255,255,255,0.1) 20px
)`;
}
// Стилизация текста события
const t = info.el.querySelector(".fc-event-main, .fc-event-title");
if (t) {
const s = t.style;
s.fontSize = "11px";
s.lineHeight = "1.3";
s.letterSpacing = "0";
s.display = "-webkit-box";
s.webkitLineClamp = "3";
s.webkitBoxOrient = "vertical";
s.overflow = "hidden";
s.whiteSpace = "normal";
s.textOverflow = "ellipsis";
s.fontWeight = "500";
s.fontFamily =
'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
s.padding = "2px 0";
}
// Добавляем обработчик правой кнопки мыши
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];
// Определяем тип календаря и добавляем соответствующие параметры
if (targetCalendar !== "company") {
if (targetCalendar === "personal") {
// Личный календарь - получаем employee_id
const userMeta = document.querySelector('meta[name="user-id"]');
const currentEmployeeId = userMeta
? parseInt(userMeta.content, 10)
: null;
if (!currentEmployeeId) {
alert("Не удалось определить ID пользователя");
return;
}
payload.employee_id = currentEmployeeId;
// Очищаем другие поля
payload.department_id = null;
payload.calendar_id = null;
} else if (targetCalendar.startsWith("dept-")) {
// Календарь отдела
const deptId = parseInt(targetCalendar.replace("dept-", ""), 10);
if (isNaN(deptId)) {
alert("Некорректный отдел");
return;
}
payload.department_id = deptId;
// Очищаем другие поля
payload.employee_id = null;
payload.calendar_id = null;
} else if (/^\d+$/.test(targetCalendar)) {
// Новый календарь - используем calendar_id
payload.calendar_id = parseInt(targetCalendar, 10);
// Очищаем другие поля
payload.employee_id = null;
payload.department_id = null;
}
} else {
// Company - очищаем все специфичные поля
payload.employee_id = null;
payload.department_id = null;
payload.calendar_id = null;
}
const url = API_EVENTS + String(form.dataset.eventId) + "/";
await fetchJSON(url, {
method: "PATCH",
headers: postHeaders,
body: JSON.stringify(payload),
});
} else {
// При создании - создаем событие для каждого выбранного календаря
const createPromises = selectedCalendars.map((targetCalendar) => {
const eventPayload = { ...payload };
// Определяем тип календаря и добавляем соответствующие параметры
if (targetCalendar !== "company") {
if (targetCalendar === "personal") {
// Личный календарь
const userMeta = document.querySelector('meta[name="user-id"]');
const currentEmployeeId = userMeta
? parseInt(userMeta.content, 10)
: null;
if (!currentEmployeeId) {
throw new Error("Не удалось определить ID пользователя");
}
eventPayload.employee_id = currentEmployeeId;
} else if (targetCalendar.startsWith("dept-")) {
// Календарь отдела
const deptId = parseInt(targetCalendar.replace("dept-", ""), 10);
if (isNaN(deptId)) {
throw new Error("Некорректный отдел");
}
eventPayload.department_id = deptId;
} else if (/^\d+$/.test(targetCalendar)) {
// Новый календарь - используем calendar_id
eventPayload.calendar_id = parseInt(targetCalendar, 10);
}
}
// Если targetCalendar === "company", то без дополнительных параметров
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);
}
});
}