refactor(js): Extract calendar widget to separate module
- Created calendarWidget.js (1002 lines) - Reduced rightbar_calendar.html from 996 to 237 lines (76% reduction) - Supports: FullCalendar integration, company/department calendars, recurring events - Features: week list view, event CRUD, color picker, responsive design - Export initCalendarWidget() function for reusability - Updated components/index.js to export calendar widget (v1.1.0)
This commit is contained in:
parent
78f4db24b3
commit
fb49f4e2b6
1002
backend/static/js/components/calendarWidget.js
Normal file
1002
backend/static/js/components/calendarWidget.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -17,14 +17,11 @@ export {
|
||||
createSelectorMatcher
|
||||
} from './listFilter.js';
|
||||
|
||||
// Компоненты форм (будут созданы в Фазе 4)
|
||||
// export { EmployeeForm } from './employeeForm.js';
|
||||
// Компоненты календаря
|
||||
export { initCalendarWidget } from './calendarWidget.js';
|
||||
|
||||
// Компоненты календаря (будут созданы в Фазе 4)
|
||||
// export { CalendarWidget } from './calendarWidget.js';
|
||||
|
||||
// Компоненты отображения (будут созданы в Фазе 4)
|
||||
// Компоненты отображения
|
||||
export { initTeamWheel } from './teamWheel.js';
|
||||
|
||||
// Версия компонентов
|
||||
export const COMPONENTS_VERSION = '1.0.0';
|
||||
export const COMPONENTS_VERSION = '1.1.0';
|
||||
|
||||
@ -232,766 +232,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
if (!window.FullCalendar) return;
|
||||
|
||||
initColorPicker();
|
||||
|
||||
|
||||
/* ===== токен из 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(){
|
||||
return globalToken ? { 'Authorization': 'Bearer ' + globalToken } : {};
|
||||
}
|
||||
|
||||
/* ===== endpoints (единый вьюсет) ===== */
|
||||
const API_EVENTS = '/api/v1/calendar/events/';
|
||||
const API_MY_DEPTS = '/api/v1/departments/my-departments/'; // список отделов пользователя
|
||||
const holder = document.querySelector('.rightbar-card');
|
||||
|
||||
/* ===== только ЧИСЛОВОЙ PK отдела ===== */
|
||||
const DIGITS_RE = /^\d+$/;
|
||||
const DEFAULT_EVENT_COLOR = '#0d6efd';
|
||||
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
|
||||
const eventsUrl = (deptId=null) => {
|
||||
const u = new URL(API_EVENTS, location.origin);
|
||||
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 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 };
|
||||
// защита от гонок при загрузке отделов
|
||||
let deptsLoadSeq = 0;
|
||||
|
||||
/* ===== utils ===== */
|
||||
const dayMs = 86400000, hourMs = 3600000;
|
||||
const isDateOnly = v => typeof v ==='string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
|
||||
const toDate = v => {
|
||||
if (!v) return null;
|
||||
if (v instanceof Date) return v;
|
||||
if (typeof v === 'number') return new Date(v);
|
||||
if (typeof v === 'string'){
|
||||
const s=v.trim();
|
||||
if (isDateOnly(s)){ const [y,m,d]=s.split('-').map(Number); return new Date(y,m-1,d); }
|
||||
const t=Date.parse(s); if (!isNaN(t)) return new Date(t);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const pick = (o, ks)=>{ for (const k of ks) if (o && o[k]!=null && o[k] !== '') return o[k]; return null; };
|
||||
const startOfWeek = d => { const x=new Date(d.getFullYear(),d.getMonth(),d.getDate()); const day=(x.getDay()+6)%7; x.setDate(x.getDate()-day); x.setHours(0,0,0,0); return x; };
|
||||
const endOfWeek = d => { const s=startOfWeek(d); const e=new Date(s); e.setDate(s.getDate()+7); return e; };
|
||||
const overlaps = (ev, ws, we) => ev.start < we && ev.end > ws;
|
||||
const truncate = (s, n=20) => {
|
||||
const t = (s ?? '').toString();
|
||||
return t.length > n ? t.slice(0, n - 1) + '…' : t;
|
||||
};
|
||||
|
||||
async function fetchJSON(url, opts = {}){
|
||||
const headers = { 'Accept':'application/json', ...authHeaders(), ...(opts.headers || {}) };
|
||||
const r = await fetch(url, { headers, ...opts });
|
||||
if (r.status === 401) { console.warn('401 от API — нужен валидный access токен'); return []; }
|
||||
if (!r.ok) {
|
||||
let text = await r.text();
|
||||
try{ text = JSON.parse(text); }catch{}
|
||||
throw { status: r.status, data: text };
|
||||
}
|
||||
const data = await r.json();
|
||||
return Array.isArray(data) ? data : (data.results || data.items || data.events || []);
|
||||
}
|
||||
function setWeekdaysFromMask(mask){
|
||||
try{
|
||||
const m = Number(mask) || 0;
|
||||
for (let i=0;i<=6;i++){
|
||||
const el = document.getElementById('wd'+i);
|
||||
if (el) el.checked = !!(m & (1<<i));
|
||||
}
|
||||
}catch(_){}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== helpers для форматирования и API детальной карточки ===== */
|
||||
function pad(n){ return (n<10?'0':'') + n; }
|
||||
function fmtDate(d){ return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
|
||||
function fmtTime(d){ return `${pad(d.getHours())}:${pad(d.getMinutes())}`; }
|
||||
function fmtWhen(ev){
|
||||
if (!ev) return '—';
|
||||
const allDay = !!ev.all_day;
|
||||
// аккуратно парсим дату/дату-время (без UTC-сдвигов на простых датах)
|
||||
const sd = toDate(ev.start || ev.start_date);
|
||||
const ed = (ev.end || ev.end_date) ? toDate(ev.end || ev.end_date) : null;
|
||||
if (allDay){
|
||||
if (ed && fmtDate(sd) !== fmtDate(ed)) return `${fmtDate(sd)} — ${fmtDate(ed)} (весь день)`;
|
||||
return `${fmtDate(sd)} (весь день)`;
|
||||
}
|
||||
if (ed) return `${fmtDate(sd)} ${fmtTime(sd)} — ${fmtDate(ed)} ${fmtTime(ed)}`;
|
||||
return `${fmtDate(sd)} ${fmtTime(sd)}`;
|
||||
}
|
||||
async function apiGet(url){
|
||||
const r = await fetch(url, { headers: { 'Accept': 'application/json', ...authHeaders() } });
|
||||
const data = await r.json().catch(()=>null);
|
||||
if (!r.ok){ const err=new Error('GET failed'); err.status=r.status; err.data=data; throw err; }
|
||||
return data;
|
||||
}
|
||||
async function apiDelete(url){
|
||||
const r = await fetch(url, { method:'DELETE', headers: { ...authHeaders() } });
|
||||
if (!r.ok){ const data = await r.json().catch(()=>null); const err=new Error('DELETE failed'); err.status=r.status; err.data=data; throw err; }
|
||||
}
|
||||
// узлы модала (если разметка подключена)
|
||||
const detailsModalEl = document.getElementById('eventDetailsModal');
|
||||
// Вынесем модал в <body>, иначе позиционирование ломается, если родитель с transform
|
||||
if (detailsModalEl && detailsModalEl.parentElement !== document.body) {
|
||||
document.body.appendChild(detailsModalEl);
|
||||
}
|
||||
// Гарантируем вертикальное центрирование (если в HTML забыли класс)
|
||||
detailsModalEl?.querySelector('.modal-dialog')?.classList.add('modal-dialog-centered');
|
||||
|
||||
const detailsModal = detailsModalEl ? new bootstrap.Modal(detailsModalEl) : null;
|
||||
|
||||
const $dt = {
|
||||
title: document.getElementById('detailTitle'),
|
||||
when: document.getElementById('detailWhen'),
|
||||
loc: document.getElementById('detailLocation'),
|
||||
desc: document.getElementById('detailDescription'),
|
||||
scope: document.getElementById('detailScope'),
|
||||
rec: document.getElementById('detailRecurrence'),
|
||||
dot: document.getElementById('detailColorDot'),
|
||||
btnEdit: document.getElementById('btnEditEvent'),
|
||||
btnDel: document.getElementById('btnDeleteEvent'),
|
||||
};
|
||||
let currentDetail = null;
|
||||
function fillDetails(ev){
|
||||
if (!$dt.title) return; // модала нет — тихо выходим
|
||||
$dt.title.textContent = ev.title || 'Событие';
|
||||
$dt.when.textContent = fmtWhen(ev);
|
||||
$dt.loc.textContent = ev.location || '—';
|
||||
$dt.desc.textContent = ev.description || '—';
|
||||
$dt.scope.textContent = ev.department ? `Отдел: ${ev.department.name || ev.department}` : 'Компания';
|
||||
$dt.rec.textContent = ev.recurrence_interval ? `${ev.recurrence} ×${ev.recurrence_interval}` : (ev.recurrence || '—');
|
||||
const col = ev.color || DEFAULT_EVENT_COLOR; if ($dt.dot) $dt.dot.style.backgroundColor = col;
|
||||
}
|
||||
async function openEventDetailsById(eventId){
|
||||
try{
|
||||
const data = await apiGet(`${API_EVENTS}${eventId}/`);
|
||||
currentDetail = data;
|
||||
fillDetails(data);
|
||||
detailsModal && detailsModal.show();
|
||||
}catch(err){
|
||||
console.error('Load details error', err);
|
||||
if (err.status === 403) alert('Недостаточно прав для просмотра деталей события.');
|
||||
else alert('Не удалось загрузить событие.');
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== загрузка отделов (только числовой PK) ===== */
|
||||
async function loadDepartments(){
|
||||
const mySeq = ++deptsLoadSeq; // метка версии вызова
|
||||
|
||||
let raw = [];
|
||||
try{ raw = await fetchJSON(API_MY_DEPTS); }catch(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;
|
||||
|
||||
// очистка пунктов отделов (только сейчас, после защиты от гонки)
|
||||
chooserMenu.querySelectorAll('[data-cal="dept"]').forEach(n => n.closest('li')?.remove());
|
||||
|
||||
if (!departments.length){
|
||||
setChooserLabel();
|
||||
return departments;
|
||||
}
|
||||
|
||||
// убедимся, что есть разделитель
|
||||
chooserMenu.querySelector('.dropdown-divider') || (() => {
|
||||
const li = document.createElement('li'); li.innerHTML = '<hr class="dropdown-divider">';
|
||||
chooserMenu.appendChild(li);
|
||||
})();
|
||||
|
||||
// вставка пунктов
|
||||
const frag = document.createDocumentFragment();
|
||||
departments.forEach(d => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<button class="dropdown-item" type="button" data-cal="dept" data-id="${d.id}">${d.name}</button>`;
|
||||
frag.appendChild(li);
|
||||
});
|
||||
chooserMenu.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 !== 'dept') return 'Компания';
|
||||
const d = departments.find(x => String(x.id) === String(state.deptId));
|
||||
return d?.name || `Отдел #${state.deptId}`;
|
||||
}
|
||||
function setChooserLabel(){
|
||||
const label = state.type === 'company' ? 'Компания' : currentDeptLabel();
|
||||
chooserBtn.textContent = label;
|
||||
if (eventTargetLabel) eventTargetLabel.textContent = label;
|
||||
}
|
||||
|
||||
/* ===== обработчик выбора из дропдауна ===== */
|
||||
chooserMenu.addEventListener('click', (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;
|
||||
} else {
|
||||
const id = btn.dataset.id;
|
||||
if (!DIGITS_RE.test(String(id || ''))) { alert('Некорректный идентификатор отдела'); return; }
|
||||
state.type='dept'; state.deptId=id;
|
||||
}
|
||||
setChooserLabel();
|
||||
[deskCalendar, mobCalendar].forEach(cal => cal?.refetchEvents());
|
||||
updateWeekLists();
|
||||
});
|
||||
|
||||
/* ===== комбинированная загрузка событий (occurrences) ===== */
|
||||
// События для текущего контекста (оставляю — используется календарём)
|
||||
async function fetchEventsCombined(start, end){
|
||||
let base = eventsUrl(state.type === 'dept' ? state.deptId : null);
|
||||
const withRange = addRange(base, start, end);
|
||||
try{ return await fetchJSON(withRange); }
|
||||
catch(e){ console.error('Fetch events failed', e); return []; }
|
||||
}
|
||||
|
||||
// ✅ ВСЕ доступные календари: компания + все отделы пользователя
|
||||
async function fetchEventsAllCalendars(start, end){
|
||||
// при первом вызове убедимся, что отделы загружены
|
||||
if (!departments || departments.length === 0){
|
||||
try { await loadDepartments(); } catch(_) {}
|
||||
}
|
||||
// формируем список источников с подписью
|
||||
const sources = [
|
||||
{ url: addRange(eventsUrl(null), start, end), label: 'Компания', type: 'company', id: null },
|
||||
...departments.map(d => ({
|
||||
url: addRange(eventsUrl(d.id), start, end),
|
||||
label: d.name,
|
||||
type: 'dept',
|
||||
id: d.id
|
||||
})),
|
||||
];
|
||||
const chunks = await Promise.all(sources.map(s =>
|
||||
fetchJSON(s.url).then(arr => (arr || []).map(ev => ({ ...ev, __source: { label: s.label, type: s.type, id: s.id } })))
|
||||
.catch(()=>[])
|
||||
));
|
||||
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 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 { 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';
|
||||
// цветовая точка слева от заголовка
|
||||
const dot = document.createElement('span'); dot.className = 'color-dot';
|
||||
dot.style.backgroundColor = ev.color || DEFAULT_EVENT_COLOR;
|
||||
const badge = document.createElement('div'); badge.className = 'date-badge';
|
||||
const dow = dfDow.format(ev.start), md = dfMD.format(ev.start);
|
||||
badge.innerHTML = `<div class="dow">${dow}</div><div class="md">${md}</div>`;
|
||||
const cont = document.createElement('div'); cont.className = 'content';
|
||||
const title = document.createElement('div'); title.className = 'title';
|
||||
title.textContent = ev.title || 'Без названия'; title.title = ev.title || '';
|
||||
// строка с источником, локацией и кратким описанием
|
||||
const meta = document.createElement('div'); meta.className = 'meta';
|
||||
const dateSpan = ev.end - ev.start > dayMs ? `${dfMD.format(ev.start)} — ${dfMD.format(new Date(ev.end - 1))}` : `${md}`;
|
||||
const parts = [ev.sourceLabel || '—'];
|
||||
if (ev.location) parts.push(ev.location);
|
||||
if (ev.description) parts.push(truncate(ev.description, 20));
|
||||
meta.textContent = `${dateSpan} • ${parts.join(' • ')}`;
|
||||
const head = document.createElement('div');
|
||||
head.style.display = 'flex'; head.style.alignItems = 'center'; head.style.gap = '8px';
|
||||
head.append(dot, title);
|
||||
cont.append(head, meta);
|
||||
row.append(badge, cont);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function updateWeekLists(){
|
||||
const now = new Date(); const ws = startOfWeek(now), we = endOfWeek(now);
|
||||
const from = new Date(ws.getTime()-3*dayMs), to = new Date(we.getTime()+3*dayMs);
|
||||
const items = await fetchEventsAllCalendars(from, to);
|
||||
renderVertical(document.getElementById('weekList'), document.getElementById('weekRange'), items, ws, we);
|
||||
renderVertical(document.getElementById('weekListMobile'), document.getElementById('weekRangeMobile'), items, ws, we);
|
||||
}
|
||||
|
||||
/* ===== offcanvas header height fix ===== */
|
||||
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,
|
||||
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';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let deskCalendar=null, mobCalendar=null;
|
||||
const desk = document.getElementById('calendarRight');
|
||||
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('calendarRightMobile');
|
||||
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 — время можно не указывать, но поля не блокируем (пусть пользователь вводит, тогда чек снимем на submit)
|
||||
// Визуально оставим как есть.
|
||||
}
|
||||
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();
|
||||
/* ===== Кнопки модала: редактирование/удаление (навешиваем ОДИН раз) ===== */
|
||||
$dt?.btnEdit?.addEventListener?.('click', ()=>{
|
||||
if (!currentDetail) return;
|
||||
const form = document.getElementById('eventForm');
|
||||
if (!form) { detailsModal?.hide(); return; }
|
||||
form.dataset.mode = 'edit';
|
||||
form.dataset.eventId = currentDetail.id;
|
||||
|
||||
// Базовые поля
|
||||
form.querySelector('[name="title"]').value = currentDetail.title || '';
|
||||
form.querySelector('[name="location"]').value = currentDetail.location || '';
|
||||
form.querySelector('[name="color"]').value = currentDetail.color || DEFAULT_EVENT_COLOR;
|
||||
form.querySelector('[name="all_day"]').checked = !!currentDetail.all_day;
|
||||
|
||||
// НОВОЕ: описание
|
||||
const descEl = form.querySelector('[name="description"]');
|
||||
if (descEl) descEl.value = currentDetail.description || '';
|
||||
|
||||
// Дата/время
|
||||
const startIso = currentDetail.start
|
||||
|| (currentDetail.start_date ? `${currentDetail.start_date}T${(currentDetail.start_time || '00:00').slice(0,5)}` : '');
|
||||
const endIso = currentDetail.end
|
||||
|| (currentDetail.end_date ? `${currentDetail.end_date}T${(currentDetail.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 = currentDetail.recurrence || 'one_time';
|
||||
const recInt = form.querySelector('[name="recurrence_interval"]');
|
||||
if (recInt) recInt.value = currentDetail.recurrence_interval || 1;
|
||||
|
||||
// НОВОЕ: until / count
|
||||
const untilEl = form.querySelector('[name="recurrence_until"]');
|
||||
if (untilEl) untilEl.value = (currentDetail.recurrence_until || '').slice(0,10);
|
||||
const countEl = form.querySelector('[name="recurrence_count"]');
|
||||
if (countEl) countEl.value = currentDetail.recurrence_count ?? '';
|
||||
|
||||
// НОВОЕ: weekly дни из маски
|
||||
if (currentDetail.weekdays_mask != null) setWeekdaysFromMask(currentDetail.weekdays_mask);
|
||||
|
||||
// Синхронизация UI
|
||||
try { typeof syncByRecurrence === 'function' && syncByRecurrence(); } catch(_){}
|
||||
|
||||
// Отдел (если есть select)
|
||||
const deptSel = form.querySelector('[name="department_id"]');
|
||||
if (deptSel && currentDetail.department){
|
||||
const val = currentDetail.department.id ?? currentDetail.department;
|
||||
if (val != null) deptSel.value = String(val);
|
||||
}
|
||||
|
||||
// Открываем форму редактирования
|
||||
detailsModal?.hide();
|
||||
setTimeout(()=>{
|
||||
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();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
|
||||
$dt?.btnDel?.addEventListener?.('click', async ()=>{
|
||||
if (!currentDetail) return;
|
||||
if (!confirm('Удалить это событие без возможности восстановления?')) return;
|
||||
try{
|
||||
await apiDelete(`${API_EVENTS}${currentDetail.id}/`);
|
||||
detailsModal?.hide();
|
||||
try{ deskCalendar?.refetchEvents?.(); mobCalendar?.refetchEvents?.(); }catch(_){}
|
||||
updateWeekLists();
|
||||
}catch(err){
|
||||
console.error('Delete event error', err);
|
||||
alert(err.status === 403 ? 'Недостаточно прав для удаления.' : 'Не удалось удалить событие.');
|
||||
}
|
||||
});
|
||||
|
||||
/* ===== Цвет: палитра и синхронизация с 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 === 'dept') {
|
||||
if (!DIGITS_RE.test(String(state.deptId))) { alert('Некорректный отдел'); return; }
|
||||
payload.department_id = Number(state.deptId);
|
||||
}
|
||||
|
||||
// Debug headers перед отправкой
|
||||
const postHeaders = { 'Content-Type': 'application/json', ...authHeaders() };
|
||||
// console.debug('Headers для POST/PATCH:', postHeaders);
|
||||
// Проверка токена
|
||||
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();
|
||||
[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 || {})));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
<script type="module">
|
||||
import { initCalendarWidget } from '{% static "js/components/calendarWidget.js" %}';
|
||||
window.calendarWidget = initCalendarWidget();
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user