Corp/backend/static/js/components/calendarWidget.js
Игорь Надеин 54a3e46b3d fix: prevent calendar context menu from going off-screen
- Added smart positioning logic to keep menu within viewport bounds
- Menu now adjusts horizontal position if it would overflow right edge
- Menu moves above cursor if it would overflow bottom edge
- Changed position from absolute to fixed for consistent behavior
- Added 10px safety margins from all edges
- Menu is temporarily hidden during position calculation for smooth appearance
2025-12-05 17:01:33 +03:00

1368 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* calendarWidget.js
* Модуль управления календарём событий с FullCalendar
* Поддерживает: компания + отделы пользователя, повторяющиеся события, недельный список
*
* @module calendarWidget
* @version 2.0.0
*/
import { getCalendarEvents, invalidateCalendarEvents } from '../api/calendarApi.js';
import { getMyDepartments } from '../api/departmentsApi.js';
/**
* Инициализация виджета календаря
* @param {Object} options - Параметры инициализации
* @param {string} options.deskContainerId - ID контейнера десктопного календаря (по умолчанию 'calendarRight')
* @param {string} options.mobContainerId - ID контейнера мобильного календаря (по умолчанию 'calendarRightMobile')
* @param {string} options.apiEventsUrl - URL API событий (по умолчанию '/api/v1/calendar/events/')
* @param {string} options.apiMyDeptsUrl - URL API отделов пользователя (по умолчанию '/api/v1/departments/my-departments/')
* @param {string} options.defaultColor - Цвет событий по умолчанию (по умолчанию '#0d6efd')
* @returns {Object} API виджета { updateWeekLists, refetchEvents, getDepartments }
*/
export function initCalendarWidget(options = {}) {
if (!window.FullCalendar) {
return null; // Тихий выход, если FullCalendar не загружен
}
// Инициализация цветовой палитры
initColorPicker();
/* ===== Конфигурация ===== */
const config = {
deskContainerId: options.deskContainerId || 'calendarRight',
mobContainerId: options.mobContainerId || 'calendarRightMobile',
apiEventsUrl: options.apiEventsUrl || '/api/v1/calendar/events/',
apiMyDeptsUrl: options.apiMyDeptsUrl || '/api/v1/departments/my-departments/',
defaultColor: options.defaultColor || '#0d6efd',
};
const API_EVENTS = config.apiEventsUrl;
const API_MY_DEPTS = config.apiMyDeptsUrl;
const DEFAULT_EVENT_COLOR = config.defaultColor;
const holder = document.querySelector('.rightbar-card') || document.querySelector('#rightbarOffcanvas');
/* ===== Регулярки и константы ===== */
const DIGITS_RE = /^\d+$/;
const dayMs = 86400000;
const hourMs = 3600000;
/* ===== Токен из meta ===== */
function getAccessToken() {
const meta = document.querySelector('meta[name="api-access"]');
const m = (meta && meta.getAttribute('content')) || '';
if (m) return m.trim();
try {
return localStorage.getItem('api.access') || '';
} catch (_) {
return '';
}
}
// Сохраняем токен глобально при загрузке, чтобы избежать timing issues
const globalToken = getAccessToken();
function authHeaders() {
const headers = {};
if (globalToken) {
headers.Authorization = 'Bearer ' + globalToken;
}
// Добавляем CSRF токен для POST/PUT/PATCH/DELETE запросов
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value ||
document.querySelector('meta[name="csrf-token"]')?.content ||
getCookie('csrftoken');
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
return headers;
}
// Функция для получения cookie
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
/* ===== Только ЧИСЛОВОЙ PK отдела ===== */
function extractNumericPk(d) {
const cands = [d?.pk, d?.id, d?.department_id];
for (const v of cands) {
const s = String(v ?? '').trim();
if (s && DIGITS_RE.test(s)) return s;
}
return null;
}
// YYYY-MM-DD в ЛОКАЛИ (без TZ-сдвига)
function ymdLocal(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// Базовый URL событий: для отдела добавляем department_id, для личного - employee_id
const eventsUrl = (deptId = null, employeeId = null) => {
const u = new URL(API_EVENTS, location.origin);
if (employeeId != null) {
u.searchParams.set('employee_id', String(employeeId));
} else if (deptId != null) {
u.searchParams.set('department_id', String(deptId));
}
return u.pathname + (u.search ? '?' + u.searchParams.toString() : '');
};
// Корректно вешаем диапазон дат на любой URL
function addRange(url, start, end) {
const u = new URL(url, location.origin);
u.searchParams.set('start', ymdLocal(start));
u.searchParams.set('end', ymdLocal(end));
return u.pathname + '?' + u.searchParams.toString();
}
// Обратная совместимость: data-dept-id="1,2,3"
const legacyDeptIds = (holder?.dataset.deptId || '')
.split(',')
.map((s) => s.trim())
.filter((s) => DIGITS_RE.test(s));
/* ===== Элементы UI ===== */
const chooserBtn = document.getElementById('calendarChooserBtn');
const chooserMenu = document.getElementById('calendarChooserMenu');
const chooserBtnMobile = document.getElementById('calendarChooserBtnMobile');
const chooserMenuMobile = document.getElementById('calendarChooserMenuMobile');
const eventTargetLabel = document.getElementById('eventTargetLabel');
// Поля модала для переключений
const allDayChk = document.getElementById('allDayChk');
const recurrenceSelect = document.getElementById('recurrenceSelect');
const weeklyBlock = document.getElementById('weeklyBlock');
const recurrenceInterval = document.getElementById('recurrenceInterval');
const recurrenceUntil = document.getElementById('recurrenceUntil');
const recurrenceCount = document.getElementById('recurrenceCount');
/* ===== Состояние ===== */
let departments = []; // [{id, name}] где id — всегда числовой PK
const state = { type: 'company', deptId: null, employeeId: null };
// Защита от гонок при загрузке отделов
let deptsLoadSeq = 0;
/* ===== Utils ===== */
const isDateOnly = (v) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
const toDate = (v) => {
if (!v) return null;
if (v instanceof Date) return v;
if (typeof v === 'number') return new Date(v);
if (typeof v === 'string') {
const s = v.trim();
if (isDateOnly(s)) {
const [y, m, d] = s.split('-').map(Number);
return new Date(y, m - 1, d);
}
const t = Date.parse(s);
if (!isNaN(t)) return new Date(t);
}
return null;
};
const pick = (o, ks) => {
for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k];
return null;
};
const startOfWeek = (d) => {
const x = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const day = (x.getDay() + 6) % 7;
x.setDate(x.getDate() - day);
x.setHours(0, 0, 0, 0);
return x;
};
const endOfWeek = (d) => {
const s = startOfWeek(d);
const e = new Date(s);
e.setDate(s.getDate() + 7);
return e;
};
const overlaps = (ev, ws, we) => ev.start < we && ev.end > ws;
const truncate = (s, n = 20) => {
const t = (s ?? '').toString();
return t.length > n ? t.slice(0, n - 1) + '…' : t;
};
async function fetchJSON(url, opts = {}) {
const headers = { Accept: 'application/json', ...authHeaders(), ...(opts.headers || {}) };
const r = await fetch(url, { headers, ...opts });
if (r.status === 401) {
console.warn('401 от API — нужен валидный access токен');
return [];
}
if (!r.ok) {
let text = await r.text();
try {
text = JSON.parse(text);
} catch {}
throw { status: r.status, data: text };
}
const data = await r.json();
return Array.isArray(data) ? data : data.results || data.items || data.events || [];
}
function setWeekdaysFromMask(mask) {
try {
const m = Number(mask) || 0;
for (let i = 0; i <= 6; i++) {
const el = document.getElementById('wd' + i);
if (el) el.checked = !!(m & (1 << i));
}
} catch (_) {}
}
/* ===== Helpers для форматирования и API детальной карточки ===== */
function pad(n) {
return (n < 10 ? '0' : '') + n;
}
function fmtDate(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function fmtTime(d) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fmtWhen(ev) {
if (!ev) return '—';
const allDay = !!ev.all_day;
// Аккуратно парсим дату/дату-время (без UTC-сдвигов на простых датах)
const sd = toDate(ev.start || ev.start_date);
const ed = ev.end || ev.end_date ? toDate(ev.end || ev.end_date) : null;
if (allDay) {
if (ed && fmtDate(sd) !== fmtDate(ed)) return `${fmtDate(sd)} — ${fmtDate(ed)} (весь день)`;
return `${fmtDate(sd)} (весь день)`;
}
if (ed) return `${fmtDate(sd)} ${fmtTime(sd)} — ${fmtDate(ed)} ${fmtTime(ed)}`;
return `${fmtDate(sd)} ${fmtTime(sd)}`;
}
async function apiGet(url) {
const r = await fetch(url, { headers: { Accept: 'application/json', ...authHeaders() } });
const data = await r.json().catch(() => null);
if (!r.ok) {
const err = new Error('GET failed');
err.status = r.status;
err.data = data;
throw err;
}
return data;
}
async function apiDelete(url) {
const r = await fetch(url, { method: 'DELETE', headers: { ...authHeaders() } });
if (!r.ok) {
const data = await r.json().catch(() => null);
const err = new Error('DELETE failed');
err.status = r.status;
err.data = data;
throw err;
}
}
// Узлы модала (если разметка подключена)
const detailsModalEl = document.getElementById('eventDetailsModal');
// Вынесем модал в <body>, иначе позиционирование ломается, если родитель с transform
if (detailsModalEl && detailsModalEl.parentElement !== document.body) {
document.body.appendChild(detailsModalEl);
}
// Гарантируем вертикальное центрирование (если в HTML забыли класс)
detailsModalEl?.querySelector('.modal-dialog')?.classList.add('modal-dialog-centered');
const detailsModal = detailsModalEl ? new bootstrap.Modal(detailsModalEl) : null;
const $dt = {
title: document.getElementById('detailTitle'),
when: document.getElementById('detailWhen'),
loc: document.getElementById('detailLocation'),
desc: document.getElementById('detailDescription'),
scope: document.getElementById('detailScope'),
rec: document.getElementById('detailRecurrence'),
dot: document.getElementById('detailColorDot'),
btnEdit: document.getElementById('btnEditEvent'),
btnDel: document.getElementById('btnDeleteEvent'),
};
let currentDetail = null;
function fillDetails(ev) {
if (!$dt.title) return; // Модала нет — тихо выходим
$dt.title.textContent = ev.title || 'Событие';
$dt.when.textContent = fmtWhen(ev);
$dt.loc.textContent = ev.location || '—';
$dt.desc.textContent = ev.description || '—';
$dt.scope.textContent = ev.department
? `Отдел: ${ev.department.name || ev.department}`
: 'Компания';
$dt.rec.textContent = ev.recurrence_interval
? `${ev.recurrence} ×${ev.recurrence_interval}`
: ev.recurrence || '—';
const col = ev.color || DEFAULT_EVENT_COLOR;
if ($dt.dot) $dt.dot.style.backgroundColor = col;
}
async function openEventDetailsById(eventId) {
console.log('[CalendarWidget] openEventDetailsById called with:', eventId);
try {
const url = `${API_EVENTS}${eventId}/`;
console.log('[CalendarWidget] Fetching event details from:', url);
const data = await apiGet(url);
console.log('[CalendarWidget] Event details loaded:', data);
currentDetail = data;
fillDetails(data);
if (detailsModal) {
console.log('[CalendarWidget] Showing details modal');
detailsModal.show();
} else {
console.error('[CalendarWidget] detailsModal is not initialized!');
}
} catch (err) {
console.error('[CalendarWidget] Load details error:', err);
if (err.status === 404) {
alert('Событие не найдено. Возможно, оно было удалено.');
// Очистить параметр event_id из URL, если он есть
const url = new URL(window.location);
if (url.searchParams.has('event_id')) {
url.searchParams.delete('event_id');
window.history.replaceState({}, '', url);
}
} else if (err.status === 403) {
alert('Недостаточно прав для просмотра деталей события.');
} else {
alert('Не удалось загрузить событие.');
}
}
}
/* ===== Загрузка отделов (только числовой PK) ===== */
async function loadDepartments() {
const mySeq = ++deptsLoadSeq; // Метка версии вызова
let raw = [];
try {
// Используем кешированный API вместо прямого fetch
raw = await getMyDepartments();
} catch (e) {
console.error('[CalendarWidget] Failed to load departments:', e);
raw = [];
}
// Маппинг → {id, name}
let mapped = (Array.isArray(raw) ? raw : [])
.map((d) => {
const pk = extractNumericPk(d);
if (!pk) return null;
const name = d.name ?? d.title ?? d.code ?? `Отдел #${pk}`;
return { id: String(pk), name };
})
.filter(Boolean);
// Если API вернул пусто — fallback к data-dept-id
if (!mapped.length && legacyDeptIds.length) {
mapped = legacyDeptIds.map((pk) => ({ id: String(pk), name: `Отдел #${pk}` }));
}
// ✅ Дедуп по id
const uniq = new Map();
for (const d of mapped) {
const key = String(d.id);
if (!uniq.has(key)) uniq.set(key, d);
}
const nextDepartments = Array.from(uniq.values());
// Если наш вызов устарел (есть более поздний) — не трогаем DOM
if (mySeq !== deptsLoadSeq) return departments;
departments = nextDepartments;
// Очистка пунктов отделов в обоих dropdown (десктоп и мобильный)
[chooserMenu, chooserMenuMobile].forEach((menu) => {
if (menu) menu.querySelectorAll('[data-cal="dept"]').forEach((n) => n.closest('li')?.remove());
});
if (!departments.length) {
setChooserLabel();
return departments;
}
// Заполнение dropdown отделами (десктоп и мобильный)
[chooserMenu, chooserMenuMobile].forEach((menu) => {
if (!menu) return;
// Убедимся, что есть разделитель
menu.querySelector('.dropdown-divider') ||
(() => {
const li = document.createElement('li');
li.innerHTML = '<hr class="dropdown-divider">';
menu.appendChild(li);
})();
// Вставка пунктов
const frag = document.createDocumentFragment();
departments.forEach((d) => {
const li = document.createElement('li');
li.innerHTML = `<button class="dropdown-item" type="button" data-cal="dept" data-id="${d.id}">${d.name}</button>`;
frag.appendChild(li);
});
menu.appendChild(frag);
});
// Валидация текущего выбора
if (state.type === 'dept' && !departments.some((d) => String(d.id) === String(state.deptId))) {
state.type = 'company';
state.deptId = null;
}
setChooserLabel();
return departments;
}
function currentDeptLabel() {
if (state.type === 'personal') return 'Личный';
if (state.type !== 'dept') return 'Компания';
const d = departments.find((x) => String(x.id) === String(state.deptId));
return d?.name || `Отдел #${state.deptId}`;
}
function setChooserLabel() {
let label = 'Компания';
if (state.type === 'personal') {
label = 'Личный';
} else if (state.type === 'dept') {
label = currentDeptLabel();
}
if (chooserBtn) chooserBtn.textContent = label;
if (chooserBtnMobile) chooserBtnMobile.textContent = label;
if (eventTargetLabel) eventTargetLabel.textContent = label;
}
/* ===== Обработчик выбора из дропдауна (десктоп и мобильный) ===== */
function handleChooserClick(e) {
const btn = e.target.closest('[data-cal]');
if (!btn) return;
const type = btn.dataset.cal;
if (type === 'company') {
state.type = 'company';
state.deptId = null;
state.employeeId = null;
} else if (type === 'personal') {
state.type = 'personal';
state.deptId = null;
// Получаем employee_id из meta-тега или атрибута
const userMeta = document.querySelector('meta[name="user-id"]');
state.employeeId = userMeta ? userMeta.content : null;
if (!state.employeeId) {
alert('Не удалось определить ID пользователя');
state.type = 'company';
return;
}
} else {
const id = btn.dataset.id;
if (!DIGITS_RE.test(String(id || ''))) {
alert('Некорректный идентификатор отдела');
return;
}
state.type = 'dept';
state.deptId = id;
state.employeeId = null;
}
setChooserLabel();
[deskCalendar, mobCalendar].forEach((cal) => cal?.refetchEvents());
updateWeekLists();
}
if (chooserMenu) chooserMenu.addEventListener('click', handleChooserClick);
if (chooserMenuMobile) chooserMenuMobile.addEventListener('click', handleChooserClick);
/* ===== Комбинированная загрузка событий (occurrences) ===== */
// События для текущего контекста (используется календарём)
async function fetchEventsCombined(start, end) {
try {
const params = {
start: ymdLocal(start),
end: ymdLocal(end)
};
// Добавляем employee_id для личного календаря
if (state.type === 'personal' && state.employeeId) {
params.employee_id = state.employeeId;
}
// Добавляем department_id для отдела
else if (state.type === 'dept' && state.deptId) {
params.department_id = state.deptId;
}
// Используем кешированный API
return await getCalendarEvents(params);
} catch (e) {
console.error('[CalendarWidget] Fetch events failed', e);
return [];
}
}
// ✅ ВСЕ доступные календари: компания + все отделы пользователя + личный
async function fetchEventsAllCalendars(start, end) {
// При первом вызове убедимся, что отделы загружены
if (!departments || departments.length === 0) {
try {
await loadDepartments();
} catch (_) {}
}
const startStr = ymdLocal(start);
const endStr = ymdLocal(end);
// Получаем employee_id текущего пользователя
const userMeta = document.querySelector('meta[name="user-id"]');
const currentEmployeeId = userMeta ? userMeta.content : null;
// Формируем список источников с подписью
const sources = [
{ params: { start: startStr, end: endStr }, label: 'Компания', type: 'company', id: null },
...departments.map((d) => ({
params: { start: startStr, end: endStr, department_id: d.id },
label: d.name,
type: 'dept',
id: d.id,
})),
];
// Добавляем личный календарь, если есть employee_id
if (currentEmployeeId) {
sources.push({
params: { start: startStr, end: endStr, employee_id: currentEmployeeId },
label: 'Личный',
type: 'personal',
id: currentEmployeeId,
});
}
// Загружаем все источники параллельно (с кешированием!)
const chunks = await Promise.all(
sources.map((s) =>
getCalendarEvents(s.params)
.then((arr) =>
(arr || []).map((ev) => ({
...ev,
__source: { label: s.label, type: s.type, id: s.id },
}))
)
.catch((err) => {
console.error(`[CalendarWidget] Failed to load events for ${s.label}:`, err);
return [];
})
)
);
const merged = chunks.flat();
// Дедуп по устойчивому ключу
const seen = new Set();
const out = [];
for (const ev of merged) {
const key = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id ?? null;
const sk = key != null ? String(key) : null;
if (!sk || !seen.has(sk)) {
if (sk) seen.add(sk);
out.push(ev);
}
}
return out;
}
function normalizeEvent(ev) {
const id = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id;
const title = pick(ev, ['title', 'name', 'summary', 'text']) || '';
const rs = pick(ev, ['start', 'start_date', 'date_start', 'date', 'date_from']);
const re = pick(ev, ['end', 'end_date', 'date_end', 'date_to']);
const allDay =
'allDay' in ev
? !!ev.allDay
: 'all_day' in ev
? !!ev.all_day
: isDateOnly(rs) && (!re || isDateOnly(re));
const s = toDate(rs);
let e = toDate(re);
if (!s) return null;
if (!e) e = new Date(s.getTime() + (allDay ? dayMs : hourMs));
const color = ev.color || ev.bgColor || null;
const recurrence = ev.recurrence || ev.recurrence_display || null;
const location = ev.location || ev.place || '';
const description = ev.description || ev.details || ev.note || '';
const sourceLabel = ev.__source?.label || (ev.department?.name ?? ev.department) || 'Компания';
return { id, title, start: s, end: e, allDay, color, recurrence, location, description, sourceLabel };
}
function renderVertical(container, rangeLabel, events, ws, we) {
if (!container) return;
container.innerHTML = '';
if (rangeLabel) {
const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'long' });
rangeLabel.textContent = `${df.format(ws)} — ${df.format(new Date(we.getTime() - dayMs))}`;
}
const dfDow = new Intl.DateTimeFormat('ru-RU', { weekday: 'short' });
const dfMD = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' });
const list = events
.map(normalizeEvent)
.filter(Boolean)
.filter((ev) => overlaps(ev, ws, we))
.sort((a, b) => a.start - b.start);
if (!list.length) {
const e = document.createElement('div');
e.className = 'week-vertical empty';
e.textContent = 'Нет событий на этой неделе';
container.appendChild(e);
return;
}
list.forEach((ev) => {
const row = document.createElement('div');
row.className = 'week-row';
// Сохраняем ID события для обработчика клика (уже нормализовано в ev.id)
if (ev.id) {
row.dataset.eventId = String(ev.id);
row.style.cursor = 'pointer';
row.addEventListener('click', () => {
console.log('[CalendarWidget] Opening event details:', ev.id);
openEventDetailsById(ev.id);
});
}
// Цветовая точка слева от заголовка
const dot = document.createElement('span');
dot.className = 'color-dot';
dot.style.backgroundColor = ev.color || DEFAULT_EVENT_COLOR;
const badge = document.createElement('div');
badge.className = 'date-badge';
const dow = dfDow.format(ev.start),
md = dfMD.format(ev.start);
badge.innerHTML = `<div class="dow">${dow}</div><div class="md">${md}</div>`;
const cont = document.createElement('div');
cont.className = 'content';
const title = document.createElement('div');
title.className = 'title';
title.textContent = ev.title || 'Без названия';
title.title = ev.title || '';
// Строка с источником, локацией и кратким описанием
const meta = document.createElement('div');
meta.className = 'meta';
const dateSpan =
ev.end - ev.start > dayMs
? `${dfMD.format(ev.start)} — ${dfMD.format(new Date(ev.end - 1))}`
: `${md}`;
const parts = [ev.sourceLabel || '—'];
if (ev.location) parts.push(ev.location);
if (ev.description) parts.push(truncate(ev.description, 20));
meta.textContent = `${dateSpan} • ${parts.join(' • ')}`;
const head = document.createElement('div');
head.style.display = 'flex';
head.style.alignItems = 'center';
head.style.gap = '8px';
head.append(dot, title);
cont.append(head, meta);
row.append(badge, cont);
container.appendChild(row);
});
}
async function updateWeekLists() {
const now = new Date();
const ws = startOfWeek(now),
we = endOfWeek(now);
const from = new Date(ws.getTime() - 3 * dayMs),
to = new Date(we.getTime() + 3 * dayMs);
const items = await fetchEventsAllCalendars(from, to);
renderVertical(
document.getElementById('weekList'),
document.getElementById('weekRange'),
items,
ws,
we
);
renderVertical(
document.getElementById('weekListMobile'),
document.getElementById('weekRangeMobile'),
items,
ws,
we
);
}
/* ===== Контекстное меню для событий ===== */
const contextMenu = document.getElementById('calendarContextMenu');
const contextMenuView = document.getElementById('contextMenuView');
const contextMenuEdit = document.getElementById('contextMenuEdit');
const contextMenuDelete = document.getElementById('contextMenuDelete');
let contextMenuEventId = null;
let contextMenuLongPressTimer = null;
// Функция показа контекстного меню
function showContextMenu(x, y, eventId) {
if (!contextMenu) return;
contextMenuEventId = eventId;
// Проверяем права доступа
checkEventPermissions(eventId).then(perms => {
// Показываем/скрываем кнопки в зависимости от прав
if (contextMenuEdit) {
contextMenuEdit.classList.toggle('d-none', !perms.can_edit);
}
if (contextMenuDelete) {
contextMenuDelete.classList.toggle('d-none', !perms.can_delete);
}
// Скрываем разделитель если нет прав на удаление
const divider = contextMenu.querySelector('.dropdown-divider[data-requires-permission="delete"]');
if (divider) {
divider.classList.toggle('d-none', !perms.can_delete);
}
// Сначала показываем меню для получения его размеров
contextMenu.style.display = 'block';
contextMenu.style.visibility = 'hidden';
// Получаем размеры меню и окна
const menuRect = contextMenu.getBoundingClientRect();
const menuWidth = menuRect.width;
const menuHeight = menuRect.height;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
// Корректируем позицию по горизонтали
let finalX = x;
if (x + menuWidth > windowWidth) {
// Меню выходит за правый край - сдвигаем влево
finalX = windowWidth - menuWidth - 10; // 10px отступ от края
}
if (finalX < 10) {
finalX = 10; // Минимальный отступ слева
}
// Корректируем позицию по вертикали
let finalY = y;
if (y + menuHeight > windowHeight) {
// Меню выходит за нижний край - показываем выше курсора
finalY = y - menuHeight;
// Если и сверху не помещается, прижимаем к нижнему краю
if (finalY < 10) {
finalY = windowHeight - menuHeight - 10;
}
}
if (finalY < 10) {
finalY = 10; // Минимальный отступ сверху
}
// Применяем скорректированную позицию
contextMenu.style.left = finalX + 'px';
contextMenu.style.top = finalY + 'px';
contextMenu.style.visibility = 'visible';
});
}
// Функция скрытия контекстного меню
function hideContextMenu() {
if (contextMenu) {
contextMenu.style.display = 'none';
}
contextMenuEventId = null;
}
// Проверка прав на событие
async function checkEventPermissions(eventId) {
try {
const response = await fetch(`${API_EVENTS}${eventId}/permissions/`, {
headers: authHeaders()
});
if (response.ok) {
return await response.json();
}
} catch (e) {
console.error('Failed to check permissions:', e);
}
return { can_view: true, can_edit: false, can_delete: false };
}
// Обработчики кнопок контекстного меню
if (contextMenuView) {
contextMenuView.addEventListener('click', () => {
if (contextMenuEventId) {
openEventDetailsById(contextMenuEventId);
}
hideContextMenu();
});
}
if (contextMenuEdit) {
contextMenuEdit.addEventListener('click', async () => {
if (contextMenuEventId) {
try {
const data = await apiGet(`${API_EVENTS}${contextMenuEventId}/`);
currentDetail = data;
// Переиспользуем логику редактирования из старой кнопки "Редактировать"
editEvent(data);
} catch (err) {
console.error('Load event for edit error', err);
alert('Не удалось загрузить событие для редактирования.');
}
}
hideContextMenu();
});
}
if (contextMenuDelete) {
contextMenuDelete.addEventListener('click', async () => {
if (contextMenuEventId) {
if (!confirm('Удалить это событие без возможности восстановления?')) {
hideContextMenu();
return;
}
try {
await apiDelete(`${API_EVENTS}${contextMenuEventId}/`);
invalidateCalendarEvents();
try {
deskCalendar?.refetchEvents?.();
mobCalendar?.refetchEvents?.();
} catch (_) {}
updateWeekLists();
} catch (err) {
console.error('Delete event error', err);
alert(
err.status === 403 ? 'Недостаточно прав для удаления.' : 'Не удалось удалить событие.'
);
}
}
hideContextMenu();
});
}
// Закрытие контекстного меню при клике вне его
document.addEventListener('click', (e) => {
if (contextMenu && !contextMenu.contains(e.target)) {
hideContextMenu();
}
});
// Функция редактирования события (вынесена из обработчика кнопки)
function editEvent(data) {
const form = document.getElementById('eventForm');
if (!form) return;
form.dataset.mode = 'edit';
form.dataset.eventId = data.id;
// Базовые поля
form.querySelector('[name="title"]').value = data.title || '';
form.querySelector('[name="location"]').value = data.location || '';
form.querySelector('[name="color"]').value = data.color || DEFAULT_EVENT_COLOR;
form.querySelector('[name="all_day"]').checked = !!data.all_day;
// Описание
const descEl = form.querySelector('[name="description"]');
if (descEl) descEl.value = data.description || '';
// Дата/время
const startIso =
data.start ||
(data.start_date
? `${data.start_date}T${(data.start_time || '00:00').slice(0, 5)}`
: '');
const endIso =
data.end ||
(data.end_date
? `${data.end_date}T${(data.end_time || '00:00').slice(0, 5)}`
: '');
const startEl = form.querySelector('[name="start"]');
const endEl = form.querySelector('[name="end"]');
if (startEl) startEl.value = (startIso || '').slice(0, 16);
if (endEl) endEl.value = (endIso || '').slice(0, 16);
// Повторы
const recSel = form.querySelector('[name="recurrence"]');
if (recSel) recSel.value = data.recurrence || 'one_time';
const recInt = form.querySelector('[name="recurrence_interval"]');
if (recInt) recInt.value = data.recurrence_interval || 1;
// Until / count
const untilEl = form.querySelector('[name="recurrence_until"]');
if (untilEl) untilEl.value = (data.recurrence_until || '').slice(0, 10);
const countEl = form.querySelector('[name="recurrence_count"]');
if (countEl) countEl.value = data.recurrence_count ?? '';
// Weekly дни из маски
if (data.weekdays_mask != null) setWeekdaysFromMask(data.weekdays_mask);
// Синхронизация UI
try {
typeof syncByRecurrence === 'function' && syncByRecurrence();
} catch (_) {}
// Отдел (если есть select)
const deptSel = form.querySelector('[name="department_id"]');
if (deptSel && data.department) {
const val = data.department.id ?? data.department;
if (val != null) deptSel.value = String(val);
}
// Открываем форму редактирования
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();
}
/* ===== Обработчики событий календаря ===== */
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, // Позволяет выбирать даты
events: async (info, success, failure) => {
try {
const raw = await fetchEventsCombined(info.start, info.end);
// Нормализуем id и цвет для FullCalendar
const mapped = (Array.isArray(raw) ? raw : []).map((ev) => {
const id = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id;
const color = ev.color || ev.bgColor || null;
return {
...ev,
...(id != null ? { id } : {}),
...(color ? { backgroundColor: color, borderColor: color } : {}),
};
});
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 col = info.event.backgroundColor || info.event.extendedProps?.color;
if (col) {
info.el.style.backgroundColor = col;
info.el.style.borderColor = col;
info.el.style.color = 'var(--bs-body-bg)';
}
if (info.event.extendedProps?.recurrence === 'annual') {
info.el.style.outline = '1px dashed currentColor';
info.el.style.outlineOffset = '-2px';
}
const t = info.el.querySelector('.fc-event-main, .fc-event-title');
if (t) {
const s = t.style;
s.fontSize = '10px';
s.lineHeight = '1.02';
s.letterSpacing = '-0.2px';
s.display = '-webkit-box';
s.webkitLineClamp = '3';
s.webkitBoxOrient = 'vertical';
s.overflow = 'hidden';
s.whiteSpace = 'normal';
s.textOverflow = 'ellipsis';
s.fontWeight = '600';
s.fontFamily =
'"Arial Narrow","Roboto Condensed",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif';
}
// Добавляем обработчик правой кнопки мыши
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 (_) {}
// Открываем модал
const createModalEl = document.getElementById('eventCreateModal');
if (createModalEl) {
const createModal = bootstrap.Modal.getOrCreateInstance(createModalEl);
createModal.show();
}
},
};
let deskCalendar = null,
mobCalendar = null;
const desk = document.getElementById(config.deskContainerId);
if (desk) {
deskCalendar = new FullCalendar.Calendar(desk, fcOpts);
deskCalendar.on('loading', (l) => {
if (!l) updateWeekLists();
});
deskCalendar.on('eventClick', (info) => {
info.jsEvent?.preventDefault?.();
const eid = info.event.id || info.event.extendedProps?.id;
if (eid) openEventDetailsById(eid);
});
deskCalendar.render();
}
const mob = document.getElementById(config.mobContainerId);
if (mob) {
mobCalendar = new FullCalendar.Calendar(mob, fcOpts);
mobCalendar.on('loading', (l) => {
if (!l) updateWeekLists();
});
mobCalendar.on('eventClick', (info) => {
info.jsEvent?.preventDefault?.();
const eid = info.event.id || info.event.extendedProps?.id;
if (eid) openEventDetailsById(eid);
});
mobCalendar.render();
}
// Загрузим отделы и обновим подпись селектора
loadDepartments().then(() => setChooserLabel());
setTimeout(() => updateWeekLists(), 100);
[deskCalendar, mobCalendar].forEach((cal) => cal?.on('datesSet', () => updateWeekLists()));
/* ===== UX: переключалки формы ===== */
function syncByRecurrence() {
const r = recurrenceSelect?.value || 'one_time';
// Weekly block
if (r === 'weekly') {
weeklyBlock?.classList.remove('d-none');
} else {
weeklyBlock?.classList.add('d-none');
}
// Hourly требует времени → снимаем all-day и блокируем чекбокс
if (r === 'hourly') {
allDayChk.checked = false;
allDayChk.disabled = true;
} else {
allDayChk.disabled = false;
}
}
function syncByAllDay() {
const isAllDay = allDayChk?.checked;
const startEl = document.querySelector('input[name="start"]');
const endEl = document.querySelector('input[name="end"]');
if (!startEl || !endEl) return;
// Если all-day — время можно не указывать, но поля не блокируем
}
function syncUntilCount() {
const hasUntil = !!recurrenceUntil?.value;
if (hasUntil) {
recurrenceCount.value = '';
recurrenceCount.disabled = true;
} else {
recurrenceCount.disabled = false;
}
}
recurrenceSelect?.addEventListener('change', syncByRecurrence);
allDayChk?.addEventListener('change', syncByAllDay);
recurrenceUntil?.addEventListener('change', syncUntilCount);
syncByRecurrence();
syncByAllDay();
syncUntilCount();
/* ===== Цвет: палитра и синхронизация с input[type=color] ===== */
function initColorPicker() {
const form = document.getElementById('eventForm');
if (!form) return;
const colorInput = form.querySelector('input[name="color"]');
const palette = document.getElementById('colorPalette');
if (!colorInput || !palette) return;
// Значение по умолчанию
if (!colorInput.value) colorInput.value = DEFAULT_EVENT_COLOR;
function highlightActive() {
const val = (colorInput.value || '').toLowerCase();
palette.querySelectorAll('[data-color]').forEach((btn) => {
btn.classList.toggle('active', (btn.dataset.color || '').toLowerCase() === val);
});
}
palette.querySelectorAll('[data-color]').forEach((btn) => {
btn.addEventListener('click', () => {
colorInput.value = btn.dataset.color || DEFAULT_EVENT_COLOR;
colorInput.dispatchEvent(new Event('input', { bubbles: true }));
highlightActive();
});
});
colorInput.addEventListener('input', highlightActive);
highlightActive();
}
/* ===== Создание/редактирование события (POST/PATCH только с Bearer) ===== */
const form = document.getElementById('eventForm');
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
const title = (fd.get('title') || '').toString().trim();
let start = fd.get('start')?.toString() || '';
let end = fd.get('end')?.toString() || '';
const allDay = !!fd.get('all_day');
if (!title) {
alert('Укажите заголовок');
return;
}
// Разбираем datetime-local → даты/время
const nowIso = new Date().toISOString().slice(0, 16);
const sDate = (start || nowIso).slice(0, 10);
const eDate = (end && end.trim() ? end : start || '').slice(0, 10) || sDate;
const sTime = start && start.length >= 16 ? start.slice(11, 16) : '';
const eTime = end && end.length >= 16 ? end.slice(11, 16) : '';
// Повторяемость
const recurrence = (fd.get('recurrence') || 'one_time').toString();
const recurrence_interval = Math.max(1, parseInt(fd.get('recurrence_interval') || '1', 10));
const recurrence_until = (fd.get('recurrence_until') || '').toString().trim();
const recurrence_count_raw = (fd.get('recurrence_count') || '').toString().trim();
const recurrence_count = recurrence_count_raw
? Math.max(1, parseInt(recurrence_count_raw, 10))
: null;
// Weekly weekdays → массив чисел
const weekdays = Array.from(document.querySelectorAll('input[name="weekdays"]:checked')).map(
(el) => parseInt(el.value, 10)
);
const colorVal = (fd.get('color') || '').toString().trim() || DEFAULT_EVENT_COLOR;
// Базовый payload
const payload = {
title,
start_date: sDate,
end_date: eDate,
all_day: allDay,
recurrence,
recurrence_interval,
color: colorVal,
location: (fd.get('location') || '').toString().trim() || '',
description: (fd.get('description') || '').toString().trim() || '',
};
// Время (если all_day=false ИЛИ пользователь указал оба времени)
if (!allDay || (sTime && eTime)) {
if (sTime && eTime) {
payload.start_time = sTime; // "HH:MM"
payload.end_time = eTime; // "HH:MM"
payload.all_day = false;
} else if (recurrence === 'hourly') {
alert('Ежечасное событие требует и время начала, и время окончания.');
return;
}
}
// Ограничители серии
if (recurrence_until && recurrence_count) {
alert('Нельзя одновременно задавать "Повторять до" и "Кол-во повторов". Укажите что-то одно.');
return;
}
if (recurrence_until) payload.recurrence_until = recurrence_until;
if (recurrence_count) payload.recurrence_count = recurrence_count;
// Weekly: передаём weekdays если выбраны
if (recurrence === 'weekly' && weekdays.length) {
payload.weekdays = weekdays;
}
// Область — компания/отдел/личный
if (state.type === 'personal') {
if (!state.employeeId) {
alert('Не удалось определить ID пользователя');
return;
}
payload.employee_id = Number(state.employeeId);
} else if (state.type === 'dept') {
if (!DIGITS_RE.test(String(state.deptId))) {
alert('Некорректный отдел');
return;
}
payload.department_id = Number(state.deptId);
}
const postHeaders = { 'Content-Type': 'application/json', ...authHeaders() };
// Проверка токена
if (!globalToken) {
alert('Требуется авторизация. Войдите заново.');
return;
}
try {
const isEdit = form.dataset.mode === 'edit' && form.dataset.eventId;
const url = isEdit ? API_EVENTS + String(form.dataset.eventId) + '/' : API_EVENTS;
const method = isEdit ? 'PATCH' : 'POST';
await fetchJSON(url, {
method,
headers: postHeaders,
body: JSON.stringify(payload),
});
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);
if (err && err.status === 403) {
const where = state?.type === 'dept' ? `отделе «${currentDeptLabel()}»` : 'компании';
alert(
'Недостаточно прав для создания события в ' +
where +
'.\n' +
'Попросите руководителя назначить вам роль «Управлять календарём отдела».'
);
} else {
const detail =
err?.data?.detail || (typeof err?.data === 'string' ? err.data : '');
alert('Не удалось создать событие: ' + (detail || JSON.stringify(err?.data || {})));
}
}
});
// API возвращаем для внешнего использования
return {
updateWeekLists,
refetchEvents: () => {
deskCalendar?.refetchEvents();
mobCalendar?.refetchEvents();
},
getDepartments: () => departments,
getState: () => ({ ...state }),
openEventById: (eventId) => openEventDetailsById(eventId),
};
}
// Автоинициализация при загрузке DOM
if (typeof document !== 'undefined') {
document.addEventListener('DOMContentLoaded', () => {
window.calendarWidget = initCalendarWidget();
// Проверяем URL на наличие параметра event_id для открытия модала
const urlParams = new URLSearchParams(window.location.search);
const eventIdFromUrl = urlParams.get('event_id');
if (eventIdFromUrl && window.calendarWidget) {
// Небольшая задержка, чтобы календарь успел загрузиться
setTimeout(() => {
window.calendarWidget.openEventById(eventIdFromUrl);
}, 500);
}
});
}