diff --git a/backend/static/js/components/calendarWidget.js b/backend/static/js/components/calendarWidget.js index c479f9ec..c1946991 100644 --- a/backend/static/js/components/calendarWidget.js +++ b/backend/static/js/components/calendarWidget.js @@ -2,13 +2,16 @@ * calendarWidget.js * Модуль управления календарём событий с FullCalendar * Поддерживает: компания + отделы пользователя, повторяющиеся события, недельный список - * + * * @module calendarWidget * @version 2.0.0 */ -import { getCalendarEvents, invalidateCalendarEvents } from '../api/calendarApi.js'; -import { getMyDepartments } from '../api/departmentsApi.js'; +import { + getCalendarEvents, + invalidateCalendarEvents, +} from "../api/calendarApi.js"; +import { getMyDepartments } from "../api/departmentsApi.js"; /** * Инициализация виджета календаря @@ -30,17 +33,20 @@ export function initCalendarWidget(options = {}) { /* ===== Конфигурация ===== */ 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', + 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 holder = + document.querySelector(".rightbar-card") || + document.querySelector("#rightbarOffcanvas"); /* ===== Регулярки и константы ===== */ const DIGITS_RE = /^\d+$/; @@ -50,12 +56,12 @@ export function initCalendarWidget(options = {}) { /* ===== Токен из meta ===== */ function getAccessToken() { const meta = document.querySelector('meta[name="api-access"]'); - const m = (meta && meta.getAttribute('content')) || ''; + const m = (meta && meta.getAttribute("content")) || ""; if (m) return m.trim(); try { - return localStorage.getItem('api.access') || ''; + return localStorage.getItem("api.access") || ""; } catch (_) { - return ''; + return ""; } } @@ -65,14 +71,15 @@ export function initCalendarWidget(options = {}) { function authHeaders() { const headers = {}; if (globalToken) { - headers.Authorization = 'Bearer ' + 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'); + const csrfToken = + document.querySelector("[name=csrfmiddlewaretoken]")?.value || + document.querySelector('meta[name="csrf-token"]')?.content || + getCookie("csrftoken"); if (csrfToken) { - headers['X-CSRFToken'] = csrfToken; + headers["X-CSRFToken"] = csrfToken; } return headers; } @@ -80,11 +87,11 @@ export function initCalendarWidget(options = {}) { // Функция для получения cookie function getCookie(name) { let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); + 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 + '=')) { + if (cookie.substring(0, name.length + 1) === name + "=") { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } @@ -97,7 +104,7 @@ export function initCalendarWidget(options = {}) { function extractNumericPk(d) { const cands = [d?.pk, d?.id, d?.department_id]; for (const v of cands) { - const s = String(v ?? '').trim(); + const s = String(v ?? "").trim(); if (s && DIGITS_RE.test(s)) return s; } return null; @@ -106,8 +113,8 @@ export function initCalendarWidget(options = {}) { // 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'); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } @@ -115,59 +122,62 @@ export function initCalendarWidget(options = {}) { const eventsUrl = (deptId = null, employeeId = null) => { const u = new URL(API_EVENTS, location.origin); if (employeeId != null) { - u.searchParams.set('employee_id', String(employeeId)); + u.searchParams.set("employee_id", String(employeeId)); } else if (deptId != null) { - u.searchParams.set('department_id', String(deptId)); + u.searchParams.set("department_id", String(deptId)); } - return u.pathname + (u.search ? '?' + u.searchParams.toString() : ''); + 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(); + 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(',') + 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 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'); + 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 }; + 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 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') { + 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); + const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d); } const t = Date.parse(s); @@ -177,7 +187,7 @@ export function initCalendarWidget(options = {}) { }; const pick = (o, ks) => { - for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k]; + for (const k of ks) if (o && o[k] != null && o[k] !== "") return o[k]; return null; }; @@ -199,15 +209,19 @@ export function initCalendarWidget(options = {}) { 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; + 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 headers = { + Accept: "application/json", + ...authHeaders(), + ...(opts.headers || {}), + }; const r = await fetch(url, { headers, ...opts }); if (r.status === 401) { - console.warn('401 от API — нужен валидный access токен'); + console.warn("401 от API — нужен валидный access токен"); return []; } if (!r.ok) { @@ -218,14 +232,16 @@ export function initCalendarWidget(options = {}) { throw { status: r.status, data: text }; } const data = await r.json(); - return Array.isArray(data) ? data : data.results || data.items || data.events || []; + 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); + const el = document.getElementById("wd" + i); if (el) el.checked = !!(m & (1 << i)); } } catch (_) {} @@ -233,7 +249,7 @@ export function initCalendarWidget(options = {}) { /* ===== Helpers для форматирования и API детальной карточки ===== */ function pad(n) { - return (n < 10 ? '0' : '') + n; + return (n < 10 ? "0" : "") + n; } function fmtDate(d) { @@ -245,24 +261,28 @@ export function initCalendarWidget(options = {}) { } function fmtWhen(ev) { - if (!ev) return '—'; + 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)} (весь день)`; + 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)}`; + 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 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'); + const err = new Error("GET failed"); err.status = r.status; err.data = data; throw err; @@ -271,10 +291,13 @@ export function initCalendarWidget(options = {}) { } async function apiDelete(url) { - const r = await fetch(url, { method: 'DELETE', headers: { ...authHeaders() } }); + 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'); + const err = new Error("DELETE failed"); err.status = r.status; err.data = data; throw err; @@ -282,75 +305,79 @@ export function initCalendarWidget(options = {}) { } // Узлы модала (если разметка подключена) - const detailsModalEl = document.getElementById('eventDetailsModal'); + 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'); + detailsModalEl + ?.querySelector(".modal-dialog") + ?.classList.add("modal-dialog-centered"); - const detailsModal = detailsModalEl ? new bootstrap.Modal(detailsModalEl) : null; + 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'), + 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.title.textContent = ev.title || "Событие"; $dt.when.textContent = fmtWhen(ev); - $dt.loc.textContent = ev.location || '—'; - $dt.desc.textContent = ev.description || '—'; + $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 || '—'; + : 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); + console.log("[CalendarWidget] openEventDetailsById called with:", eventId); try { const url = `${API_EVENTS}${eventId}/`; - console.log('[CalendarWidget] Fetching event details from:', url); + console.log("[CalendarWidget] Fetching event details from:", url); const data = await apiGet(url); - console.log('[CalendarWidget] Event details loaded:', data); + console.log("[CalendarWidget] Event details loaded:", data); currentDetail = data; fillDetails(data); if (detailsModal) { - console.log('[CalendarWidget] Showing details modal'); + console.log("[CalendarWidget] Showing details modal"); detailsModal.show(); } else { - console.error('[CalendarWidget] detailsModal is not initialized!'); + console.error("[CalendarWidget] detailsModal is not initialized!"); } } catch (err) { - console.error('[CalendarWidget] Load details error:', err); + console.error("[CalendarWidget] Load details error:", err); if (err.status === 404) { - alert('Событие не найдено. Возможно, оно было удалено.'); + 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); + if (url.searchParams.has("event_id")) { + url.searchParams.delete("event_id"); + window.history.replaceState({}, "", url); } } else if (err.status === 403) { - alert('Недостаточно прав для просмотра деталей события.'); + alert("Недостаточно прав для просмотра деталей события."); } else { - alert('Не удалось загрузить событие.'); + alert("Не удалось загрузить событие."); } } } @@ -364,7 +391,7 @@ export function initCalendarWidget(options = {}) { // Используем кешированный API вместо прямого fetch raw = await getMyDepartments(); } catch (e) { - console.error('[CalendarWidget] Failed to load departments:', e); + console.error("[CalendarWidget] Failed to load departments:", e); raw = []; } @@ -380,7 +407,10 @@ export function initCalendarWidget(options = {}) { // Если API вернул пусто — fallback к data-dept-id if (!mapped.length && legacyDeptIds.length) { - mapped = legacyDeptIds.map((pk) => ({ id: String(pk), name: `Отдел #${pk}` })); + mapped = legacyDeptIds.map((pk) => ({ + id: String(pk), + name: `Отдел #${pk}`, + })); } // ✅ Дедуп по id @@ -398,7 +428,10 @@ export function initCalendarWidget(options = {}) { // Очистка пунктов отделов в обоих dropdown (десктоп и мобильный) [chooserMenu, chooserMenuMobile].forEach((menu) => { - if (menu) menu.querySelectorAll('[data-cal="dept"]').forEach((n) => n.closest('li')?.remove()); + if (menu) + menu + .querySelectorAll('[data-cal="dept"]') + .forEach((n) => n.closest("li")?.remove()); }); if (!departments.length) { @@ -411,9 +444,9 @@ export function initCalendarWidget(options = {}) { if (!menu) return; // Убедимся, что есть разделитель - menu.querySelector('.dropdown-divider') || + menu.querySelector(".dropdown-divider") || (() => { - const li = document.createElement('li'); + const li = document.createElement("li"); li.innerHTML = ''; menu.appendChild(li); })(); @@ -421,7 +454,7 @@ export function initCalendarWidget(options = {}) { // Вставка пунктов const frag = document.createDocumentFragment(); departments.forEach((d) => { - const li = document.createElement('li'); + const li = document.createElement("li"); li.innerHTML = ``; frag.appendChild(li); }); @@ -429,8 +462,11 @@ export function initCalendarWidget(options = {}) { }); // Валидация текущего выбора - if (state.type === 'dept' && !departments.some((d) => String(d.id) === String(state.deptId))) { - state.type = 'company'; + if ( + state.type === "dept" && + !departments.some((d) => String(d.id) === String(state.deptId)) + ) { + state.type = "company"; state.deptId = null; } setChooserLabel(); @@ -438,17 +474,17 @@ export function initCalendarWidget(options = {}) { } function currentDeptLabel() { - if (state.type === 'personal') return 'Личный'; - if (state.type !== 'dept') return 'Компания'; + 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') { + let label = "Компания"; + if (state.type === "personal") { + label = "Личный"; + } else if (state.type === "dept") { label = currentDeptLabel(); } if (chooserBtn) chooserBtn.textContent = label; @@ -458,31 +494,31 @@ export function initCalendarWidget(options = {}) { /* ===== Обработчик выбора из дропдауна (десктоп и мобильный) ===== */ function handleChooserClick(e) { - const btn = e.target.closest('[data-cal]'); + const btn = e.target.closest("[data-cal]"); if (!btn) return; const type = btn.dataset.cal; - if (type === 'company') { - state.type = 'company'; + if (type === "company") { + state.type = "company"; state.deptId = null; state.employeeId = null; - } else if (type === 'personal') { - state.type = 'personal'; + } 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'; + alert("Не удалось определить ID пользователя"); + state.type = "company"; return; } } else { const id = btn.dataset.id; - if (!DIGITS_RE.test(String(id || ''))) { - alert('Некорректный идентификатор отдела'); + if (!DIGITS_RE.test(String(id || ""))) { + alert("Некорректный идентификатор отдела"); return; } - state.type = 'dept'; + state.type = "dept"; state.deptId = id; state.employeeId = null; } @@ -491,8 +527,9 @@ export function initCalendarWidget(options = {}) { updateWeekLists(); } - if (chooserMenu) chooserMenu.addEventListener('click', handleChooserClick); - if (chooserMenuMobile) chooserMenuMobile.addEventListener('click', handleChooserClick); + if (chooserMenu) chooserMenu.addEventListener("click", handleChooserClick); + if (chooserMenuMobile) + chooserMenuMobile.addEventListener("click", handleChooserClick); /* ===== Комбинированная загрузка событий (occurrences) ===== */ // События для текущего контекста (используется календарём) @@ -500,22 +537,22 @@ export function initCalendarWidget(options = {}) { try { const params = { start: ymdLocal(start), - end: ymdLocal(end) + end: ymdLocal(end), }; - + // Добавляем employee_id для личного календаря - if (state.type === 'personal' && state.employeeId) { + if (state.type === "personal" && state.employeeId) { params.employee_id = state.employeeId; } // Добавляем department_id для отдела - else if (state.type === 'dept' && state.deptId) { + 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); + console.error("[CalendarWidget] Fetch events failed", e); return []; } } @@ -528,35 +565,44 @@ export function initCalendarWidget(options = {}) { 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 }, + { + 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', + type: "dept", id: d.id, })), ]; - + // Добавляем личный календарь, если есть employee_id if (currentEmployeeId) { sources.push({ - params: { start: startStr, end: endStr, employee_id: currentEmployeeId }, - label: 'Личный', - type: 'personal', + params: { + start: startStr, + end: endStr, + employee_id: currentEmployeeId, + }, + label: "Личный", + type: "personal", id: currentEmployeeId, }); } - + // Загружаем все источники параллельно (с кешированием!) const chunks = await Promise.all( sources.map((s) => @@ -565,15 +611,18 @@ export function initCalendarWidget(options = {}) { (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); + console.error( + `[CalendarWidget] Failed to load events for ${s.label}:`, + err, + ); return []; - }) - ) + }), + ), ); - + const merged = chunks.flat(); // Дедуп по устойчивому ключу const seen = new Set(); @@ -591,36 +640,62 @@ export function initCalendarWidget(options = {}) { 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 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 + "allDay" in ev ? !!ev.allDay - : 'all_day' in ev - ? !!ev.all_day - : isDateOnly(rs) && (!re || isDateOnly(re)); + : "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 }; + 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 = ''; + container.innerHTML = ""; if (rangeLabel) { - const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'long' }); + 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 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) @@ -629,57 +704,57 @@ export function initCalendarWidget(options = {}) { .sort((a, b) => a.start - b.start); if (!list.length) { - const e = document.createElement('div'); - e.className = 'week-vertical empty'; - e.textContent = 'Нет событий на этой неделе'; + 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'; - + 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); + 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'; + 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 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 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 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 || '—']; + 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'; + 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); @@ -695,63 +770,65 @@ export function initCalendarWidget(options = {}) { to = new Date(we.getTime() + 3 * dayMs); const items = await fetchEventsAllCalendars(from, to); renderVertical( - document.getElementById('weekList'), - document.getElementById('weekRange'), + document.getElementById("weekList"), + document.getElementById("weekRange"), items, ws, - we + we, ); renderVertical( - document.getElementById('weekListMobile'), - document.getElementById('weekRangeMobile'), + document.getElementById("weekListMobile"), + document.getElementById("weekRangeMobile"), items, ws, - we + we, ); } /* ===== Контекстное меню для событий ===== */ - const contextMenu = document.getElementById('calendarContextMenu'); - const contextMenuView = document.getElementById('contextMenuView'); - const contextMenuEdit = document.getElementById('contextMenuEdit'); - const contextMenuDelete = document.getElementById('contextMenuDelete'); - + 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 => { + checkEventPermissions(eventId).then((perms) => { // Показываем/скрываем кнопки в зависимости от прав if (contextMenuEdit) { - contextMenuEdit.classList.toggle('d-none', !perms.can_edit); + contextMenuEdit.classList.toggle("d-none", !perms.can_edit); } if (contextMenuDelete) { - contextMenuDelete.classList.toggle('d-none', !perms.can_delete); + contextMenuDelete.classList.toggle("d-none", !perms.can_delete); } - + // Скрываем разделитель если нет прав на удаление - const divider = contextMenu.querySelector('.dropdown-divider[data-requires-permission="delete"]'); + const divider = contextMenu.querySelector( + '.dropdown-divider[data-requires-permission="delete"]', + ); if (divider) { - divider.classList.toggle('d-none', !perms.can_delete); + divider.classList.toggle("d-none", !perms.can_delete); } - + // Сначала показываем меню для получения его размеров - contextMenu.style.display = 'block'; - contextMenu.style.visibility = 'hidden'; - + 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) { @@ -761,7 +838,7 @@ export function initCalendarWidget(options = {}) { if (finalX < 10) { finalX = 10; // Минимальный отступ слева } - + // Корректируем позицию по вертикали let finalY = y; if (y + menuHeight > windowHeight) { @@ -775,18 +852,18 @@ export function initCalendarWidget(options = {}) { if (finalY < 10) { finalY = 10; // Минимальный отступ сверху } - + // Применяем скорректированную позицию - contextMenu.style.left = finalX + 'px'; - contextMenu.style.top = finalY + 'px'; - contextMenu.style.visibility = 'visible'; + contextMenu.style.left = finalX + "px"; + contextMenu.style.top = finalY + "px"; + contextMenu.style.visibility = "visible"; }); } // Функция скрытия контекстного меню function hideContextMenu() { if (contextMenu) { - contextMenu.style.display = 'none'; + contextMenu.style.display = "none"; } contextMenuEventId = null; } @@ -795,20 +872,20 @@ export function initCalendarWidget(options = {}) { async function checkEventPermissions(eventId) { try { const response = await fetch(`${API_EVENTS}${eventId}/permissions/`, { - headers: authHeaders() + headers: authHeaders(), }); if (response.ok) { return await response.json(); } } catch (e) { - console.error('Failed to check permissions:', e); + console.error("Failed to check permissions:", e); } return { can_view: true, can_edit: false, can_delete: false }; } // Обработчики кнопок контекстного меню if (contextMenuView) { - contextMenuView.addEventListener('click', () => { + contextMenuView.addEventListener("click", () => { if (contextMenuEventId) { openEventDetailsById(contextMenuEventId); } @@ -817,7 +894,7 @@ export function initCalendarWidget(options = {}) { } if (contextMenuEdit) { - contextMenuEdit.addEventListener('click', async () => { + contextMenuEdit.addEventListener("click", async () => { if (contextMenuEventId) { try { const data = await apiGet(`${API_EVENTS}${contextMenuEventId}/`); @@ -825,8 +902,8 @@ export function initCalendarWidget(options = {}) { // Переиспользуем логику редактирования из старой кнопки "Редактировать" editEvent(data); } catch (err) { - console.error('Load event for edit error', err); - alert('Не удалось загрузить событие для редактирования.'); + console.error("Load event for edit error", err); + alert("Не удалось загрузить событие для редактирования."); } } hideContextMenu(); @@ -834,9 +911,9 @@ export function initCalendarWidget(options = {}) { } if (contextMenuDelete) { - contextMenuDelete.addEventListener('click', async () => { + contextMenuDelete.addEventListener("click", async () => { if (contextMenuEventId) { - if (!confirm('Удалить это событие без возможности восстановления?')) { + if (!confirm("Удалить это событие без возможности восстановления?")) { hideContextMenu(); return; } @@ -849,9 +926,11 @@ export function initCalendarWidget(options = {}) { } catch (_) {} updateWeekLists(); } catch (err) { - console.error('Delete event error', err); + console.error("Delete event error", err); alert( - err.status === 403 ? 'Недостаточно прав для удаления.' : 'Не удалось удалить событие.' + err.status === 403 + ? "Недостаточно прав для удаления." + : "Не удалось удалить событие.", ); } } @@ -860,7 +939,7 @@ export function initCalendarWidget(options = {}) { } // Закрытие контекстного меню при клике вне его - document.addEventListener('click', (e) => { + document.addEventListener("click", (e) => { if (contextMenu && !contextMenu.contains(e.target)) { hideContextMenu(); } @@ -868,56 +947,57 @@ export function initCalendarWidget(options = {}) { // Функция редактирования события (вынесена из обработчика кнопки) function editEvent(data) { - const form = document.getElementById('eventForm'); + const form = document.getElementById("eventForm"); if (!form) return; - form.dataset.mode = 'edit'; + 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="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 || ''; + 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)}` - : ''); + ? `${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)}` - : ''); + ? `${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); + 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'; + 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); + if (untilEl) untilEl.value = (data.recurrence_until || "").slice(0, 10); const countEl = form.querySelector('[name="recurrence_count"]'); - if (countEl) countEl.value = data.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(); + typeof syncByRecurrence === "function" && syncByRecurrence(); } catch (_) {} // Отдел (если есть select) @@ -927,53 +1007,141 @@ export function initCalendarWidget(options = {}) { if (val != null) deptSel.value = String(val); } + // Заполняем селектор календарей + populateCalendarSelector(); + + // Устанавливаем текущий календарь в селекторе + const targetSelect = document.getElementById("targetCalendarSelect"); + if (targetSelect && data) { + if (data.calendar_id) { + targetSelect.value = data.calendar_id; + } else if (data.employee_id) { + targetSelect.value = "personal"; + } else if (data.department_id) { + targetSelect.value = `dept-${data.department_id}`; + } else { + targetSelect.value = "company"; + } + } + // Открываем форму редактирования - const createModalEl = document.getElementById('eventCreateModal'); + 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(); + const offcanvasEl = document.getElementById("rightbarOffcanvas"); + if (offcanvasEl) + bootstrap.Offcanvas.getOrCreateInstance(offcanvasEl).show(); } form.querySelector('[name="title"]')?.focus(); } + /* ===== Функция заполнения селектора календарей ===== */ + function populateCalendarSelector() { + const select = document.getElementById("targetCalendarSelect"); + if (!select) return; + + // Очищаем селект + select.innerHTML = ""; + + // Добавляем базовые опции (legacy) + const companyOption = document.createElement("option"); + companyOption.value = "company"; + companyOption.textContent = "Компания (общие события)"; + select.appendChild(companyOption); + + const personalOption = document.createElement("option"); + personalOption.value = "personal"; + personalOption.textContent = "Личный календарь"; + select.appendChild(personalOption); + + // Добавляем отделы, если есть + if (window.departments && Array.isArray(window.departments)) { + window.departments.forEach((dept) => { + const deptOption = document.createElement("option"); + deptOption.value = `dept-${dept.id}`; + deptOption.textContent = `Отдел: ${dept.name}`; + select.appendChild(deptOption); + }); + } + + // Добавляем новые календари из интеграции + if (window.calendarIntegration?.calendars) { + const newCalendars = window.calendarIntegration.calendars.filter( + (cal) => !cal.id.toString().startsWith("legacy-") + ); + + if (newCalendars.length > 0) { + // Добавляем разделитель + const separator = document.createElement("option"); + separator.disabled = true; + separator.textContent = "─────────────────"; + select.appendChild(separator); + + // Добавляем новые календари + newCalendars.forEach((cal) => { + const calOption = document.createElement("option"); + calOption.value = cal.id; + calOption.textContent = `📅 ${cal.name}`; + if (cal.description) { + calOption.title = cal.description; + } + select.appendChild(calOption); + }); + } + } + + // Устанавливаем значение по умолчанию в зависимости от текущего состояния + if (state.type === "personal") { + select.value = "personal"; + } else if (state.type === "dept" && state.deptId) { + select.value = `dept-${state.deptId}`; + } else { + select.value = "company"; + } + } + /* ===== Обработчики событий календаря ===== */ - const oc = document.getElementById('rightbarOffcanvas'); + 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'); + 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', () => { + 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', + locale: "ru", + initialView: "dayGridMonth", + headerToolbar: { left: "prev,next today", center: "title", right: "" }, + height: "auto", displayEventTime: false, selectable: true, // Позволяет выбирать даты 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); + 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'); + console.log("[CalendarWidget] Using legacy fetchEventsCombined"); raw = await fetchEventsCombined(info.start, info.end); } - + // Нормализуем id и цвет для FullCalendar const mapped = (Array.isArray(raw) ? raw : []).map((ev) => { const id = ev.id ?? ev.pk ?? ev.uuid ?? ev.slug ?? ev._id; @@ -986,15 +1154,15 @@ export function initCalendarWidget(options = {}) { }); success(mapped); } catch (e) { - console.error('Calendar fetch error:', 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 || ''; + 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) => { @@ -1002,40 +1170,40 @@ export function initCalendarWidget(options = {}) { if (col) { info.el.style.backgroundColor = col; info.el.style.borderColor = col; - info.el.style.color = 'var(--bs-body-bg)'; + 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'; + 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'); + 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.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) => { + 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) => { + info.el.addEventListener("touchstart", (e) => { contextMenuLongPressTimer = setTimeout(() => { const eid = info.event.id || info.event.extendedProps?.id; if (eid) { @@ -1044,15 +1212,15 @@ export function initCalendarWidget(options = {}) { } }, 500); // 500ms для долгого нажатия }); - - info.el.addEventListener('touchend', () => { + + info.el.addEventListener("touchend", () => { if (contextMenuLongPressTimer) { clearTimeout(contextMenuLongPressTimer); contextMenuLongPressTimer = null; } }); - - info.el.addEventListener('touchmove', () => { + + info.el.addEventListener("touchmove", () => { if (contextMenuLongPressTimer) { clearTimeout(contextMenuLongPressTimer); contextMenuLongPressTimer = null; @@ -1062,43 +1230,46 @@ export function initCalendarWidget(options = {}) { // Обработчик клика по дате для создания нового события dateClick: (info) => { // Открываем модал создания события - const form = document.getElementById('eventForm'); + const form = document.getElementById("eventForm"); if (!form) return; - + // Очищаем форму form.reset(); - form.dataset.mode = 'create'; + 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')}`; + 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')}`; + 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(); + typeof syncByRecurrence === "function" && syncByRecurrence(); + typeof syncByAllDay === "function" && syncByAllDay(); + typeof syncUntilCount === "function" && syncUntilCount(); } catch (_) {} - + + // Заполняем селектор календарей + populateCalendarSelector(); + // Открываем модал - const createModalEl = document.getElementById('eventCreateModal'); + const createModalEl = document.getElementById("eventCreateModal"); if (createModalEl) { const createModal = bootstrap.Modal.getOrCreateInstance(createModalEl); createModal.show(); @@ -1111,10 +1282,10 @@ export function initCalendarWidget(options = {}) { const desk = document.getElementById(config.deskContainerId); if (desk) { deskCalendar = new FullCalendar.Calendar(desk, fcOpts); - deskCalendar.on('loading', (l) => { + deskCalendar.on("loading", (l) => { if (!l) updateWeekLists(); }); - deskCalendar.on('eventClick', (info) => { + deskCalendar.on("eventClick", (info) => { info.jsEvent?.preventDefault?.(); const eid = info.event.id || info.event.extendedProps?.id; if (eid) openEventDetailsById(eid); @@ -1124,10 +1295,10 @@ export function initCalendarWidget(options = {}) { const mob = document.getElementById(config.mobContainerId); if (mob) { mobCalendar = new FullCalendar.Calendar(mob, fcOpts); - mobCalendar.on('loading', (l) => { + mobCalendar.on("loading", (l) => { if (!l) updateWeekLists(); }); - mobCalendar.on('eventClick', (info) => { + mobCalendar.on("eventClick", (info) => { info.jsEvent?.preventDefault?.(); const eid = info.event.id || info.event.extendedProps?.id; if (eid) openEventDetailsById(eid); @@ -1139,19 +1310,21 @@ export function initCalendarWidget(options = {}) { loadDepartments().then(() => setChooserLabel()); setTimeout(() => updateWeekLists(), 100); - [deskCalendar, mobCalendar].forEach((cal) => cal?.on('datesSet', () => updateWeekLists())); + [deskCalendar, mobCalendar].forEach((cal) => + cal?.on("datesSet", () => updateWeekLists()), + ); /* ===== UX: переключалки формы ===== */ function syncByRecurrence() { - const r = recurrenceSelect?.value || 'one_time'; + const r = recurrenceSelect?.value || "one_time"; // Weekly block - if (r === 'weekly') { - weeklyBlock?.classList.remove('d-none'); + if (r === "weekly") { + weeklyBlock?.classList.remove("d-none"); } else { - weeklyBlock?.classList.add('d-none'); + weeklyBlock?.classList.add("d-none"); } // Hourly требует времени → снимаем all-day и блокируем чекбокс - if (r === 'hourly') { + if (r === "hourly") { allDayChk.checked = false; allDayChk.disabled = true; } else { @@ -1170,85 +1343,96 @@ export function initCalendarWidget(options = {}) { function syncUntilCount() { const hasUntil = !!recurrenceUntil?.value; if (hasUntil) { - recurrenceCount.value = ''; + recurrenceCount.value = ""; recurrenceCount.disabled = true; } else { recurrenceCount.disabled = false; } } - recurrenceSelect?.addEventListener('change', syncByRecurrence); - allDayChk?.addEventListener('change', syncByAllDay); - recurrenceUntil?.addEventListener('change', syncUntilCount); + 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'); + const form = document.getElementById("eventForm"); if (!form) return; const colorInput = form.querySelector('input[name="color"]'); - const palette = document.getElementById('colorPalette'); + 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); + 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', () => { + palette.querySelectorAll("[data-color]").forEach((btn) => { + btn.addEventListener("click", () => { colorInput.value = btn.dataset.color || DEFAULT_EVENT_COLOR; - colorInput.dispatchEvent(new Event('input', { bubbles: true })); + colorInput.dispatchEvent(new Event("input", { bubbles: true })); highlightActive(); }); }); - colorInput.addEventListener('input', highlightActive); + colorInput.addEventListener("input", highlightActive); highlightActive(); } /* ===== Создание/редактирование события (POST/PATCH только с Bearer) ===== */ - const form = document.getElementById('eventForm'); - form?.addEventListener('submit', async (e) => { + 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'); + 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('Укажите заголовок'); + 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 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 = (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 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; + const colorVal = + (fd.get("color") || "").toString().trim() || DEFAULT_EVENT_COLOR; // Базовый payload const payload = { title, @@ -1258,8 +1442,8 @@ export function initCalendarWidget(options = {}) { recurrence, recurrence_interval, color: colorVal, - location: (fd.get('location') || '').toString().trim() || '', - description: (fd.get('description') || '').toString().trim() || '', + location: (fd.get("location") || "").toString().trim() || "", + description: (fd.get("description") || "").toString().trim() || "", }; // Время (если all_day=false ИЛИ пользователь указал оба времени) @@ -1268,58 +1452,81 @@ export function initCalendarWidget(options = {}) { payload.start_time = sTime; // "HH:MM" payload.end_time = eTime; // "HH:MM" payload.all_day = false; - } else if (recurrence === 'hourly') { - alert('Ежечасное событие требует и время начала, и время окончания.'); + } else if (recurrence === "hourly") { + alert("Ежечасное событие требует и время начала, и время окончания."); return; } } // Ограничители серии if (recurrence_until && recurrence_count) { - alert('Нельзя одновременно задавать "Повторять до" и "Кол-во повторов". Укажите что-то одно.'); + 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) { + 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 targetCalendar = fd.get("target_calendar"); - const postHeaders = { 'Content-Type': 'application/json', ...authHeaders() }; + // Определяем тип календаря и добавляем соответствующие параметры + if (targetCalendar && targetCalendar !== "company") { + if (targetCalendar === "personal") { + // Личный календарь - получаем employee_id + const userMeta = document.querySelector('meta[name="user-id"]'); + const currentEmployeeId = userMeta + ? parseInt(userMeta.content, 10) + : null; + if (!currentEmployeeId) { + alert("Не удалось определить ID пользователя"); + return; + } + payload.employee_id = currentEmployeeId; + } else if (targetCalendar.startsWith("dept-")) { + // Календарь отдела + const deptId = parseInt(targetCalendar.replace("dept-", ""), 10); + if (isNaN(deptId)) { + alert("Некорректный отдел"); + return; + } + payload.department_id = deptId; + } else if (/^\d+$/.test(targetCalendar)) { + // Новый календарь - используем calendar_id + payload.calendar_id = parseInt(targetCalendar, 10); + } + } + // Если targetCalendar === "company" или null, то это общие события компании (без дополнительных параметров) + + const postHeaders = { + "Content-Type": "application/json", + ...authHeaders(), + }; // Проверка токена if (!globalToken) { - alert('Требуется авторизация. Войдите заново.'); + 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'; + 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(); + document.querySelector("#eventCreateModal .btn-close")?.click(); form.reset(); delete form.dataset.mode; delete form.dataset.eventId; @@ -1330,40 +1537,49 @@ export function initCalendarWidget(options = {}) { [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); - + 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()}»` : 'компании'; + const where = + state?.type === "dept" + ? `отделе «${currentDeptLabel()}»` + : "компании"; alert( - 'Недостаточно прав для создания события в ' + + "Недостаточно прав для создания события в " + where + - '.\n' + - 'Попросите руководителя назначить вам роль «Управлять календарём отдела».' + ".\n" + + "Попросите руководителя назначить вам роль «Управлять календарём отдела».", ); } else if (err && err.status === 400) { // Детальная информация о валидации const errors = err?.data?.errors || err?.data; - const detail = err?.data?.detail || ''; - let errorMsg = 'Ошибка валидации события:\n\n'; - + const detail = err?.data?.detail || ""; + let errorMsg = "Ошибка валидации события:\n\n"; + if (detail) { - errorMsg += detail + '\n\n'; + errorMsg += detail + "\n\n"; } - - if (typeof errors === 'object' && errors !== null) { + + 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`; + errorMsg += `${field}: ${msgs.join(", ")}\n`; } } - - alert(errorMsg || 'Не удалось сохранить событие. Проверьте введенные данные.'); + + alert( + errorMsg || + "Не удалось сохранить событие. Проверьте введенные данные.", + ); } else { const detail = - err?.data?.detail || (typeof err?.data === 'string' ? err.data : ''); - alert('Не удалось создать событие: ' + (detail || JSON.stringify(err?.data || {}))); + err?.data?.detail || (typeof err?.data === "string" ? err.data : ""); + alert( + "Не удалось создать событие: " + + (detail || JSON.stringify(err?.data || {})), + ); } } }); @@ -1382,13 +1598,13 @@ export function initCalendarWidget(options = {}) { } // Автоинициализация при загрузке DOM -if (typeof document !== 'undefined') { - document.addEventListener('DOMContentLoaded', () => { +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'); + const eventIdFromUrl = urlParams.get("event_id"); if (eventIdFromUrl && window.calendarWidget) { // Небольшая задержка, чтобы календарь успел загрузиться setTimeout(() => { diff --git a/backend/templates/includes/components/calendar_modal_create.html b/backend/templates/includes/components/calendar_modal_create.html index f3073f9e..31542d51 100644 --- a/backend/templates/includes/components/calendar_modal_create.html +++ b/backend/templates/includes/components/calendar_modal_create.html @@ -119,9 +119,14 @@ - - Календарь: Компания - +
+ + +
События создаются в выбранном календаре
+