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