394 lines
18 KiB
HTML
394 lines
18 KiB
HTML
{% 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;
|
||
}
|
||
|
||
/* Правый столбец (десктоп): контейнер без скролла, скролл внутри .card-body */
|
||
@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; /* iOS 16+ (переопределит) */
|
||
}
|
||
#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)); /* iOS */
|
||
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);
|
||
}
|
||
</style>
|
||
|
||
|
||
{# десктопная правая колонка #}
|
||
<div class="rightbar d-none d-lg-block">
|
||
{# передай сюда один id или список через запятую: "12" или "12,34" #}
|
||
<div class="card rightbar-card" data-dept-id="{{ dept_ids|default:'' }}">
|
||
<div class="card-header d-flex align-items-center justify-content-between">
|
||
<h6 class="card-title fw-semibold mb-0">Календарь</h6>
|
||
<span class="card-sub text-muted">события</span>
|
||
</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"
|
||
aria-labelledby="rightbarOffcanvasLabel"
|
||
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>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function(){
|
||
if (!window.FullCalendar) return;
|
||
|
||
/* ===== endpoints ===== */
|
||
const API_COMPANY = '/api/v1/calendar/company-events/';
|
||
const holder = document.querySelector('.rightbar-card');
|
||
const deptIds = (holder?.dataset.deptId || '')
|
||
.split(',')
|
||
.map(s => s.trim())
|
||
.filter(Boolean);
|
||
|
||
const deptUrls = deptIds.map(id => `/api/v1/calendar/departments/${encodeURIComponent(id)}/events/`);
|
||
|
||
/* ===== utils ===== */
|
||
const INLINE_SIZE_PX = '10px';
|
||
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;
|
||
|
||
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 : (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));
|
||
return { title, start:s, end:e, allDay, color: ev.color || null, recurrence: ev.recurrence || null };
|
||
}
|
||
|
||
async function fetchJSON(url){
|
||
const r = await fetch(url, { credentials: 'same-origin' });
|
||
if (!r.ok) return [];
|
||
const data = await r.json();
|
||
return Array.isArray(data) ? data : (data.results || data.items || data.events || []);
|
||
}
|
||
|
||
function qsRange(start, end){
|
||
// API понимает и start/end, и from/to — отдаём короткие даты
|
||
const s = start.toISOString().slice(0,10);
|
||
const e = end.toISOString().slice(0,10);
|
||
return `?start=${s}&end=${e}`;
|
||
}
|
||
|
||
async function fetchEventsCombined(start, end){
|
||
const q = qsRange(start, end);
|
||
const urls = [API_COMPANY + q, ...deptUrls.map(u=> u + q)];
|
||
if (urls.length === 0) return [];
|
||
const parts = await Promise.all(urls.map(u => fetchJSON(u).catch(() => [])));
|
||
// плоский массив
|
||
return parts.flat();
|
||
}
|
||
|
||
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 badge = document.createElement('div'); badge.className = 'date-badge';
|
||
badge.innerHTML = `<div class="dow">${dfDow.format(ev.start)}</div><div class="md">${dfMD.format(ev.start)}</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 span = ev.end - ev.start > dayMs ? `${dfMD.format(ev.start)} — ${dfMD.format(new Date(ev.end - 1))}` : `${dfMD.format(ev.start)}`;
|
||
meta.textContent = span;
|
||
cont.append(title, meta);
|
||
row.append(badge, cont);
|
||
container.appendChild(row);
|
||
});
|
||
}
|
||
|
||
async function updateWeekLists(sourceCal){
|
||
const now = new Date(); const ws = startOfWeek(now), we = endOfWeek(now);
|
||
// если календарь уже загрузил события — используем их, чтобы не делать лишний запрос
|
||
let items = sourceCal ? (sourceCal.getEvents?.() || []).map(e => ({
|
||
title: e.title, start: e.start, end: e.end, allDay: e.allDay, color: e.extendedProps?.color, recurrence: e.extendedProps?.recurrence
|
||
})) : [];
|
||
if (!items.length){
|
||
items = await fetchEventsCombined(new Date(ws.getTime()-3*dayMs), new Date(we.getTime()+3*dayMs));
|
||
}
|
||
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 data = await fetchEventsCombined(info.start, info.end);
|
||
success(data);
|
||
}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 => {
|
||
// цвет из API (если задан)
|
||
if (info.event.extendedProps?.color){
|
||
info.el.style.backgroundColor = info.event.extendedProps.color;
|
||
info.el.style.borderColor = info.event.extendedProps.color;
|
||
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){
|
||
t.style.fontSize = INLINE_SIZE_PX;
|
||
t.style.lineHeight = '1.02';
|
||
t.style.letterSpacing = '-0.2px';
|
||
t.style.display = '-webkit-box';
|
||
t.style.webkitLineClamp = '3';
|
||
t.style.webkitBoxOrient = 'vertical';
|
||
t.style.overflow = 'hidden';
|
||
t.style.whiteSpace = 'normal';
|
||
t.style.textOverflow = 'ellipsis';
|
||
t.style.fontWeight = '600';
|
||
t.style.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); }); deskCalendar.render(); }
|
||
const mob = document.getElementById('calendarRightMobile');
|
||
if (mob){ mobCalendar = new FullCalendar.Calendar(mob, fcOpts); mobCalendar.on('loading', (l)=>{ if(!l) updateWeekLists(mobCalendar); }); mobCalendar.render(); }
|
||
|
||
setTimeout(()=> updateWeekLists(deskCalendar || mobCalendar), 100);
|
||
[deskCalendar, mobCalendar].forEach(cal => cal?.on('datesSet', ()=> updateWeekLists(cal)));
|
||
});
|
||
</script> |