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() во вьюсетах и добавить в роутер.
240 lines
10 KiB
HTML
240 lines
10 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Чат{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
/* прибиваем низ страницы и добавляем воздух снизу */
|
||
main.main{ padding-bottom:0 !important; }
|
||
|
||
/* ===== Контейнер страницы чата ===== */
|
||
|
||
@supports(height: 100svh){
|
||
.chat-root{ height: calc(100svh - var(--navbar-h)); }
|
||
}
|
||
.chat-root > .row{ height:100%; min-height:0; }
|
||
.chat-col{ display:flex; flex-direction:column; height:93%; min-height:0; }
|
||
.feed-header{ flex:0 0 auto; }
|
||
|
||
/* ===== Карточка чата ===== */
|
||
.feed-card.chat-box{
|
||
position:relative;
|
||
flex: 1 1 auto;
|
||
min-height:0;
|
||
display:grid;
|
||
grid-template-rows: 1fr auto; /* лента + композер */
|
||
overflow: hidden;
|
||
margin-bottom:0;
|
||
background: var(--bs-body-bg);
|
||
}
|
||
|
||
/* ===== Лента с кастомным скроллом ===== */
|
||
.chat-scroll{
|
||
overflow-y:auto;
|
||
padding:.75rem;
|
||
min-height:0;
|
||
scroll-behavior:smooth;
|
||
-webkit-overflow-scrolling:touch;
|
||
scrollbar-width: thin;
|
||
scrollbar-gutter: stable both-edges;
|
||
overscroll-behavior: contain;
|
||
scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 70%) transparent;
|
||
}
|
||
.chat-scroll:hover{ scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 55%) transparent; }
|
||
.chat-scroll::-webkit-scrollbar{ width:10px; height:10px; }
|
||
.chat-scroll::-webkit-scrollbar-track{ background: transparent; margin-block:0; }
|
||
.chat-scroll::-webkit-scrollbar-thumb{
|
||
background-color: color-mix(in srgb, var(--bs-body-color), transparent 75%);
|
||
border-radius:999px; border:3px solid transparent; background-clip: padding-box;
|
||
}
|
||
.chat-scroll:hover::-webkit-scrollbar-thumb{
|
||
background-color: color-mix(in srgb, var(--bs-body-color), transparent 55%);
|
||
}
|
||
[data-bs-theme="dark"] .chat-scroll{
|
||
scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 78%) transparent;
|
||
}
|
||
[data-bs-theme="dark"] .chat-scroll:hover{
|
||
scrollbar-color: color-mix(in srgb, var(--bs-body-color), transparent 60%) transparent;
|
||
}
|
||
[data-bs-theme="dark"] .chat-scroll::-webkit-scrollbar-thumb{
|
||
background-color: color-mix(in srgb, var(--bs-body-color), transparent 78%);
|
||
}
|
||
[data-bs-theme="dark"] .chat-scroll:hover::-webkit-scrollbar-thumb{
|
||
background-color: color-mix(in srgb, var(--bs-body-color), transparent 60%);
|
||
}
|
||
@media (hover:none), (pointer:coarse){
|
||
.chat-scroll{ scrollbar-width:none; }
|
||
.chat-scroll::-webkit-scrollbar{ display:none; }
|
||
}
|
||
|
||
/* ===== Композер: непрерывная линия и скругления ===== */
|
||
.chat-composer{
|
||
position: sticky; bottom:0;
|
||
border-top:none;
|
||
box-shadow: inset 0 1px 0 var(--bs-border-color);
|
||
border-bottom-left-radius: inherit;
|
||
border-bottom-right-radius: inherit;
|
||
background-clip: padding-box;
|
||
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
|
||
padding:.5rem .75rem calc(.5rem + env(safe-area-inset-bottom)) .75rem;
|
||
backdrop-filter: blur(8px);
|
||
z-index:1;
|
||
}
|
||
.composer-wrap{ display:flex; align-items:end; gap:.5rem; flex-wrap:nowrap; }
|
||
.composer-tools{ display:flex; gap:.25rem; align-items:center; }
|
||
|
||
/* Кнопка «вниз» */
|
||
.scroll-to-bottom{
|
||
position:absolute; right:.75rem; bottom:4.25rem;
|
||
z-index:2; display:none;
|
||
}
|
||
.scroll-to-bottom.show{ display:block; }
|
||
|
||
/* Пузыри / аватары */
|
||
.bubble{ border-radius:16px; padding:.5rem .75rem; word-break:break-word; box-shadow:0 1px 2px rgba(0,0,0,.06); }
|
||
.bubble-me{ background: color-mix(in srgb, var(--bs-primary) 92%, #fff 0%); color:#fff; }
|
||
.bubble-other{ background: var(--bs-secondary-bg); color: var(--bs-body-color); }
|
||
.mini-ava{ width:32px; height:32px; border-radius:9999px; overflow:hidden; display:grid; place-items:center; }
|
||
.mini-ava img{ width:100%; height:100%; object-fit:cover; display:block; }
|
||
|
||
/* ===== Разделители: гибкие, безопасные ===== */
|
||
.day-divider,
|
||
.unread-divider{
|
||
display:flex; align-items:center; justify-content:center; gap:.5rem;
|
||
margin:.5rem 0 1rem;
|
||
font-size:.85rem; line-height:1; font-weight:700;
|
||
pointer-events:none; user-select:none;
|
||
}
|
||
.day-divider::before, .day-divider::after,
|
||
.unread-divider::before, .unread-divider::after{
|
||
content:""; flex:1 1 auto; height:1px;
|
||
background: color-mix(in srgb, var(--bs-border-color), transparent 30%);
|
||
}
|
||
.day-divider > span, .unread-divider > span{
|
||
flex:0 0 auto; padding:0 .5rem; background: transparent;
|
||
}
|
||
.day-divider{ color: var(--bs-secondary-color); }
|
||
.unread-divider{ color: var(--bs-danger); }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container chat-root">
|
||
<div class="row gx-0">
|
||
<div class="col-12 chat-col">
|
||
|
||
<!-- Заголовок -->
|
||
<div class="feed-header">
|
||
{% if chat.type == 'global' %}
|
||
<i class="bi-globe fs-2 text-primary"></i>
|
||
<h2 class="title mb-0">Глобальный чат</h2>
|
||
{% elif chat.type == 'department' %}
|
||
<i class="bi-diagram-3 fs-2 text-primary"></i>
|
||
<h2 class="title mb-0">Чат отдела: {{ chat.department.name }}</h2>
|
||
{% else %}
|
||
<i class="bi-person fs-2 text-primary"></i>
|
||
<h2 class="title mb-0">
|
||
{% for u in chat.participants.all %}
|
||
{% if u != user %}
|
||
{% if not forloop.first %}, {% endif %}
|
||
<a href="{% url 'employees:employee_detail' u.pk %}" class="text-decoration-none">
|
||
{{ u.get_full_name|default:u.username }}
|
||
</a>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</h2>
|
||
{% endif %}
|
||
<div class="ms-auto">
|
||
<a href="{% url 'communications:chat_list' %}" class="btn btn-ghost">
|
||
<i class="bi-arrow-left"></i> К чатам
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Корпус чата -->
|
||
<article class="feed-card chat-box">
|
||
|
||
<!-- Кнопка «вниз» -->
|
||
<button id="scrollBtn" class="btn btn-light shadow scroll-to-bottom" type="button" aria-label="Прокрутить вниз">
|
||
<i class="bi-arrow-down-circle"></i>
|
||
</button>
|
||
|
||
<!-- Лента сообщений -->
|
||
<div id="chatScroll"
|
||
class="chat-scroll"
|
||
data-me-id="{{ user.id }}"
|
||
data-chat-id="{{ chat.id }}"
|
||
data-last-read-ts="{% if last_read_at %}{{ last_read_at|date:'U' }}000{% endif %}">
|
||
{% if messages %}
|
||
{% for message in messages %}
|
||
{% ifchanged message.created_at|date:"d.m.Y" %}
|
||
<div class="day-divider"><span>{{ message.created_at|date:"d.m.Y" }}</span></div>
|
||
{% endifchanged %}
|
||
|
||
<!-- сообщение -->
|
||
<div class="d-flex mb-3 msg {% if message.author_id == user.id %}justify-content-end{% else %}justify-content-start{% endif %}"
|
||
data-id="{{ message.id }}"
|
||
data-ts="{{ message.created_at|date:'U' }}000"
|
||
data-author-id="{{ message.author_id }}">
|
||
{% if message.author_id != user.id %}
|
||
<a class="me-2 text-decoration-none" href="{% url 'employees:employee_detail' message.author.pk %}">
|
||
<span class="mini-ava border">
|
||
{% if message.author.avatar %}<img src="{{ message.author.avatar.url }}" alt="" loading="lazy">
|
||
{% else %}<i class="bi-person"></i>{% endif %}
|
||
</span>
|
||
</a>
|
||
{% endif %}
|
||
|
||
<div class="d-flex flex-column" style="max-width:80%;">
|
||
<div class="small text-secondary {% if message.author_id == user.id %}text-end{% endif %}">
|
||
{% if message.author_id == user.id %}
|
||
<a href="{% url 'employees:profile' %}" class="text-decoration-none">Вы</a>
|
||
{% else %}
|
||
<a href="{% url 'employees:employee_detail' message.author.pk %}" class="text-decoration-none fw-semibold">
|
||
{{ message.author.get_full_name|default:message.author.username }}
|
||
</a>
|
||
{% endif %}
|
||
· <time datetime="{{ message.created_at|date:'c' }}">{{ message.created_at|date:"H:i" }}</time>
|
||
</div>
|
||
<div class="mt-1 bubble {% if message.author_id == user.id %}bubble-me{% else %}bubble-other{% endif %}">
|
||
{{ message.content|linebreaksbr }}
|
||
</div>
|
||
</div>
|
||
|
||
{% if message.author_id == user.id %}
|
||
<a class="ms-2 text-decoration-none" href="{% url 'employees:profile' %}">
|
||
<span class="mini-ava border">
|
||
{% if user.avatar %}<img src="{{ user.avatar.url }}" alt="" loading="lazy">
|
||
{% else %}<i class="bi-person"></i>{% endif %}
|
||
</span>
|
||
</a>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
{% else %}
|
||
<div class="text-center text-secondary py-5">Пока нет сообщений.</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Композер -->
|
||
<div class="chat-composer">
|
||
<div id="typing" class="small text-secondary ps-1 pt-1 d-none">Собеседник печатает…</div>
|
||
<form method="post" id="chatForm" class="composer-wrap">
|
||
{% csrf_token %}
|
||
<div class="composer-tools">
|
||
<button type="button" class="btn btn-ghost" title="Вложить файл"><i class="bi-paperclip"></i></button>
|
||
<button type="button" class="btn btn-ghost" title="Камера"><i class="bi-camera"></i></button>
|
||
</div>
|
||
<textarea name="content" id="id_content" class="form-control flex-grow-1" rows="1" placeholder="Напишите сообщение…" required></textarea>
|
||
<button type="submit" class="btn btn-primary"><i class="bi-send"></i></button>
|
||
</form>
|
||
</div>
|
||
</article>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{% endblock %}
|