Игорь Надеин c5123eb02f feat(calendar): интеграция выбора отдела через API /my-departments
feat(calendar): правый календарь с выбором источника и созданием событий (Bearer)
fix(layout): выровнял колонки к центральному контейнеру, убрал лишние отступы
fix(templates): убрана несуществующая тэг-функция токена; добавлен meta api-token
feat(chat/ui): полировка chat-root, скролла и кнопки «вниз»
style(feed): «Подробнее/Скрыть» — серые без подчёркивания

- добавлен эндпоинт DepartmentViewSet.my_departments для получения доступных отделов
  (HR → все отделы, иначе только руководимые/активные)
- настроены permissions для нового экшена
- фронтенд: заменена логика дропдауна календаря
  * загрузка списка отделов через /api/v1/departments/my-departments/
  * автогенерация пунктов меню с именами отделов
  * fallback на data-dept-id при пустом ответе
- обновлена подпись текущего календаря (кнопка и модалка «Создать событие»)
- сохранена логика работы с событиями (компания/отделы), переработан только источник данных для списка отделов
— Календарь (правая колонка)
  • Добавлен дропдаун возле «Календарь» для выбора источника: «Компания» и «Мои отделы».
  • «Мои отделы» подгружаются из GET /api/v1/employees/my-departments/ (JSON: [{id, name}]).
  • Рядом с «События» добавлена кнопка «+», открывающая модал создания события.
  • Создание события уходит в POST:
      - /api/v1/calendar/company-events/
      - /api/v1/calendar/departments/<id>/events/
    Форматы: all-day (date) либо с временем (start/end ISO). Не через сессию.
  • FullCalendar переключает источник событий в зависимости от выбранного календаря и
    ходит за ними с параметрами ?start=YYYY-MM-DD&end=YYYY-MM-DD.
  • Вертикальная «На этой неделе» рендерится из тех же данных (без лишних запросов,
    если события уже в памяти FullCalendar).
  • Мобильная версия — Offcanvas + тот же функционал.

— Авторизация / API
  • Все запросы к API — через Authorization: Bearer <token>.
  • В base.html добавлен мета-тег с токеном:
      <meta name="api-token" content="{{ request.user.api_token|default:'' }}">
  • Общий helper authFetch() отключает cookie/сессию и всегда проставляет заголовки.
  • Это устраняет 401 при GET и готовит почву под POST (ранее наблюдался 405 без POST-ручек).

— Вёрстка / контейнер
  • Левую и правую колонки «привязал» к центральному блоку (container-xxl), убраны
    чрезмерные внутренние паддинги, чтобы центральный контент не сжимался.
  • Уточнены вертикальные расстояния между карточками (gap / margin), карточки теперь
    реально «прижимаются» по вертикали, где это нужно.
  • В main контент тянется на всю доступную ширину внутри контейнера.

— Чаты
  • Объяснён и оставлен padding у .chat-root (верх/низ под шапку и композер, включая safe-area).
  • Кнопка «вниз» и стили скролла — без конфликтов с обёртками.

— Шаблоны
  • Удалён несуществующий тэг `{% api_access_token %}` (исправляет TemplateSyntaxError).
  • Вставлен рабочий meta name="api-token" вместо кастомного тега.

— Кнопки «Подробнее/Скрыть»
  • Сделаны серыми и без подчёркивания (btn btn-link text-secondary text-decoration-none).

Затронутые файлы (основные):
  • backend/templates/base.html
  • backend/templates/includes/rightbar_calendar.html        (новый/полностью переписан)
  • backend/templates/feed/_feed_cards.html                  (кнопки «Подробнее/Скрыть»)
  • backend/templates/includes/sidebar.html                  (мелкие правки отступов)
  • стили в базовом шаблоне: правки контейнеров/отступов/гридов
  • (JS в rightbar_calendar): authFetch, загрузка отделов, POST создания события, weekly strip

Примечания по бэку (ожидания фронта):
  • GET  /api/v1/employees/my-departments/ → 200 OK, JSON: [{ "id": <int>, "name": <str> }, …]
  • GET  /api/v1/calendar/company-events/?start=&end=
  • GET  /api/v1/calendar/departments/<id>/events/?start=&end=
  • POST /api/v1/calendar/company-events/                      (body: {title, date|start,end, allDay?, color?})
  • POST /api/v1/calendar/departments/<id>/events/            (аналогично)
  • Все ручки принимают Authorization: Bearer <JWT/токен>.
  • Если раньше был 405 на POST — включить create() во вьюсетах и добавить в роутер.
2025-09-08 17:19:57 +03:00

