Corp/backend/templates/includes/rightbar_calendar.html

394 lines
18 KiB
HTML
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;
}
/* Правый столбец (десктоп): контейнер без скролла, скролл внутри .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>