- **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
272 lines
11 KiB
HTML
272 lines
11 KiB
HTML
{% 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>
|
||
|
||
|
||
|