- **EmployeeActionSerializer**
- Добавлен `history[]` со снапшотами simple-history и diff по полям: `action`, `date`, `comment`, `extra`, `employee_id`.
- Локализация названий событий через `employees.constants.ACTION_CHOICES`.
- Поля `action_display`, `date_display` для удобного фронтового рендера.
- **EmployeeSerializer**
- Возвращает `actions` (массив событий) с историей, **только** если в контексте выставлено `include_action_history=True`.
- Навыки: чтение — `skills` как список объектов `{id, name}`, запись — через `skills_ids`.
- Должность: чтение — объект `{id, name}`, запись — через `position_id`.
- Отделы: добавлен флаг `is_head` (сотрудник — руководитель отдела).
- **EmployeeListSerializer**
- Те же правки для навыков/должности и `is_head`, без `actions` (для производительности).
- **Views (EmployeeViewSet)**
- `get_queryset`: префетч `actions` добавляется **только** для `retrieve/me`, иначе не тянем историю.
- `get_serializer_context`: для `retrieve` и `me` выставляем `include_action_history=True`.
- Экшен `me`: выборка с `Prefetch("actions", order_by -date)` и передачей `include_action_history=True`.
- **Разное**
- Используем `ACTION_CHOICES` для «человеческих» названий событий в истории.
- `HistoryActionMixin` оставлен без изменений; история объектов доступна по `/{basename}/{pk}/history/`.
Почему так
- История кадровых событий бывает тяжёлой → грузим и отдаём её только там, где нужно (`retrieve/me`).
- Явное разделение read/write для навыков и должности упрощает фронту жизнь и убирает двусмысленность.
- `is_head` позволяет фронту не вычислять этот флаг отдельно.
Файлы
- `backend/api/v1/employees/serializers.py`
- `backend/api/v1/employees/views.py`
- (использованы константы из) `backend/employees/constants.py`
Тесты/проверка
1) GET `/api/v1/employees/1/` → поле `actions[]` заполнено, внутри каждого элемента `history[]` со снапшотами и переводами событий.
2) GET `/api/v1/employees/?page=1` → `actions` отсутствуют (по замыслу), навыки и должность приходят в «красивом» виде.
3) GET `/api/v1/employees/me/` → аналогично `retrieve`, с историей.
* UI (templates/employees/employee\_detail.html)
* Навыки: больше не итерируем по строке, выводим объекты `{id,name}` (`s.name`).
Защита от пустых списков.
* Отделы: убрал «аватарки» у элементов списка, оставил иконку у кнопки раздела.
Роль сотрудника показывается «Название отдела — Роль».
Для руководителя добавлен понятный бейдж с иконкой `bi-award-fill` + tooltip «Руководитель отдела».
* Кадровые события: добавлен раздел «Кадровые события» (для аутентифицированных).
Отрисовывает список всех событий (дата, комментарий, extra), а также историю каждого события (`action.history`) в `<details>`.
В истории показываем только название события и новые значения полей (без «создано/изменено» и без зачёркнутых старых значений).
Перевод кодов событий (`hired`, `dismissed`, …) в человекочитаемый вид согласно `employees/constants.py`.
* Аккордеон: кнопка и выпадающая панель объединены в один блок `.ios-acc` единой ширины.
Добавлена разделительная линия между заголовком и контентом (`.ios-acc-body { border-top: ... }`).
Исправлены дёрганья при анимации: убраны внешние отступы у `.collapse`, возврат стандартной скорости Bootstrap (`.35s ease`), `overflow:hidden` на фазе `collapsing`.
* Views (employees/views.py — веб-приложение)
* В `employee_detail` и `employee_me` дополнительно запрашиваем полную историю кадровых событий:
`GET v1/employee-actions/?employee=<id>&ordering=-date` и передаём в шаблон `employee_actions`.
Тем самым отображаем все события, а не только последнее.
* Шаблон умеет брать события из `employee_actions`, а при отсутствии — из `emp.actions`.
* API (backend/api/v1/employees/serializers.py)
* `EmployeeActionSerializer`: добавлен `history` (MethodField) с формированием массива изменений из `HistoricalRecords` (без старых значений; только `changes[field].new`, дата/пользователь).
Добален `action_display` и `date_display` для удобного рендера на фронте.
* `EmployeeSerializer`: возвращает `departments` через `get_departments()` (id, name, role\_id, role\_name, is\_head) и `skills` как массив объектов `{id,name}`.
* Прочее/фиксы
* Безопасные проверки на пустые коллекции (контакты, навыки, отделы, события).
* Устранена ошибка шаблона «Failed lookup…» при попытке обратиться к отсутствующим ключам.
* Не изменяем скорость анимации: возвращён дефолт Bootstrap; корректное закрытие панелей.
Refs: employees/constants.py (ACTION\_CHOICES), templates/employees/employee\_detail.html, employees/views.py, api/v1/employees/serializers.py
857 lines
33 KiB
HTML
857 lines
33 KiB
HTML
{% load static %}
|
||
<!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">
|
||
<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">
|
||
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css" rel="stylesheet">
|
||
|
||
<!-- Базовые стили приложения (+ feed) -->
|
||
<style>
|
||
:root{
|
||
--sidebar-w: 260px;
|
||
--rightbar-w: 370px;
|
||
--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:1rem; }
|
||
|
||
.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 1rem 1rem 1rem; position:relative; }
|
||
.feed-title{ font-weight:700; margin:.25rem 0 .35rem 0; font-size:1.05rem; }
|
||
|
||
|
||
.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:.6rem 1rem .9rem 1rem;
|
||
border-top:1px solid var(--bs-border-color); position:relative; z-index:2;
|
||
}
|
||
.btn-ghost{
|
||
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);
|
||
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>
|
||
|
||
{% block extra_css %}{% endblock %}
|
||
</head>
|
||
<body class="min-vh-100">
|
||
|
||
{% include "includes/navbar.html" %}
|
||
|
||
<div class="app-layout container-fluid px-0 {% if user.is_authenticated %}with-sidebar with-rightbar{% endif %}">
|
||
{% if user.is_authenticated %}
|
||
{% include "includes/sidebar.html" %}
|
||
{% endif %}
|
||
|
||
<main class="main py-3 px-3 px-lg-4">
|
||
{% block content %}{% endblock %}
|
||
</main>
|
||
|
||
{% if user.is_authenticated %}
|
||
{% include "includes/rightbar_calendar.html" %}
|
||
{% endif %}
|
||
</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,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"')
|
||
.replace(/'/g,''');
|
||
}
|
||
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>
|