/** * 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'); // Вынесем модал в , иначе позиционирование ломается, если родитель с 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 = ''; menu.appendChild(li); })(); // Вставка пунктов const frag = document.createDocumentFragment(); departments.forEach((d) => { const li = document.createElement('li'); li.innerHTML = ``; 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 = `
${dow}
${md}
`; 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); } }); }