Corp/backend/static/js/components/calendarWidget.js

1678 lines
62 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* calendarWidget.js
* Модуль управления календарём событий с FullCalendar
* Поддерживает: компания + отделы пользователя, повторяющиеся события, недельный список
*
* @module calendarWidget
* @version 2.0.0
*/
import {
getCalendarEvents,
invalidateCalendarEvents,
} from "../api/calendarApi.js";
import { getMyDepartments } from "../api/departmentsApi.js";
import {
getAccessToken,
authHeaders as getAuthHeaders,
getCookie,
} from "../utils/authUtils.js";
import { formatDate, ymdLocal, fmtDate, fmtTime } from "../utils/dateUtils.js";
import {
CALENDAR_TYPES,
CALENDAR_COLORS,
createLegacyDeptId,
} from "../constants/calendarTypes.js";
import { API_URLS } from "../constants/apiUrls.js";
import { resolveEventPayload } from "../utils/calendarTypeResolver.js";
import {
extractNumericPk,
eventsUrl,
addRange,
isDateOnly,
toDate,
pick,
startOfWeek,
endOfWeek,
overlaps,
truncate,
setWeekdaysFromMask,
fmtWhen,
DIGITS_RE,
dayMs,
hourMs,
} from "./calendarWidget/helpers.js";
import { fetchJSON, apiGet, apiDelete } from "./calendarWidget/apiClient.js";
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);
}
});
}