Игорь Надеин c78d1e5fea Ключевые изменения
- **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
2025-08-27 17:58:46 +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-3 px-lg-2">
<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>