Corp/backend/templates/base.html
Игорь Надеин 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

1099 lines
44 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 api_tags %}
<!doctype html>
<html lang="ru" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="color-scheme" content="light dark">
{% if user.is_authenticated %}
<meta name="api-access" content="{% api_access_token request %}">
{% endif %}
<title>{% block title %}EUSRR{% endblock %}</title>
<!-- Bootstrap / Icons / FullCalendar -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<!-- Базовые стили приложения (+ feed) -->
<style>
:root{
--sidebar-w: 190px;
--rightbar-w: 350px;
--navbar-h: 60px;
--bs-primary:#007AFF;
--bs-link-color: var(--bs-primary);
--bs-link-hover-color:#0062CC;
/* feed tokens */
--feed-radius: 18px;
--feed-shadow: 0 1px 2px rgba(0,0,0,.04), 0 10px 30px rgba(0,0,0,.08);
--feed-shadow-hover: 0 4px 10px rgba(0,0,0,.06), 0 16px 40px rgba(0,0,0,.12);
}
[data-bs-theme="dark"] {
--bs-body-bg:#1C1C1E;
--bs-body-color:#F2F2F7;
--bs-border-color:#3A3A3C;
--bs-tertiary-bg:#2C2C2E;
--bs-primary:#0A84FF;
--bs-link-color:var(--bs-primary);
--bs-link-hover-color:#59AFFF;
}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
font-size:17px;
padding-top:var(--navbar-h);
overflow-x:hidden;
background-color:var(--bs-body-bg);
}
@supports(padding-top: env(safe-area-inset-top)){
body{ padding-top: calc(var(--navbar-h) + env(safe-area-inset-top)); }
}
.btn{ border-radius:10px; min-height:44px; transition:background-color .3s, transform .1s; }
.btn:hover{ transform:scale(1.02); }
.form-control{ border-radius:10px; height:44px; font-size:17px; }
.form-control:focus{ box-shadow:0 0 0 2px rgba(var(--bs-primary-rgb), .25); border-color: var(--bs-primary); }
.dropdown-menu{ border-radius:13px; box-shadow:0 6px 24px rgba(0,0,0,.12); }
/* navbar */
.app-navbar{ position:fixed; inset:0 0 auto 0; z-index:1030; border-bottom:.5px solid var(--bs-border-color); }
/* layout */
.app-layout{ display:block; }
@media (min-width: 992px){
.app-layout.with-sidebar.with-rightbar{
display:grid; grid-template-columns: var(--sidebar-w) 1fr var(--rightbar-w); align-items:start;
}
.app-layout.with-sidebar:not(.with-rightbar){
display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:start;
}
}
/* левый sidebar (sticky ≥lg) */
#sidebar{ --bs-offcanvas-width: var(--sidebar-w); border-radius:0 13px 13px 0; }
@media (min-width: 992px){
#sidebar.offcanvas-lg{
position:sticky; top:var(--navbar-h);
height:calc(100dvh - var(--navbar-h));
align-self:start; overflow:auto;
border-right:.5px solid var(--bs-border-color);
background:var(--bs-body-bg);
transform:none !important; visibility:visible !important;
}
}
/* бейдж возле колокола */
.bell-wrapper{ position:relative; line-height:1; display:inline-block; }
.bell-dot{
position:absolute; top:-2px; right:-2px;
width:9px; height:9px; border-radius:999px;
background:var(--bs-danger); color:#fff;
font-size:7px; line-height:9px; font-weight:700;
display:flex; align-items:center; justify-content:center;
box-shadow:0 0 0 1px rgba(255,255,255,.65);
}
/* mobile FAB */
.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);
}
/* ===== feed styles (карточки ленты) ===== */
.feed-header{ display:flex; align-items:center; gap:.75rem; margin-bottom:1rem; }
.feed-header .title{ font-weight:800; letter-spacing:.2px; margin:0; }
.feed-stream{ display:flex; flex-direction:column; gap:0; }
.feed-card{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
border-radius: var(--feed-radius);
background: var(--bs-body-bg);
box-shadow: var(--feed-shadow);
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
overflow:hidden;
}
.feed-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
.feed-hd{ display:flex; align-items:center; gap:.75rem; padding:.9rem 1rem .5rem 1rem; }
.feed-ava{
width:42px; height:42px; border-radius:9999px; overflow:hidden;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: var(--bs-tertiary-bg);
display:grid; place-items:center; flex:0 0 auto;
}
.feed-ava img{ width:100%; height:100%; object-fit:cover; display:block; }
.feed-meta{ line-height:1.2; }
.feed-author{ font-weight:700; color: var(--bs-body-color); }
.feed-sub{ font-size:.82rem; color: var(--bs-secondary-color); }
.feed-pin{
margin-left:auto; font-size:.75rem;
padding:.2rem .5rem; border-radius:999px;
background:#fff3cd; color:#664d03; border:1px solid #ffe69c;
}
[data-bs-theme="dark"] .feed-pin{ background:#3b2e04; color:#ffda6a; border-color:#4f3f0a; }
.feed-body{ padding:.25rem .75rem .5rem .75rem; position:relative; }
.feed-title {
font: 700 1.3rem/1.5 var(--bs-body-font-family);
margin: 0.25rem 0 0.35rem;
color: #4B4B4B; /* Альтернатива: rgba(0,0,0,.8) */
}
.feed-img{ position:relative; border-top:1px solid var(--bs-border-color); background: var(--bs-tertiary-bg); }
.feed-img img{ display:block; width:100%; height:auto; aspect-ratio:16/9; object-fit:cover; }
.feed-actions{
display:flex; align-items:center; gap:.5rem; padding:0rem 1rem .9rem 1rem;
border-top:1px solid var(--bs-border-color); position:relative; z-index:2;
}
.btn-ghost{
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
color: var(--bs-body-color);
padding:.35rem .6rem; border-radius:999px; font-size:.9rem;
}
.btn-ghost:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.btn-ghost .bi{ font-size:1rem; margin-right:.35rem; vertical-align:-2px; }
/* «Новые сотрудники» */
.feed-new{
border-radius: var(--feed-radius);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
background: var(--bs-body-bg);
box-shadow: var(--feed-shadow);
padding:.9rem 1rem;
}
/* stretched-link не перекрывает футер/формы */
.feed-body .stretched-link::after,
.feed-img .stretched-link::after { z-index: 1; }
.collapse{ position:relative; z-index:2; }
/* подпись «Скрыть/число» у кнопки комментариев по aria-expanded */
.btn-ghost .txt-close{ display:none; }
.btn-ghost[aria-expanded="true"] .txt-open{ display:none; }
.btn-ghost[aria-expanded="true"] .txt-close{ display:inline; }
/* Буквенные разделители (липкие) */
.alpha-header{
position: sticky; top: calc(var(--navbar-h) + 8px);
z-index: 1;
background: color-mix(in srgb, var(--bs-body-bg) 92%, transparent);
backdrop-filter: blur(6px);
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
color: var(--bs-secondary-color);
display: inline-flex; align-items: center; gap:.5rem;
padding: .2rem .6rem; border-radius: 999px; font-weight: 700; font-size: .85rem;
margin: .25rem 0 .5rem .25rem;
}
.alpha-dot{ width:6px; height:6px; border-radius:999px; background: var(--bs-primary); opacity:.6; }
/* Иконки-действия справа в карточке */
.action-btn{ min-height: unset; padding:.35rem .6rem; }
.action-btn .bi{ font-size: 1rem; }
/* Переносим длинные слова/ссылки, сохраняем переносы строк */
.feed-text, .feed-full{
overflow-wrap: anywhere;
width: 100%;
margin-right: 0;
padding-right: 0;
}
/* чтобы кнопка «Подробнее» не была перекрыта stretched-link */
.readmore-toggle{ position: relative; z-index: 2; }
</style>
<style>
/* Base card / layout */
.ios-card{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
border-radius: var(--feed-radius);
background: var(--bs-body-bg);
box-shadow: var(--feed-shadow);
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
overflow:hidden;
}
.ios-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
.ios-header{
position: sticky; top: var(--navbar-h, 0px);
z-index: 5; padding:.75rem 0;
backdrop-filter: saturate(180%) blur(12px);
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
border-bottom: none !important;
}
.ios-title{ font-weight: 800; letter-spacing: -.01em; }
.row.g-ios{ align-items: flex-start; }
.ios-ava, .ios-ava--ph{
display: grid; place-items: center; background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color); font-size: 3rem; line-height: 1;
width: 100%; height: 100%; border-radius: 28px;
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ava-wrap{ position: relative; width:132px; height:132px; margin:0 auto 12px; display:block; }
.btn-icon{
width:36px; height:36px; padding:0; border-radius:9999px; display:grid; place-items:center;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.btn-icon:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.ios-name{ font-weight:800; font-size:1.25rem; }
.ios-role{ font-size:.9rem; color: var(--bs-secondary-color); }
.chips{ display:flex; flex-wrap:wrap; gap:.5rem; }
.chip{
padding:.35rem .65rem; border-radius:9999px; font-size:.85rem; line-height:1;
background: var(--bs-secondary-bg); color: var(--bs-body-color);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ios-collapse-trigger{
width:100%; display:flex; align-items:center; justify-content:space-between; gap:.75rem;
padding:.6rem .9rem; cursor:pointer; border-radius:14px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.ios-collapse-caret{ transition: transform .25s ease; }
.ios-collapse-trigger[aria-expanded="true"] .ios-collapse-caret{ transform: rotate(180deg); }
.ios-list{
border-radius:14px; overflow:hidden; background: var(--bs-body-bg);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell{
display:flex; align-items:center; gap:.75rem; padding:.85rem 1rem;
border-bottom:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell:last-child{ border-bottom:0; }
.ios-cell i{ width:1.25rem; text-align:center; opacity:.9; }
.ios-cell a{ text-decoration:none; color:inherit; }
.ios-section-title{ font-weight:700; font-size:1rem; margin-bottom:.5rem; }
.rounded-ios{ border-radius:14px !important; }
/* Departments list */
.ios-cell.dept-item{ justify-content: space-between; }
.ios-cell.dept-head{ border-left: 0; background: transparent; }
.dept-line{ display:flex; align-items:center; gap:.5rem; }
.dept-name{ font-weight:700; }
.dept-role{ color: var(--bs-secondary-color); }
.badge-head{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px;
background: color-mix(in srgb, var(--bs-warning) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-warning) 60%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-warning) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1;
white-space: nowrap;
}
.badge-head .bi{ font-size:.95rem; line-height:1; }
/* NEW: blue pill like .badge-head, for the gear edit button */
.badge-gear{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px; cursor:pointer;
background: color-mix(in srgb, var(--bs-primary) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-primary) 65%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-primary) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1; white-space: nowrap;
}
.badge-gear .bi{ font-size:.95rem; line-height:1; }
.badge-gear:hover{ background: color-mix(in srgb, var(--bs-primary) 20%, var(--bs-body-bg)); }
@media (max-width: 420px){
.badge-head .lbl, .badge-gear .lbl{ display:none; }
}
.ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-collapse-trigger + .collapse > .ios-list,
.ios-collapse-trigger + .collapse > .chips { margin-top: .5rem; }
.collapsing { overflow: hidden; transition: height .35s ease; }
.ios-acc{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
border-radius:14px; background: var(--bs-body-bg); overflow: hidden;
}
.ios-acc + .ios-acc{ margin-top:.75rem; }
.ios-acc .ios-collapse-trigger{ border:0; border-radius:0; background: transparent; width:100%; padding:.6rem .9rem; }
.ios-acc .ios-collapse-trigger:focus{ box-shadow:none; }
.ios-acc .collapse{ margin:0 !important; }
.ios-acc .ios-list{ border:0; border-radius:0; }
.ios-acc .chips{ margin:0; }
.ios-acc .ios-acc-body{
padding:.5rem .25rem .75rem;
border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
/* Activity block adjustments */
#activity .p-4{ padding:0 !important; }
#activity .ios-section-title{ padding:1rem 1rem .5rem !important; margin:0 !important; }
#activity .activity-feed{ margin:0 !important; }
#activity .activity-feed .feed-card{
width:100% !important; max-width:none !important; border:0 !important; box-shadow:none !important;
border-radius:0 !important; margin:0 !important;
}
#activity .activity-feed{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .feed-stream{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .row > *{ flex:0 0 100% !important; max-width:100% !important; }
#activity .feed-actions{ border-top: 0 !important; }
#activity .collapse .bg-body-tertiary.border{ border: 0 !important; }
/* Skills UI */
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;cursor:pointer;border-style:dashed}
#skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease}
#skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem}
.chip.chip-skill{position:relative;padding:.35rem .65rem}
.chip.chip-skill .lbl{transition:opacity .15s ease}
.chip.chip-skill .chip-del-btn{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:transparent;border:0;padding:0;margin:0;cursor:pointer;
color:var(--bs-danger);font-size:1rem;line-height:1;
visibility:hidden;opacity:0;transition:opacity .15s ease;z-index:1
}
.chip.chip-skill .chip-del-btn .bi{pointer-events:none}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .lbl{opacity:0}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .chip-del-btn,
#skillsBlock[data-editable="1"] .chip.chip-skill:focus-within .chip-del-btn{visibility:visible;opacity:1}
.skills-empty{color:var(--bs-secondary-color);font-size:.875rem;margin-right:.25rem}
/* Dept inline editor */
.dept-edit-btn{
width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:999px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
}
.dept-edit-btn:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.dept-editor-wrap{ flex: 0 0 100%; width:100%; margin-top:.5rem; }
.dept-editor form{ display:flex; align-items:center; gap:.5rem; }
.dept-role-select{ min-width: 180px; }
/* полностью прячем роль, когда открыт редактор */
.dept-item.is-editing .dept-role{ display:none !important; }
/* не даём блоку с кнопками сжиматься/уезжать */
.dept-item > .d-flex{
flex: 0 1 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: .35rem .5rem;
max-width: 100%;
}
/* 2) Левую часть реально можно ужать с многоточием */
.dept-line{ min-width: 0; }
/* 3) Сами пилюли не растягиваются */
.badge-head,
.badge-gear{ flex: 0 0 auto; }
/* 4) Строка отдела допускает перенос элементов (на всякий) */
.ios-cell{ flex-wrap: wrap; }
/* 5) При открытом редакторе полностью прячем роль */
.dept-item.is-editing .dept-role{ display:none !important; }
/* 6) На узких — прячем подписи внутри пилюль, чтобы всё поместилось */
@media (max-width: 576px){
.badge-head .lbl,
.badge-gear .lbl{ display:none; }
}
/* (аккуратная связка кнопки-разделителя и панели списка) — у тебя была потеряна скобка */
.ios-acc .ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-acc .ios-acc-body { border-top: 0; }
/* Группа иконок у заголовка */
.header-icon-group .btn-icon{ width:36px; height:36px; }
/* Варианты окраски, дружат со светлой/тёмной темой */
.btn-icon--primary{
color: var(--bs-primary);
border-color: color-mix(in srgb, var(--bs-primary) 30%, var(--bs-border-color));
}
.btn-icon--primary:hover{
background: color-mix(in srgb, var(--bs-primary) 16%, var(--bs-body-bg));
}
.btn-icon--success{
color: var(--bs-success);
border-color: color-mix(in srgb, var(--bs-success) 30%, var(--bs-border-color));
}
.btn-icon--success:hover{
background: color-mix(in srgb, var(--bs-success) 16%, var(--bs-body-bg));
}
.btn-icon--danger{
color: var(--bs-danger);
border-color: color-mix(in srgb, var(--bs-danger) 30%, var(--bs-border-color));
}
.btn-icon--danger:hover{
background: color-mix(in srgb, var(--bs-danger) 16%, var(--bs-body-bg));
}
/* Акцентированный фокус для клавиатуры */
.btn-icon:focus-visible{
outline: 2px solid color-mix(in srgb, currentColor 60%, transparent);
outline-offset: 2px;
}
.container-xxl { max-width: 1820px; padding-inline: var(--bs-gutter-x,.75rem); }
</style>
{% block extra_css %}{% endblock %}
</head>
<body class="min-vh-100">
{% include "includes/navbar.html" %}
<div class="container-xxl px-2 px-lg-3">
<div class="app-layout {% if user.is_authenticated %}with-sidebar with-rightbar{% endif %}">
{% if user.is_authenticated %}
{% include "includes/sidebar.html" %}
{% endif %}
<main class="main py-3">
{% block content %}{% endblock %}
</main>
{% if user.is_authenticated %}
{% include "includes/rightbar_calendar.html" %}
{% endif %}
</div>
</div>
<!-- JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz"
crossorigin="anonymous" defer></script>
<script src="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/@fullcalendar/core@6.1.8/locales-all.global.min.js" defer></script>
<script>
// реальная высота navbar -> CSS var
document.addEventListener('DOMContentLoaded', () => {
const nav = document.querySelector('.app-navbar');
const setH = () => nav && document.documentElement.style.setProperty('--navbar-h', nav.offsetHeight + 'px');
setH(); addEventListener('resize', setH);
});
</script>
<script>
// Глобальный обработчик лайков (использует стили/классы базового шаблона)
(function(){
function getCookie(name){
const m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return m ? m.pop() : '';
}
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-like-post]');
if (!btn) return;
e.preventDefault();
const url = btn.getAttribute('data-like-url');
if (!url || btn.disabled) return;
// текущее состояние -> какая операция нужна
const currentlyLiked =
btn.classList.contains('is-liked') ||
btn.getAttribute('aria-pressed') === 'true';
const op = currentlyLiked ? 'unlike' : 'like';
btn.disabled = true;
try{
const resp = await fetch(url, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({ op })
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json().catch(() => ({}));
// совместимость с разными ключами
const liked = (data.liked !== undefined) ? !!data.liked
: (data.is_liked !== undefined) ? !!data.is_liked
: !currentlyLiked; // если что-то пошло не так — просто инвертируем
const count = (data.likes !== undefined) ? data.likes
: (data.likes_count !== undefined) ? data.likes_count
: null;
// обновляем кнопку/иконку/aria
btn.classList.toggle('is-liked', liked);
btn.setAttribute('aria-pressed', liked ? 'true' : 'false');
const icon = btn.querySelector('.bi');
if (icon){
icon.classList.toggle('bi-heart', !liked);
icon.classList.toggle('bi-heart-fill', liked);
}
// счётчик (в пределах карточки)
const card = btn.closest('[data-post-card]');
const cnt = card && card.querySelector('[data-like-count]');
if (cnt && count !== null) cnt.textContent = String(count);
// если есть скрытый input name="op", поддержим и серверный постбэк
const opInput = btn.closest('form')?.querySelector('input[name="op"]');
if (opInput) opInput.value = liked ? 'unlike' : 'like';
}catch(err){
console.error(err);
}finally{
btn.disabled = false;
}
}, { passive:false });
})();
</script>
{% if user.is_authenticated %}
<script>
// Базовая карта аватаров (только текущий пользователь; остальные подтянутся из ws)
window.__CHAT_AVATARS__ = {
"{{ user.id }}": {% if user.avatar %}"{{ user.avatar.url }}"{% else %}""{% endif %}
};
</script>
{% endif %}
<script>
// автоскролл / авто-рост textarea / кнопка «вниз» / разделитель «Новые сообщения» / отметка прочитанного
(function(){
const box = document.getElementById('chatScroll');
if (!box) return;
const ta = document.getElementById('id_content');
const form = document.getElementById('chatForm');
const btn = document.getElementById('scrollBtn');
const chatId = Number(box?.dataset.chatId || 0);
const meId = Number(box?.dataset.meId || 0);
function getCookie(name){
const m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
return m ? m.pop() : '';
}
const csrf = getCookie('csrftoken');
const LS_KEY = `chat:lastRead:${chatId}`;
function getLocalLastReadTs(){
const v = localStorage.getItem(LS_KEY);
return v ? Number(v) : NaN;
}
function setLocalLastReadTs(ts){
localStorage.setItem(LS_KEY, String(ts));
}
(function initLastRead(){
let lastRead = Number(box.dataset.lastReadTs || NaN);
if (!Number.isFinite(lastRead)){
const ls = getLocalLastReadTs();
if (Number.isFinite(ls)) lastRead = ls;
}
if (Number.isFinite(lastRead)) box.dataset.lastReadTs = String(lastRead);
})();
let lastMarkedTs = Number(box.dataset.lastReadTs || 0);
function atBottom(){ return (box.scrollHeight - box.scrollTop - box.clientHeight) < 60; }
function autoscroll(){
const prev = box.style.scrollBehavior;
box.style.scrollBehavior = 'auto';
box.scrollTop = box.scrollHeight;
if (prev) { box.style.scrollBehavior = prev; } else { box.style.removeProperty('scroll-behavior'); }
}
function toggleBtn(){ btn?.classList.toggle('show', !atBottom()); }
function makeDivider(cls, text){
const div = document.createElement('div');
div.className = cls;
div.setAttribute('role','separator');
div.setAttribute('aria-label', text);
div.innerHTML = `<span>${text}</span>`;
return div;
}
function findUnreadTarget(){
const id = window.__FIRST_UNREAD_ID__;
const ts = window.__UNREAD_FROM_TS__;
const lastReadTs = Number(box.dataset.lastReadTs || NaN);
if (id != null){
const el = box.querySelector(`.msg[data-id="${id}"]`);
if (el) return el;
}
if (Number.isFinite(ts)){
const items = Array.from(box.querySelectorAll('.msg[data-ts]'));
const el = items.find(e => Number(e.dataset.ts) >= Number(ts));
if (el) return el;
}
if (Number.isFinite(lastReadTs)){
const items = Array.from(box.querySelectorAll(`.msg[data-ts][data-author-id]`));
return items.find(e => Number(e.dataset.ts) > lastReadTs && Number(e.dataset.authorId) !== meId) || null;
}
return null;
}
function insertUnreadDivider(){
if (!box || box.querySelector('.unread-divider')) return null;
const target = findUnreadTarget();
if (target){
const div = makeDivider('unread-divider', 'Новые сообщения');
target.parentNode.insertBefore(div, target);
return div;
}
return null;
}
function removeUnreadDivider(){
const d = box.querySelector('.unread-divider');
if (d) d.remove();
}
async function markRead(ts){
ts = Number(ts);
if (!Number.isFinite(ts) || ts <= lastMarkedTs) return;
lastMarkedTs = ts;
setLocalLastReadTs(ts);
box.dataset.lastReadTs = String(ts);
removeUnreadDivider();
// URL: только если есть chat в контексте — иначе fallback на suffix
{% if chat %}
let markUrl = "{% url 'communications:chat_mark_read' chat.pk %}";
{% else %}
let markUrl = "";
{% endif %}
if (!markUrl){
const base = location.pathname.endsWith("/") ? location.pathname : (location.pathname + "/");
markUrl = base + "mark-read/";
}
try{
await fetch(markUrl, {
method: 'POST',
headers: { 'X-CSRFToken': csrf, 'Accept':'application/json' },
body: new URLSearchParams({ upto_ts: String(ts) }),
credentials: 'same-origin'
});
// 👉 сообщаем глобальному апдейтеру бейджа
const chatId = Number(document.getElementById('chatScroll')?.dataset.chatId || 0);
if (chatId){
window.dispatchEvent(new CustomEvent('chat:read', { detail: { chatId: String(chatId) } }));
}
}catch(_){}
}
function autosize(){
if (!ta) return;
ta.style.height='auto';
ta.style.height=Math.min(ta.scrollHeight, 6*24)+'px';
if (atBottom()) autoscroll();
}
const mark = insertUnreadDivider();
requestAnimationFrame(()=>{
if (mark) { mark.scrollIntoView({ block:'center' }); } else { autoscroll(); }
autosize();
toggleBtn();
window.__SCROLLED_ON_INIT__ = true;
});
ta?.addEventListener('input', autosize);
ta?.addEventListener('keydown', e=>{
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter'){ e.preventDefault(); form?.requestSubmit(); }
});
box?.addEventListener('scroll', toggleBtn);
btn?.addEventListener('click', autoscroll);
const io = new IntersectionObserver((entries)=>{
entries.forEach(en=>{
if (en.isIntersecting){
const el = en.target.closest('.msg');
if (!el) return;
const ts = Number(el.dataset.ts || 0);
markRead(ts);
}
});
}, { root: box, threshold: 1.0 });
function observeLastForeign(){
io.disconnect();
const msgs = Array.from(box.querySelectorAll(`.msg[data-ts][data-author-id]`));
const lastForeign = [...msgs].reverse().find(e => Number(e.dataset.authorId) !== meId);
if (lastForeign){
const bubble = lastForeign.querySelector('.bubble') || lastForeign;
io.observe(bubble);
}
}
observeLastForeign();
let bottomTimer=null;
box.addEventListener('scroll', ()=>{
clearTimeout(bottomTimer);
if (atBottom()){
bottomTimer = setTimeout(()=>{
const lastMsg = box.querySelector('.msg:last-of-type');
const ts = lastMsg ? Number(lastMsg.dataset.ts || 0) : 0;
markRead(ts);
}, 300);
}
});
window.__CHAT_MARK_READ__ = { markRead, observeLastForeign, autoscroll, atBottom };
})();
</script>
<script>
(function(){
const chatId = {{ chat.id|default_if_none:"null"|default:"null" }};
const meId = {{ user.id|default_if_none:"null"|default:"null" }};
if (chatId === null) return;
const scrollEl = document.getElementById('chatScroll');
const form = document.getElementById('chatForm');
const ta = document.getElementById('id_content');
const AVA = window.__CHAT_AVATARS__ || {};
const typingEl = document.getElementById('typing');
const { markRead, observeLastForeign, autoscroll, atBottom } = window.__CHAT_MARK_READ__ || {};
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/chat/${chatId}/`);
const z2 = n => (n < 10 ? '0' : '') + n;
function toTS(msg){
if (msg && msg.created_ts != null && !isNaN(Number(msg.created_ts))) return Number(msg.created_ts);
if (msg && msg.created){
const t = Date.parse(msg.created); if (!isNaN(t)) return t;
}
return Date.now();
}
function fmtHHMM(ts){
const d = new Date(ts);
return `${z2(d.getHours())}:${z2(d.getMinutes())}`;
}
function fmtDay(ts){
const d = new Date(ts);
return `${z2(d.getDate())}.${z2(d.getMonth()+1)}.${d.getFullYear()}`;
}
function esc(s){
return String(s || '')
.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;')
.replace(/'/g,'&#39;');
}
function avatarHTML(url){
return url
? `<span class="mini-ava border"><img src="${url}" alt="" loading="lazy"></span>`
: `<span class="mini-ava border d-inline-grid"><i class="bi-person"></i></span>`;
}
function nameHTML(id, name, url){
if (Number(id) === Number(meId)){
return `<a href="{% url 'employees:profile' %}" class="text-decoration-none">Вы</a>`;
}
if (url){
return `<a href="${url}" class="text-decoration-none fw-semibold">${esc(name || 'Сотрудник')}</a>`;
}
return `<a href="{% url 'employees:employee_detail' 0 %}`.replace('/0/', `/${id}/`) +
`" class="text-decoration-none fw-semibold">${esc(name || 'Сотрудник')}</a>`;
}
function renderDayDivider(dateStr){
const div = document.createElement('div');
div.className = 'day-divider';
div.innerHTML = `<span>${esc(dateStr)}</span>`;
scrollEl.appendChild(div);
}
(function ensureLastDayFromDom(){
const dl = scrollEl.querySelector('.day-divider:last-of-type span');
if (dl) {
scrollEl.dataset.lastDay = dl.textContent.trim();
} else {
const lastMsg = scrollEl.querySelector('.msg:last-of-type');
if (lastMsg){
const ts = Number(lastMsg.dataset.ts || Date.now());
scrollEl.dataset.lastDay = fmtDay(ts);
}
}
})();
function renderMessage(msg){
const mine = Number(msg.author_id) === Number(meId);
if (msg.author_id && msg.avatar && !AVA[msg.author_id]) AVA[msg.author_id] = msg.avatar;
const ts = toTS(msg);
const dayStr = msg.day || fmtDay(ts);
if (dayStr && scrollEl.dataset.lastDay !== dayStr){
renderDayDivider(dayStr);
scrollEl.dataset.lastDay = dayStr;
}
const wrap = document.createElement('div');
wrap.className = `d-flex mb-3 msg ${mine ? 'justify-content-end' : 'justify-content-start'}`;
if (msg.id != null) wrap.setAttribute('data-id', String(msg.id));
wrap.setAttribute('data-ts', String(ts));
wrap.setAttribute('data-author-id', String(msg.author_id || ''));
const who = nameHTML(msg.author_id, msg.author_name || msg.author, msg.author_url);
const time = fmtHHMM(ts);
const text = esc(msg.content || '').replace(/\n/g,'<br>');
const bubble = `
<div class="d-flex flex-column" style="max-width:80%;">
<div class="small text-secondary ${mine ? 'text-end' : ''}">
${who} · ${time}
</div>
<div class="mt-1 bubble ${mine ? 'bubble-me' : 'bubble-other'}">
${text}
</div>
</div>`;
const avaUrl = msg.avatar || AVA[msg.author_id] || "";
const ava = avatarHTML(avaUrl);
if (!mine){
const link = msg.author_url || `{% url 'employees:employee_detail' 0 %}`.replace('/0/', `/${msg.author_id}/`);
wrap.innerHTML = `<a class="me-2 text-decoration-none" href="${link}">${ava}</a>${bubble}`;
}else{
wrap.innerHTML = `${bubble}<a class="ms-2 text-decoration-none" href="{% url 'employees:profile' %}">${ava}</a>`;
}
const keep = atBottom && atBottom();
scrollEl.appendChild(wrap);
if (keep && autoscroll) autoscroll();
if (observeLastForeign) observeLastForeign();
if (keep && window.__CHAT_MARK_READ__?.markRead){
window.__CHAT_MARK_READ__.markRead(ts);
}
}
let typingTimer = null;
function showTyping(){
if (!typingEl) return;
typingEl.classList.remove('d-none');
clearTimeout(typingTimer);
typingTimer = setTimeout(()=> typingEl.classList.add('d-none'), 2000);
}
ws.addEventListener('message', (e)=>{
try{
const data = JSON.parse(e.data);
if (data.type === 'typing' && Number(data.user_id) !== Number(meId)){ showTyping(); return; }
const msg = data.payload || data.message || data;
renderMessage(msg);
}catch(_){}
});
ws.addEventListener('close', ()=>{ setTimeout(()=>location.reload(), 2000); });
form?.addEventListener('submit', (e)=>{
e.preventDefault();
const text = (ta?.value || '').trim();
if (!text || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ content: text }));
ta.value=''; ta.dispatchEvent(new Event('input'));
});
let lastTyping = 0;
ta?.addEventListener('input', ()=>{
const now = Date.now();
if (ws.readyState === WebSocket.OPEN && now - lastTyping > 1500){
ws.send(JSON.stringify({ type:'typing' }));
lastTyping = now;
}
});
if (!window.__SCROLLED_ON_INIT__ && window.__CHAT_MARK_READ__?.autoscroll) window.__CHAT_MARK_READ__.autoscroll();
})();
</script>
{# Поиск/фильтр + скрытие пустых секций #}
<script>
(function(){
const search = document.getElementById('chatSearch');
const labels = document.querySelectorAll('[data-filter]');
const sections = {
global: document.querySelector('.chat-section[data-sec="global"]'),
department: document.querySelector('.chat-section[data-sec="department"]'),
private: document.querySelector('.chat-section[data-sec="private"]')
};
let active = 'all';
const norm = s => (s || '').toLowerCase().replace(/\s+/g,' ').trim();
function apply(){
const q = norm(search?.value || '');
document.querySelectorAll('.chat-row').forEach(el=>{
const type = el.getAttribute('data-type');
const hay = norm(el.getAttribute('data-haystack'));
const okType = (active === 'all') || (type === active);
const okText = !q || hay.includes(q);
el.style.display = (okType && okText) ? '' : 'none';
});
Object.entries(sections).forEach(([key, wrap])=>{
if (!wrap) return;
const list = wrap.querySelector('.list-chats');
const visible = list && list.querySelector('.chat-row:not([style*="display: none"])');
const note = wrap.querySelector('[data-empty-note]');
wrap.style.display = visible ? '' : 'none';
if (note) note.style.display = visible ? 'none' : '';
});
}
labels.forEach(l=>{
l.addEventListener('click', ()=>{
labels.forEach(x=>x.classList.toggle('active', x===l));
active = l.getAttribute('data-filter');
apply();
});
});
search && search.addEventListener('input', apply);
apply();
})();
</script>
{# Realtime-обновления списка через /ws/chats/ — только если есть список #}
<script>
(function(){
const listAny = document.querySelector('.chat-row');
if (!listAny) return;
const meId = {{ user.id|default_if_none:"null"|default:"null" }};
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/chats/`);
function resortSection(sectionListEl){
const rows = Array.from(sectionListEl.querySelectorAll('.chat-row'));
rows.sort((a,b)=>{
const ta = Number(a.getAttribute('data-last-ts') || 0);
const tb = Number(b.getAttribute('data-last-ts') || 0);
return tb - ta;
});
rows.forEach(r => sectionListEl.appendChild(r));
}
function containerForType(t){
if (t === 'global') return document.getElementById('sec-global');
if (t === 'department') return document.getElementById('sec-department');
return document.getElementById('sec-private');
}
ws.addEventListener('message', (e)=>{
try{
const data = JSON.parse(e.data);
if (data.type !== 'list_update') return;
const chatId = String(data.chat_id);
const msg = data.message || {};
const row = document.querySelector(`.chat-row[data-chat-id="${chatId}"]`);
if (!row) return;
const ts = Number(msg.created_ts || Date.now());
row.setAttribute('data-last-ts', String(ts));
const timeEl = row.querySelector('[data-last-time]');
const authEl = row.querySelector('[data-last-author]');
const prevEl = row.querySelector('[data-last-preview]');
if (timeEl) timeEl.textContent = msg.created || '';
if (authEl) authEl.textContent = msg.author_name || 'Сотрудник';
if (prevEl) prevEl.textContent = (msg.content || '').slice(0, 120);
const badge = row.querySelector('[data-unread]');
const cntEl = row.querySelector('[data-unread-count]');
if (Number(msg.author_id) === Number(meId)){
if (cntEl) cntEl.textContent = '0';
if (badge) badge.classList.add('d-none');
}else{
const cur = Number((cntEl && cntEl.textContent) || 0) + 1;
if (cntEl) cntEl.textContent = String(cur);
if (badge && cur > 0) badge.classList.remove('d-none');
}
const type = row.getAttribute('data-type');
const list = containerForType(type);
if (list){ resortSection(list); }
}catch(_){}
});
ws.addEventListener('close', ()=>{ setTimeout(()=>location.reload(), 2000); });
})();
</script>
<script>
(function(){
const meId = {{ user.id|default:"0" }};
const badge = document.getElementById('sidebarChatBadge');
function setBadge(n){
if (!badge) return;
const v = Math.max(0, Number(n)||0);
badge.dataset.count = String(v);
badge.textContent = v > 99 ? '99+' : String(v);
badge.classList.toggle('d-none', v <= 0);
}
// стартовое значение (из шаблона сайдбара)
setBadge(badge?.dataset.count || 0);
// карта непрочитанных по чатам (если мы на списке чатов — проинициализируем из DOM)
const chatUnread = Object.create(null);
document.querySelectorAll('.chat-row[data-chat-id]').forEach(row=>{
const id = String(row.getAttribute('data-chat-id'));
const cnt = Number(row.querySelector('[data-unread-count]')?.textContent || 0);
chatUnread[id] = cnt;
});
function recompute(){
let total = 0;
for (const k in chatUnread){ total += Number(chatUnread[k]||0); }
setBadge(total);
}
if (Object.keys(chatUnread).length) recompute();
// глобальный канал обновлений списка чатов
try{
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws/chats/`);
ws.addEventListener('message', (e)=>{
try{
const data = JSON.parse(e.data);
if (data.type !== 'list_update') return;
const chatId = String(data.chat_id);
const msg = data.message || {};
const isMine = Number(msg.author_id) === Number(meId);
// если сообщение моё — обнуляем счётчик для этого чата, иначе +1
chatUnread[chatId] = isMine ? 0 : (Number(chatUnread[chatId]||0) + 1);
recompute();
}catch(_){}
});
ws.addEventListener('close', ()=>{ /* молча, без перезапуска */ });
}catch(_){}
// Обнуляем конкретный чат, когда где-то на сайте он помечен прочитанным
window.addEventListener('chat:read', (e)=>{
const chatId = String(e?.detail?.chatId || '');
if (!chatId) return;
chatUnread[chatId] = 0;
recompute();
});
})();
</script>
{% block extra_js %}{% endblock %}
</body>
</html>