Corp/backend/templates/includes/rightbar_calendar.html.backup
Игорь Надеин 4d04aafdb9 refactor: Replace inline CSS with components (Phase 3)
- Replaced feed-* inline styles in base.html with feed-cards.css component (~60 lines)
- Replaced ios-search inline styles in 3 templates with ios-search.css component
  * employees/employees_list.html
  * search/results.html
  * employees/department_list.html
- Eliminated ~150 lines of duplicate CSS across templates
- All 17 tests passing
2025-10-28 08:54:38 +03:00

1130 lines
52 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

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

{% load static %}
<style>
/* ====== БАЗА ====== */
.calendar-wrap{
min-height: 360px;
border-radius: 10px;
overflow: hidden;
background: var(--bs-body-bg);
box-shadow: inset 0 1px 3px color-mix(in srgb, var(--bs-body-color), transparent 92%);
border: 0;
}
.rightbar-card{
height: 100%;
display: flex;
flex-direction: column;
border-radius: 16px;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
background: var(--bs-body-bg);
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 8px 24px rgba(0,0,0,.08);
min-height: 0;
}
.rightbar-card .card-header{
flex: 0 0 auto;
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
padding: .6rem .9rem;
}
.rightbar-card .card-body{
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: .75rem;
}
@media (min-width: 992px){
.rightbar{
position: sticky; top: var(--navbar-h);
height: calc(100dvh - var(--navbar-h));
padding: 1rem 1rem 1rem .75rem;
overflow: hidden;
}
}
/* ====== FullCalendar — компактно ====== */
.fc{
font-size: 0.7rem;
--fc-border-color: var(--bs-border-color);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: transparent;
--fc-today-bg-color: color-mix(in srgb, var(--bs-primary), transparent 85%);
--fc-event-bg-color: var(--bs-primary);
--fc-event-border-color: var(--bs-primary);
--fc-event-text-color: var(--bs-body-bg);
}
.fc-theme-standard .fc-scrollgrid{ border: 0 !important; }
.fc-theme-standard td, .fc-theme-standard th{ border: 0 !important; }
.fc .fc-toolbar{ padding: .7rem .9rem; background: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); justify-content: space-between; }
.fc .fc-toolbar-title{ font-size: 1rem; font-weight: 600; color: var(--bs-body-color); }
.fc .fc-button{ border-radius: 8px; font-size: .82rem; padding: .25rem .5rem; background: transparent; border: none; color: var(--bs-primary); }
.fc .fc-button:hover{ background: rgba(var(--bs-primary-rgb), .1); }
.fc .fc-daygrid-day-frame{ padding: 2px 3px !important; }
.fc .fc-daygrid-day-number{
font-size: .82rem; width: 100%; height: 1.6rem; display: flex; align-items: center; justify-content: center;
font-weight: 600; color: var(--bs-body-color); text-decoration: none !important;
}
.fc .fc-day-today{ background: none !important; }
.fc .fc-day-today .fc-daygrid-day-number{
color: var(--bs-primary); font-weight: 700; border: 1px solid var(--bs-primary); border-radius: 50%;
}
.fc .fc-day-other .fc-daygrid-day-number{ opacity: .4; }
.fc .fc-daygrid-day-events{ padding: 0 2px 2px !important; row-gap: 2px !important; }
.fc .fc-daygrid-event-harness{ margin: 0 0 2px 0 !important; }
.fc .fc-daygrid-event .fc-event-time{ display: none !important; }
.fc .fc-daygrid-event{
border-radius: 6px;
padding: .1rem .25rem !important;
line-height: 1.02 !important;
background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 75%);
border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 60%);
color: var(--bs-body-color);
}
.fc .fc-daygrid-event .fc-event-title{
font-size: .62rem !important; line-height: 1.02 !important; letter-spacing: -0.2px; font-weight: 600;
font-family: "Arial Narrow","Roboto Condensed",system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display: -webkit-box !important; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
overflow: hidden !important; white-space: normal !important; text-overflow: ellipsis; max-height: calc(1.02em * 3);
}
/* ====== Вертикальная лента недели ====== */
.week-strip-header{ margin-top: .6rem; display: flex; align-items: center; justify-content: space-between; color: var(--bs-secondary-color); font-size: .8rem; }
.week-vertical{ margin-top: .6rem; display: flex; flex-direction: column; gap: .4rem; }
.week-vertical .week-row{ display: grid; grid-template-columns: 56px 1fr; align-items: start; gap: .6rem; padding: .5rem .6rem; border-radius: 12px; background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 86%); border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 70%); }
.week-vertical .week-row:hover{ background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 82%); }
.date-badge{ display: grid; place-items: center; border-radius: 10px; padding: .35rem .25rem; background: color-mix(in srgb, var(--bs-primary), var(--bs-body-bg) 92%); border: 1px solid color-mix(in srgb, var(--bs-primary), transparent 75%); text-align: center; line-height: 1.05; }
.date-badge .dow{ font-size: .7rem; font-weight: 700; color: var(--bs-primary); text-transform: uppercase; }
.date-badge .md { font-size: .74rem; font-weight: 700; color: var(--bs-body-color); }
.week-vertical .content .title{ font-size: .8rem; font-weight: 600; color: var(--bs-body-color); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; white-space: normal; }
.week-vertical .content .meta{ margin-top: .15rem; font-size: .72rem; color: var(--bs-secondary-color); }
/* Offcanvas (мобайл) */
#rightbarOffcanvas{ height: 100dvh; height: 100svh; }
#rightbarOffcanvas .offcanvas-body{
display: flex; flex-direction: column; gap: .6rem;
height: calc(100dvh - var(--rb-offcanvas-head, 56px));
height: calc(100svh - var(--rb-offcanvas-head, 56px));
min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
#rightbarOffcanvas .calendar-wrap{ flex: 0 0 auto; }
#rightbarOffcanvas .week-vertical{ flex: 0 0 auto; }
.calendar-fab{ position: fixed; right: 1rem; bottom: 1rem; z-index: 1060; width: 52px; height: 52px; border-radius: 9999px; box-shadow: 0 8px 24px rgba(0,0,0,.18); }
/* ====== Color picker (palette) ====== */
.color-swatch{
width: 26px; height: 26px;
border: 1px solid var(--bs-border-color);
border-radius: 9999px;
padding: 0;
cursor: pointer;
background: transparent;
}
.color-swatch.active{
outline: 2px solid var(--bs-primary);
outline-offset: 2px;
}
/* ====== Event details modal ====== */
.color-dot{
width: 12px; height: 12px; border-radius: 9999px; display: inline-block;
vertical-align: middle; margin-right: 6px; border: 1px solid #00000022;
}
</style>
{# десктопная правая колонка #}
<div class="rightbar d-none d-lg-block px-0">
<div class="card rightbar-card" data-dept-id="{{ dept_ids|default:'' }}">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<h6 class="card-title fw-semibold mb-0">Календарь</h6>
{# Выпадающий выбор: Компания / Отделы #}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
id="calendarChooserBtn" data-bs-toggle="dropdown" aria-expanded="false">
Компания
</button>
<ul class="dropdown-menu" aria-labelledby="calendarChooserBtn" id="calendarChooserMenu">
<li><button class="dropdown-item" type="button" data-cal="company">Компания</button></li>
<li><hr class="dropdown-divider"></li>
{# пункты отделов добавим из JS по data-dept-id #}
</ul>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="card-sub text-muted">События</span>
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#eventCreateModal"
aria-label="Создать событие"><i class="bi-plus-lg"></i></button>
</div>
</div>
<div class="card-body">
<div id="calendarRight" class="calendar-wrap"></div>
<div class="week-strip-header">
<span><i class="bi-calendar-week me-1"></i> На этой неделе</span>
<span id="weekRange" class="text-muted"></span>
</div>
<div id="weekList" class="week-vertical" aria-live="polite"></div>
</div>
</div>
</div>
{# мобилка: offcanvas справа + плавающая кнопка #}
<button class="btn btn-primary calendar-fab d-lg-none" type="button"
data-bs-toggle="offcanvas" data-bs-target="#rightbarOffcanvas"
aria-controls="rightbarOffcanvas" aria-label="Календарь">
<i class="bi-calendar-event"></i>
</button>
<div class="offcanvas offcanvas-end d-lg-none" id="rightbarOffcanvas" tabindex="-1"
data-bs-scroll="true" style="--bs-offcanvas-width: min(92vw, 420px);">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="rightbarOffcanvasLabel">Календарь</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body">
<div id="calendarRightMobile" class="calendar-wrap"></div>
<div class="week-strip-header">
<span><i class="bi-calendar-week me-1"></i> На этой неделе</span>
<span id="weekRangeMobile" class="text-muted"></span>
</div>
<div id="weekListMobile" class="week-vertical" aria-live="polite"></div>
</div>
</div>
<!-- Модал создания события — ОБНОВЛЁННЫЙ -->
<div class="modal fade" id="eventCreateModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="eventForm">
<div class="modal-header">
<h5 class="modal-title">Новое событие</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">Заголовок</label>
<input type="text" class="form-control" name="title" required>
</div>
<div class="mb-3">
<label class="form-label" for="eventDescription">Описание</label>
<textarea class="form-control" id="eventDescription" name="description" rows="3" ></textarea>
</div>
<div class="row g-2 align-items-end">
<div class="col-6">
<label class="form-label">Начало</label>
<input type="datetime-local" class="form-control" name="start">
</div>
<div class="col-6">
<label class="form-label">Окончание</label>
<input type="datetime-local" class="form-control" name="end">
</div>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" value="1" id="allDayChk" name="all_day" checked>
<label class="form-check-label" for="allDayChk">Весь день</label>
</div>
<hr class="my-3">
<div class="row g-2">
<div class="col-6">
<label class="form-label">Повторение</label>
<select class="form-select" name="recurrence" id="recurrenceSelect">
<option value="one_time" selected>Одноразовое</option>
<option value="hourly">Ежечасно</option>
<option value="daily">Ежедневно</option>
<option value="weekly">Еженедельно</option>
<option value="monthly">Ежемесячно</option>
<option value="annual">Ежегодно</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Интервал</label>
<input type="number" class="form-control" name="recurrence_interval" id="recurrenceInterval" value="1" min="1" step="1">
<div class="form-text">Каждые N часов/дней/недель/месяцев</div>
</div>
</div>
<div class="mt-2 d-none" id="weeklyBlock">
<label class="form-label d-block mb-1">Дни недели</label>
<div class="d-flex flex-wrap gap-2">
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="0"> Пн
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="1"> Вт
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="2"> Ср
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="3"> Чт
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="4"> Пт
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="5"> Сб
</label>
<label class="form-check">
<input class="form-check-input" type="checkbox" name="weekdays" value="6"> Вс
</label>
</div>
<div class="form-text">Если пусто — возьмём день недели даты начала.</div>
</div>
<div class="row g-2 mt-2">
<div class="col-6">
<label class="form-label">Повторять до</label>
<input type="date" class="form-control" name="recurrence_until" id="recurrenceUntil">
</div>
<div class="col-6">
<label class="form-label">Кол-во повторов</label>
<input type="number" class="form-control" name="recurrence_count" id="recurrenceCount" min="1" step="1">
<div class="form-text">Нельзя вместе с «Повторять до»</div>
</div>
</div>
<hr class="my-3">
<div class="row g-2">
<div class="col-6">
<label class="form-label">Цвет</label>
<div class="d-flex align-items-center gap-2">
<input type="color" class="form-control form-control-color" name="color" id="eventColorInput" value="#0d6efd" title="Выберите цвет">
<div class="d-flex flex-wrap gap-1" id="colorPalette" aria-label="Быстрый выбор цвета">
<button type="button" class="color-swatch" data-color="#0d6efd" style="background-color:#0d6efd" title="Синий"></button>
<button type="button" class="color-swatch" data-color="#198754" style="background-color:#198754" title="Зелёный"></button>
<button type="button" class="color-swatch" data-color="#dc3545" style="background-color:#dc3545" title="Красный"></button>
<button type="button" class="color-swatch" data-color="#fd7e14" style="background-color:#fd7e14" title="Оранжевый"></button>
<button type="button" class="color-swatch" data-color="#ffc107" style="background-color:#ffc107" title="Жёлтый"></button>
<button type="button" class="color-swatch" data-color="#0dcaf0" style="background-color:#0dcaf0" title="Голубой"></button>
<button type="button" class="color-swatch" data-color="#6f42c1" style="background-color:#6f42c1" title="Фиолетовый"></button>
<button type="button" class="color-swatch" data-color="#212529" style="background-color:#212529" title="Чёрный"></button>
</div>
</div>
</div>
<div class="col-6">
<label class="form-label">Локация</label>
<input type="text" class="form-control" name="location" placeholder="Переговорка, адрес…">
</div>
</div>
<small class="text-secondary d-block mt-2">
Календарь: <span id="eventTargetLabel">Компания</span>
</small>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<!-- ===== Modal: Просмотр события ===== -->
<div class="modal fade" id="eventDetailsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title d-flex align-items-center gap-2">
<span id="detailColorDot" class="color-dot"></span>
<span id="detailTitle">Событие</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<dl class="row mb-0">
<dt class="col-4">Когда</dt>
<dd class="col-8" id="detailWhen">—</dd>
<dt class="col-4">Где</dt>
<dd class="col-8" id="detailLocation">—</dd>
<dt class="col-4">Описание</dt>
<dd class="col-8" id="detailDescription">—</dd>
<dt class="col-4">Контекст</dt>
<dd class="col-8" id="detailScope">—</dd>
<dt class="col-4">Повторение</dt>
<dd class="col-8" id="detailRecurrence">—</dd>
</dl>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-danger me-auto" id="btnDeleteEvent">Удалить</button>
<button type="button" class="btn btn-primary" id="btnEditEvent">Редактировать</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</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>