272 lines
11 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 req_tags %}
<style>
/* Offcanvas остаётся как есть (липкий ≥ lg) */
#sidebar{
--bs-offcanvas-border-color: transparent;
--bs-offcanvas-border-width: 0;
--bs-offcanvas-border-radius: 0;
border: 0 !important; border-radius: 0 !important; box-shadow: none !important;
background: var(--bs-body-bg);
}
@media (min-width: 992px){
#sidebar.offcanvas-lg{
position: sticky; top: var(--navbar-h, 60px);
height: calc(100dvh - var(--navbar-h, 60px));
align-self: start; overflow: auto;
border-right: .5px solid var(--bs-border-color); /* тонкая разделительная грань */
transform: none !important; visibility: visible !important;
}
}
/* ====== новый визуал навигации ====== */
.side-section{ margin: .6rem 0 1rem; }
.side-hd{
font-size:.78rem; font-weight:800; letter-spacing:.03em; text-transform:uppercase;
color: var(--bs-secondary-color);
padding: .25rem .5rem; margin: 0 0 .35rem .25rem;
}
.side-nav{ display:flex; flex-direction:column; gap:.15rem; }
.side-nav .nav-link{
position:relative; display:flex; align-items:center; gap:.6rem;
padding:.48rem .55rem; border-radius:12px;
color: var(--bs-body-color); text-decoration:none; border:0; background:transparent !important;
line-height:1.15;
transition: background .15s ease, color .15s ease, transform .05s ease, box-shadow .15s ease;
outline: none;
}
/* мягкий ховер */
.side-nav .nav-link:hover{
color: var(--bs-primary);
background: color-mix(in srgb, var(--bs-primary) 10%, transparent);
}
/* видимая клавиатурная фокусировка */
.side-nav .nav-link:focus-visible{
box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), .25);
}
/* активная «пилюля» + тонкий индикатор слева */
.side-nav .nav-link.active{
color: var(--bs-primary); font-weight: 800;
background: color-mix(in srgb, var(--bs-primary) 16%, transparent);
}
.side-nav .nav-link.active::before{
content:""; position:absolute; left:-6px; top:8px; bottom:8px; width:3px; border-radius:2px;
background: var(--bs-primary);
}
.side-icon{ width:1.35rem; height:1.35rem; display:inline-grid; place-items:center; flex:0 0 auto; }
.side-icon > i{ font-size:1.1rem; line-height:1; }
.icon-wrap{ position:relative; display:inline-grid; place-items:center; }
.nav-unread{
position:absolute; top:-4px; right:-8px;
min-width:16px; height:16px; padding:0 4px; border-radius:9999px;
font-size:10px; line-height:16px; font-weight:700;
background: var(--bs-danger); color:#fff; border:1px solid rgba(255,255,255,.6);
}
[data-bs-theme="dark"] .nav-unread{ border-color: rgba(0,0,0,.25); }
/* «отключённый» пункт выглядит реально disabled */
.side-nav .nav-link.disabled{ opacity:.5; pointer-events:none; }
</style>
<style>
.btn-theme{
display:inline-flex; align-items:center; gap:.5rem;
min-height:36px; padding:.35rem .75rem;
border-radius:999px;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
border:1px solid var(--bs-border-color);
transition: transform .08s ease, background-color .25s ease, border-color .25s ease;
}
.btn-theme:hover{
transform: translateY(-1px);
background: color-mix(in srgb, var(--bs-tertiary-bg) 90%, var(--bs-primary) 10%);
border-color: color-mix(in srgb, var(--bs-border-color), var(--bs-primary) 30%);
}
.btn-theme:focus-visible{
outline: 0;
box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), .25);
}
.btn-theme .bi{ font-size:1rem; line-height:1; }
.btn-theme .mode-label{ font-weight:700; font-size:.85rem; opacity:.9; }
@media (max-width:576px){
.btn-theme .mode-label{ display:none; }
}
</style>
<div id="sidebar"
class="offcanvas offcanvas-start offcanvas-lg"
tabindex="-1" aria-labelledby="sidebarLabel"
data-bs-backdrop="false" data-bs-scroll="true">
<div class="offcanvas-header border-bottom d-lg-none">
<h5 class="offcanvas-title" id="sidebarLabel">Меню</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body d-flex flex-column h-100 px-0">
<nav class="side-nav">
<a href="{% url 'feed:feed_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'feed_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'feed_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-megaphone"></i></span>
<span>Новости</span>
</a>
<a href="{% url 'employees:employees_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'employees_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'employees_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-people"></i></span>
<span>Сотрудники</span>
</a>
<a href="{% url 'employees:department_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'department_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'department_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-diagram-3"></i></span>
<span>Отделы</span>
</a>
<a href="{% url 'requests_app:my_requests' %}"
class="nav-link {% if request.resolver_match.url_name == 'my_requests' %}active{% endif %}"
{% if request.resolver_match.url_name == 'my_requests' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-journal-text"></i></span>
<span>Мои заявления</span>
</a>
{% if perms.requests_app.can_view_all_requests or user.headed_departments.exists %}
{% with url_name=request.resolver_match.url_name %}
<a href="{% url 'requests_app:all_requests' %}"
class="nav-link {% if url_name|in_list:'all_requests,request_process' %}active{% endif %}"
{% if url_name|in_list:'all_requests,request_process' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-clipboard-check"></i></span>
<span>Заявления сотрудников</span>
</a>
{% endwith %}
{% endif %}
{% with total=unread_total|default:0 %}
<a href="{% url 'communications:chat_list' %}"
class="nav-link {% if request.resolver_match.app_name == 'communications' %}active{% endif %}"
{% if request.resolver_match.app_name == 'communications' %}aria-current="page"{% endif %}
aria-label="Чаты{% if total|add:0 > 0 %}, непрочитанных {{ total }}{% endif %}">
<span class="side-icon icon-wrap">
<i class="bi-chat-dots"></i>
{% if total|add:0 > 0 %}
<span class="nav-unread" id="sidebarChatBadge" data-count="{{ total }}">{{ total }}</span>
{% else %}
<span class="nav-unread d-none" id="sidebarChatBadge" data-count="0">0</span>
{% endif %}
</span>
<span>Чаты</span>
</a>
{% endwith %}
<a href="{% url 'documents:document_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'document_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'document_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-file-earmark-text"></i></span>
<span>Документы</span>
</a>
<a href="#" class="nav-link disabled" tabindex="-1" aria-disabled="true">
<span class="side-icon"><i class="bi-calendar-event"></i></span>
<span>Календарь</span>
</a>
</nav>
<div class="mt-auto pt-3 small">
<button type="button" class="btn-theme js-theme-btn" id="themeBtnSidebar"
aria-label="Сменить тему" title="Сменить тему">
<i class="bi bi-circle-half" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<script>
// компактный апдейтер бейджа "Чаты" (форматирует 99+)
(function(){
const badge = document.getElementById('sidebarChatBadge');
if (!badge) return;
function render(n){
const v = Number(n)||0;
badge.dataset.count = String(v);
badge.textContent = v > 99 ? '99+' : String(v);
badge.classList.toggle('d-none', v <= 0);
}
render(badge.dataset.count);
// глобальное событие для real-time апдейта
window.addEventListener('chats:unread-total', (e)=> render(e?.detail?.total));
// fallback: суммируем [data-unread-count] на страницах списка чатов
document.addEventListener('DOMContentLoaded', ()=>{
const rows = document.querySelectorAll('[data-unread-count]');
if (!rows.length) return;
const total = Array.from(rows).reduce((acc, el)=> acc + (Number(el.textContent)||0), 0);
render(total);
});
})();
</script>
<script>
(function(){
const media = window.matchMedia('(prefers-color-scheme: dark)');
const root = document.documentElement;
const MODES = ['light','auto','dark'];
const ICONS = { light:'bi-sun-fill', auto:'bi-circle-half', dark:'bi-moon-stars' };
const LABEL = { light:'Светлая', auto:'Авто', dark:'Темная' };
// какие-то проекты любят хранить 'theme' в LS — используем тот же ключ
function getSavedMode(){ return localStorage.getItem('theme') || 'auto'; }
function setSavedMode(m){ try{ localStorage.setItem('theme', m); }catch(_){} }
function effective(mode){ return mode === 'auto' ? (media.matches ? 'dark' : 'light') : mode; }
function applyMode(mode, persist=true){
const eff = effective(mode);
root.setAttribute('data-bs-theme', eff);
if (persist) setSavedMode(mode);
updateButtons(mode);
}
function updateButtons(mode){
document.querySelectorAll('.js-theme-btn').forEach(btn=>{
const icon = btn.querySelector('.bi');
const lab = btn.querySelector('.mode-label');
// сброс иконок
if (icon){
icon.className = 'bi ' + (ICONS[mode] || ICONS.auto);
}
if (lab){ lab.textContent = LABEL[mode] || LABEL.auto; }
btn.dataset.mode = mode;
btn.title = `Тема: ${LABEL[mode]}. Нажмите, чтобы переключить`;
btn.setAttribute('aria-label', `Тема: ${LABEL[mode]}. Нажмите, чтобы переключить`);
});
}
function nextMode(cur){
const i = MODES.indexOf(cur); return MODES[(i >= 0 ? i : 0) + 1 === MODES.length ? 0 : i + 1];
}
document.addEventListener('DOMContentLoaded', ()=>{
const start = getSavedMode();
applyMode(start, false);
document.body.addEventListener('click', (e)=>{
const btn = e.target.closest('.js-theme-btn'); if (!btn) return;
e.preventDefault();
const cur = btn.dataset.mode || getSavedMode() || 'auto';
const nxt = nextMode(cur);
applyMode(nxt, true);
});
// если системная тема поменялась и у нас «Авто», нужно обновить
media.addEventListener('change', ()=>{ if ((localStorage.getItem('theme') || 'auto') === 'auto'){ applyMode('auto', false); } });
});
})();
</script>