Игорь Надеин 52f2aed5fd From: You <you@example.com>
Date: Mon, 22 Sep 2025 10:30:00 +0300
Subject: [requests] Drawer «Рассмотреть», корректный чекбокс «Черновик», дружелюбные даты,
 robust-обработка ошибок reject, и глобальный бейдж непрочитанных чатов

- Список заявлений:
  - вместо россыпи кнопок добавлен аккуратный выезжающий блок действий (collapse drawer)
    по клику «Рассмотреть»; компактные нейтральные стили, мягкая анимация.
  - в модале «Изменить» добавлена галочка «Сохранить как черновик» с правильной
    семантикой: hidden `_save_as=submit` по умолчанию + checkbox `_save_as=draft`.
    Теперь снятие галочки действительно снимает draft.
  - улучшены стили и структура разметки.

- Даты:
  - на фронт-обёртке нормализуем datetime-поля (created_at/updated_at/decided_at)
    в человекочитаемый вид, чтобы не светить сырые ISO 8601.

- Обработка ошибок:
  - фикс падения при попытке отклонить уже одобренную заявку: ответ 400 у DRF
    может приходить списком ошибок; теперь сообщение извлекается безопасно.

- Чаты:
  - синхронизировали название ключа контекста с проектом (`chat_unread_total`);
  - бейдж «Чаты» обновляется глобальным событием `chats:unread-total`, даже если
    список чатов не открыт (fallback — суммирование счётчиков на странице).

Примечание: пути к шаблонам могут отличаться в вашем проекте — ниже даны
типовые варианты (`backend/requests_app/templates/...`). Если у вас другой
layout, внесите изменения в соответствующие файлы шаблонов.
2025-09-22 13:01:20 +03:00

260 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-0">
<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:request_list' %}"
class="nav-link {% if request.resolver_match.url_name == 'request_list' %}active{% endif %}"
{% if request.resolver_match.url_name == 'request_list' %}aria-current="page"{% endif %}>
<span class="side-icon"><i class="bi-clipboard-check"></i></span>
<span>Заявления</span>
</a>
{% with total=chat_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>
</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>