Files
ACORE/templates/index.html
T
lokses 2a1571ef73 chat/htmx: CSRF-заголовок для всех htmx-запросов
hx-post вне формы (кнопка «Синхронизировать чат») слал POST без CSRF —
Forbidden (CSRF token missing). Добавил hx-headers с X-CSRFToken на <body>:
токен уходит со всеми htmx-запросами, работает и при CSRF_COOKIE_HTTPONLY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 20:35:07 +03:00

848 lines
51 KiB
HTML

{% load common_tags %}
{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html lang="{{ LANGUAGE_CODE }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>АКС — Корпоративний Портал</title>
<link rel="stylesheet" href="{% static 'assets/css/main.css' %}">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>htmx.config.allowEval = false;</script>
<script src="{% static 'tinymce/tinymce.min.js' %}"></script>
<script>
// HTMX собирает данные формы ДО configRequest — дописываем актуальное
// содержимое редакторов прямо в параметры запроса
document.addEventListener('htmx:configRequest', function(evt) {
if (!window.tinymce || !evt.detail || !evt.detail.parameters) return;
tinymce.get().forEach(function(editor) {
var el = editor.getElement();
if (!el || !el.name) return;
var params = evt.detail.parameters;
if (typeof params.set === 'function') {
params.set(el.name, editor.getContent());
} else {
params[el.name] = editor.getContent();
}
});
});
</script>
<script>(function(){var t=localStorage.getItem('theme')||'light';if(t!=='light')document.documentElement.setAttribute('data-theme',t);})();</script>
<script>window.ACORE_MAP_CONFIG={cartoApiKey:"{{ carto_basemaps_api_key|escapejs }}"};</script>
<script src="{% url 'javascript-catalog' %}"></script>
<script src="{% static 'assets/js/map-tiles.js' %}"></script>
<script src="{% static 'assets/js/pagination.js' %}"></script>
<script src="{% static 'assets/js/pharmacies.js' %}"></script>
<script src="{% static 'assets/js/employees.js' %}"></script>
<script src="{% static 'assets/js/timetrack.js' %}"></script>
<script src="{% static 'assets/js/utilities.js' %}"></script>
<script src="{% static 'assets/js/people-picker.js' %}"></script>
<script src="{% static 'assets/js/searchable-select.js' %}"></script>
<script src="{% static 'assets/js/hr-portal.js' %}"></script>
<script src="{% static 'assets/js/calendar.js' %}"></script>
<script src="{% static 'assets/js/employee-profile.js' %}"></script>
<script src="{% static 'assets/js/it-manager.js' %}"></script>
<script src="{% static 'assets/js/reports-template-editor.js' %}"></script>
<script src="{% static 'assets/js/directory-quick-add.js' %}"></script>
<script src="{% static 'assets/js/directories.js' %}"></script>
<script src="{% static 'assets/js/user-groups.js' %}"></script>
<script src="{% static 'assets/js/chat.js' %}"></script>
<script src="{% static 'assets/js/documents.js' %}"></script>
</head>
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
<script>
function isMobile() { return window.innerWidth < 768; }
function toggleSidebar() {
const s = document.getElementById('sidebar');
if (isMobile()) {
const backdrop = document.getElementById('sidebar-backdrop');
const opening = !s.classList.contains('mobile-open');
s.classList.toggle('mobile-open');
backdrop.classList.toggle('active');
document.body.style.overflow = opening ? 'hidden' : '';
} else {
s.classList.toggle('collapsed');
}
}
function closeSidebar() {
const s = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
s.classList.remove('mobile-open');
backdrop.classList.remove('active');
document.body.style.overflow = '';
}
function setActive(el) {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
el.classList.add('active');
const hxGet = el.getAttribute('hx-get') || '';
syncBottomNav(hxGet);
if (isMobile()) closeSidebar();
}
function syncBottomNav(hxGet) {
document.querySelectorAll('.bottom-nav-item[hx-get]').forEach(i => {
i.classList.toggle('active', i.getAttribute('hx-get') === hxGet);
});
}
function bottomNavClick(el) {
document.querySelectorAll('.bottom-nav-item').forEach(i => i.classList.remove('active'));
el.classList.add('active');
const hxGet = el.getAttribute('hx-get') || '';
const sidebarItem = document.querySelector(`.nav-item[hx-get="${hxGet}"]`);
if (sidebarItem) {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
sidebarItem.classList.add('active');
}
}
function toggleUserMenu() {
const d = document.getElementById('user-dropdown');
d.style.display = d.style.display === 'none' ? 'block' : 'none';
}
function closeUserMenu() {
document.getElementById('user-dropdown').style.display = 'none';
}
function applyTheme(theme) {
const html = document.documentElement;
if (theme === 'light') {
html.removeAttribute('data-theme');
} else {
html.setAttribute('data-theme', theme);
}
localStorage.setItem('theme', theme);
document.querySelectorAll('.theme-option').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-theme-value') === theme);
});
}
function toggleThemeSwitcher() {
const d = document.getElementById('theme-dropdown');
const isOpen = d.style.display !== 'none';
if (!isOpen) closeUserMenu();
d.style.display = isOpen ? 'none' : 'block';
}
function closeThemeSwitcher() {
const d = document.getElementById('theme-dropdown');
if (d) d.style.display = 'none';
}
function handleBgFile(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const dataUrl = e.target.result;
const opacity = parseFloat(localStorage.getItem('bg-opacity') || '0.88');
try { localStorage.setItem('bg-image', dataUrl); } catch(err) { /* image too large to persist */ }
applyBgImage(dataUrl, opacity);
};
reader.readAsDataURL(file);
}
function applyBgImage(url, opacity) {
const wp = document.getElementById('bg-wallpaper');
const ov = document.getElementById('bg-overlay');
wp.style.backgroundImage = 'url("' + url + '")';
wp.style.display = 'block';
ov.style.display = 'block';
ov.style.opacity = opacity;
document.getElementById('main-content').style.background = 'transparent';
document.body.style.background = 'transparent';
const slider = document.getElementById('bg-opacity-slider');
if (slider) slider.value = Math.round(opacity * 100);
const opRow = document.getElementById('bg-opacity-row');
if (opRow) opRow.style.display = 'flex';
const clearBtn = document.getElementById('bg-clear-btn');
if (clearBtn) clearBtn.style.display = 'flex';
}
function clearBgImage() {
document.getElementById('bg-wallpaper').style.display = 'none';
document.getElementById('bg-overlay').style.display = 'none';
document.getElementById('main-content').style.background = '';
document.body.style.background = '';
localStorage.removeItem('bg-image');
const opRow = document.getElementById('bg-opacity-row');
if (opRow) opRow.style.display = 'none';
const clearBtn = document.getElementById('bg-clear-btn');
if (clearBtn) clearBtn.style.display = 'none';
const fi = document.getElementById('bg-file-input');
if (fi) fi.value = '';
}
function setBgOpacity(val) {
const opacity = val / 100;
const ov = document.getElementById('bg-overlay');
if (ov) ov.style.opacity = opacity;
localStorage.setItem('bg-opacity', opacity);
}
document.addEventListener('click', function(e) {
const userWrapper = document.getElementById('user-menu-wrapper');
if (userWrapper && !userWrapper.contains(e.target)) closeUserMenu();
const themeWrapper = document.getElementById('theme-switcher-wrapper');
if (themeWrapper && !themeWrapper.contains(e.target)) closeThemeSwitcher();
});
window.addEventListener('resize', function() {
if (!isMobile()) closeSidebar();
});
</script>
<!-- Background wallpaper layers -->
<div id="bg-wallpaper" style="display:none; position:fixed; inset:0; z-index:-2; background-size:cover; background-position:center; background-repeat:no-repeat;"></div>
<div id="bg-overlay" style="display:none; position:fixed; inset:0; z-index:-1; background-color:var(--color-background); transition:opacity 200ms;"></div>
<!-- Loader bar (HTMX) -->
<div class="loader-bar" id="loader" aria-hidden="true"></div>
<div style="display:flex; height:100dvh; overflow:hidden;">
<!-- Backdrop (mobile overlay) -->
<div id="sidebar-backdrop" class="sidebar-backdrop" onclick="closeSidebar()"></div>
<!-- ══════════════════ SIDEBAR ══════════════════ -->
<aside id="sidebar" class="sidebar">
<!-- Шапка -->
<div class="sidebar-header">
<div class="sidebar-logo">
<img src="{% static 'assets/img/logosm.svg' %}" alt="АКС" style="width:100%; height:100%; object-fit:contain;">
</div>
<div class="sidebar-logo-text">
<div style="color:var(--color-sidebar-accent-foreground); font-size:0.8125rem; font-weight:700; line-height:1.2; letter-spacing:0.01em;">{% trans "Сеть аптек" %}</div>
<div style="color:var(--color-sidebar-foreground); font-size:0.625rem; opacity:0.6;">{% trans "Портал пользователя" %}</div>
</div>
</div>
<!-- Навигация -->
<nav style="flex:1; overflow-y:auto; overflow-x:hidden; padding:0.5rem; display:flex; flex-direction:column; gap:1rem;">
<!-- Головне меню -->
<div>
<div class="nav-section-label">{% trans "Главное меню" %}</div>
<div style="display:flex; flex-direction:column; gap:2px;">
<a href="/dashboard/" class="nav-item"
hx-get="/dashboard/" hx-target="#main-content" hx-push-url="/dashboard/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<span class="nav-label-text">{% trans "Главная" %}</span>
</a>
<a href="/news/" class="nav-item"
hx-get="/news/" hx-target="#main-content" hx-push-url="/news/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/>
<path d="M18 14h-8M15 18h-5M10 6h8v4h-8V6Z"/>
</svg>
<span class="nav-label-text">{% trans "Новости" %}</span>
</a>
<a href="/hr-portal/" class="nav-item"
hx-get="/hr-portal/" hx-target="#main-content" hx-push-url="/hr-portal/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="nav-label-text">{% trans "HR портал" %}</span>
</a>
<a href="/tasks/" class="nav-item"
hx-get="/tasks/" hx-target="#main-content" hx-push-url="/tasks/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect width="8" height="4" x="8" y="2" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="m9 14 2 2 4-4"/>
</svg>
<span class="nav-label-text">{% trans "Задачи" %}</span>
</a>
<a href="/dislocation/" class="nav-item"
hx-get="/dislocation/" hx-target="#main-content" hx-push-url="/dislocation/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>
</svg>
<span class="nav-label-text">{% trans "Дислокация" %}</span>
</a>
<a href="/documents/" class="nav-item"
hx-get="/documents/" hx-target="#main-content" hx-push-url="/documents/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/>
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 9H8M16 13H8M16 17H8"/>
</svg>
<span class="nav-label-text">{% trans "Документы" %}</span>
</a>
{% if request.user|can_section:'email' %}
<a href="/email/" class="nav-item"
hx-get="/email/" hx-target="#main-content" hx-push-url="/email/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
<span class="nav-label-text">{% trans "Электронная почта" %}</span>
</a>
{% endif %}
<a href="/chat/" class="nav-item"
hx-get="/chat/" hx-target="#main-content" hx-push-url="/chat/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>
</svg>
<span class="nav-label-text">{% trans "Чат" %}</span>
</a>
<a href="/calendar/" class="nav-item"
hx-get="/calendar/" hx-target="#main-content" hx-push-url="/calendar/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect width="18" height="18" x="3" y="4" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>
</svg>
<span class="nav-label-text">{% trans "Календарь" %}</span>
</a>
{% if request.user|can_section:'databases' %}
<a href="/databases/" class="nav-item"
hx-get="/databases/" hx-target="#main-content" hx-push-url="/databases/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
<span class="nav-label-text">{% trans "Системные справочники" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'reports' %}
<a href="/reports/" class="nav-item"
hx-get="/reports/" hx-target="#main-content" hx-push-url="/reports/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/>
</svg>
<span class="nav-label-text">{% trans "Отчёты" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'purchases' %}
<a href="/purchases/" class="nav-item"
hx-get="/purchases/" hx-target="#main-content" hx-push-url="/purchases/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="8" cy="21" r="1"/><circle cx="19" cy="21" r="1"/>
<path d="M2.05 2.05h2l2.66 12.42a2 2 0 0 0 2 1.58h9.78a2 2 0 0 0 1.95-1.57l1.65-7.43H5.12"/>
</svg>
<span class="nav-label-text">{% trans "Закупки" %}</span>
</a>
{% endif %}
{% if request.user|can_accounting %}
<a href="/accounting/" class="nav-item"
hx-get="/accounting/" hx-target="#main-content" hx-push-url="/accounting/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/>
<path d="M7 7h.01M7 11h4M13 11h4M7 15h10"/>
</svg>
<span class="nav-label-text">{% trans "Учётные данные" %}</span>
</a>
{% endif %}
<a href="/settings/" class="nav-item"
hx-get="/settings/" hx-target="#main-content" hx-push-url="/settings/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span class="nav-label-text">{% trans "Настройки" %}</span>
</a>
</div>
</div>
<!-- Управління -->
<div>
<div class="nav-section-label">{% trans "Управление" %}</div>
<div style="display:flex; flex-direction:column; gap:2px;">
{% if request.user|can_section:'structure' %}
<a href="/structure/" class="nav-item"
hx-get="/structure/" hx-target="#main-content" hx-push-url="/structure/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 4h12M2 8h8M2 12h5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span class="nav-label-text">{% trans "Структура" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'user_groups' %}
<a href="/user-groups/" class="nav-item"
hx-get="/user-groups/" hx-target="#main-content" hx-push-url="/user-groups/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="nav-label-text">{% trans "Группы пользователей" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'pharmacies' %}
<a href="/pharmacies/" class="nav-item"
hx-get="/pharmacies/" hx-target="#main-content" hx-push-url="/pharmacies/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z"/>
<path d="M15 5.764v15"/><path d="M9 3.236v15"/>
</svg>
<span class="nav-label-text">{% trans "Аптеки" %}</span>
</a>
{% endif %}
</div>
</div>
<!-- Підтримка -->
<div>
<div class="nav-section-label">{% trans "Поддержка" %}</div>
<div style="display:flex; flex-direction:column; gap:2px;">
<a href="/help/" class="nav-item"
hx-get="/help/" hx-target="#main-content" hx-push-url="/help/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>
</svg>
<span class="nav-label-text">{% trans "Справка" %}</span>
</a>
<a href="/it-support/" class="nav-item"
hx-get="/it-support/" hx-target="#main-content" hx-push-url="/it-support/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 18v-6a9 9 0 0 1 18 0v6"/>
<path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/>
</svg>
<span class="nav-label-text">{% trans "Поддержка IT" %}</span>
</a>
{% if request.user|can_section:'it_manager' %}
<a href="/it-manager/" class="nav-item"
hx-get="/it-manager/" hx-target="#main-content" hx-push-url="/it-manager/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>
</svg>
<span class="nav-label-text">{% trans "IT-отдел" %}</span>
</a>
{% endif %}
{% if request.user|can_section:'ad_mapping' %}
<a href="/ad-mapping/" class="nav-item"
hx-get="/ad-mapping/" hx-target="#main-content" hx-push-url="/ad-mapping/"
hx-indicator="#loader" onclick="setActive(this)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect width="8" height="8" x="2" y="2" rx="2"/><rect width="8" height="8" x="14" y="14" rx="2"/>
<path d="M10 6h4a2 2 0 0 1 2 2v2M14 18h-4a2 2 0 0 1-2-2v-2"/>
</svg>
<span class="nav-label-text">{% trans "Active Directory" %}</span>
</a>
{% endif %}
</div>
</div>
</nav>
<!-- Футер сайдбара -->
<div class="sidebar-footer" style="justify-content:center; padding:0.75rem;">
<span class="sidebar-logo-text" style="font-size:0.625rem; color:var(--color-sidebar-foreground); opacity:0.4; white-space:nowrap;">© 2026 АКС Корпоративний Портал | v1.0</span>
</div>
</aside>
<!-- ══════════════════ MAIN ══════════════════ -->
<div style="flex:1; display:flex; flex-direction:column; min-width:0; height:100vh;">
<!-- Header -->
<header style="height:56px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--color-border); background:var(--color-card); padding:0 1.25rem; flex-shrink:0; gap:1rem;">
<div style="display:flex; align-items:center; gap:0.75rem; flex:1;">
<!-- Кнопка сворачивания -->
<button onclick="toggleSidebar()" class="btn btn-ghost btn-icon" title="Згорнути меню">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/>
</svg>
</button>
<!-- Поиск -->
<div class="header-search" style="display:flex; align-items:center; gap:0.5rem; background:var(--color-muted); border:1px solid var(--color-border); border-radius:var(--radius); padding:0.4375rem 0.75rem; width:18rem;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--color-muted-foreground); flex-shrink:0;">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
</svg>
<input type="text"
placeholder="{% trans 'Поиск...' %}"
class="input"
style="border:none; background:transparent; padding:0; box-shadow:none;"
hx-get="/api/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
name="q">
</div>
</div>
<div style="display:flex; align-items:center; gap:0.5rem;">
{% if dev_access_preview_enabled %}
<!-- Dev: смотреть как роль -->
<form method="post" action="{% url 'access_preview' %}" id="dev-access-preview-form" style="margin:0;">
{% csrf_token %}
<input type="hidden" name="next" value="">
<select name="preview" title="{% trans 'Смотреть как…' %}"
onchange="this.form.next.value = window.location.pathname + window.location.search; this.form.submit();"
class="input"
style="height:2rem; padding:0 0.5rem; font-size:0.75rem; width:auto; max-width:12rem; cursor:pointer;{% if dev_access_preview_active %} border-color:#d97706; background:color-mix(in srgb, #f59e0b 14%, var(--color-card));{% endif %}">
{% for preset in dev_access_preview_presets %}
<option value="{{ preset.id }}" {% if preset.id == dev_access_preview %}selected{% endif %}>{{ preset.label }}</option>
{% endfor %}
</select>
</form>
{% endif %}
<!-- Language switcher -->
{% get_current_language as CUR_LANG %}
<form action="{% url 'set_language' %}" method="post" style="margin:0;">
{% csrf_token %}
<input type="hidden" name="next" value="/">
<select name="language" onchange="this.form.next.value = window.location.pathname; this.form.submit();" title="{% trans 'Язык' %}"
class="input" style="height:2rem; padding:0 0.5rem; font-size:0.75rem; width:auto; cursor:pointer;">
{% for code, name in LANGUAGES %}
<option value="{{ code }}" {% if code == CUR_LANG %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</form>
<!-- Theme switcher -->
<div style="position:relative;" id="theme-switcher-wrapper">
<button onclick="toggleThemeSwitcher()" class="btn btn-ghost btn-icon" title="Тема інтерфейсу">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 2a10 10 0 0 1 0 20V2z" fill="currentColor" stroke="none"/>
</svg>
</button>
<div id="theme-dropdown" style="display:none; position:absolute; top:calc(100% + 8px); right:0; background:var(--color-card); border:1px solid var(--color-border); border-radius:var(--radius); box-shadow:0 8px 24px rgba(0,0,0,0.15); z-index:1000; padding:0.375rem; min-width:160px;">
<div style="padding:0.25rem 0.625rem 0.375rem; font-size:0.625rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em;">Тема</div>
<button class="theme-option" data-theme-value="light" onclick="applyTheme('light')">
<span class="theme-dot" style="background:hsl(224.8,81%,60.8%);"></span>
Light Blue
<span class="theme-check">✓</span>
</button>
<button class="theme-option" data-theme-value="dark" onclick="applyTheme('dark')">
<span class="theme-dot" style="background:hsl(215,25%,10%); border-color:hsl(215,25%,30%);"></span>
Dark
<span class="theme-check">✓</span>
</button>
<button class="theme-option" data-theme-value="slate" onclick="applyTheme('slate')">
<span class="theme-dot" style="background:hsl(215,30%,14%); border-color:hsl(215,25%,30%);"></span>
Slate
<span class="theme-check">✓</span>
</button>
<button class="theme-option" data-theme-value="emerald" onclick="applyTheme('emerald')">
<span class="theme-dot" style="background:hsl(152,58%,38%);"></span>
Emerald
<span class="theme-check">✓</span>
</button>
<div style="height:1px; background:var(--color-border); margin:0.375rem 0;"></div>
<div style="padding:0.25rem 0.625rem 0.375rem; font-size:0.625rem; color:var(--color-muted-foreground); text-transform:uppercase; letter-spacing:0.05em;">Фон</div>
<label class="theme-option" style="cursor:pointer;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
</svg>
Обрати фото
<input type="file" id="bg-file-input" accept="image/*" style="display:none" onchange="handleBgFile(this)">
</label>
<div id="bg-opacity-row" style="display:none; padding:0.25rem 0.625rem 0.375rem; align-items:center; gap:0.5rem;">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--color-muted-foreground); flex-shrink:0;">
<circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 0 20V2z" fill="currentColor" stroke="none"/>
</svg>
<input type="range" id="bg-opacity-slider" min="40" max="98" value="88" style="flex:1; accent-color:var(--color-primary); cursor:pointer;" oninput="setBgOpacity(this.value)">
</div>
<button class="theme-option" id="bg-clear-btn" style="display:none;" onclick="clearBgImage()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
Без фону
</button>
</div>
</div>
<!-- Уведомления -->
<button class="btn btn-ghost btn-icon" style="position:relative;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
</svg>
<span style="position:absolute; top:4px; right:4px; width:7px; height:7px; background:var(--color-destructive); border-radius:50%;"></span>
</button>
<!-- Аватар пользователя + дропдаун -->
<div style="position:relative;" id="user-menu-wrapper">
<div onclick="toggleUserMenu()" style="width:2rem; height:2rem; border-radius:50%; cursor:pointer; flex-shrink:0; user-select:none; overflow:hidden;
{% if not request.user.photo %}background:var(--color-accent); color:var(--color-accent-foreground); display:flex; align-items:center; justify-content:center; font-size:0.6875rem; font-weight:700;{% endif %}">
{% if request.user.photo %}
<img src="{{ request.user.photo.url }}" alt="" style="width:100%; height:100%; object-fit:cover; display:block;">
{% else %}
{{ request.user|initials }}
{% endif %}
</div>
<div id="user-dropdown" style="display:none; position:absolute; top:calc(100% + 8px); right:0; min-width:200px; background:var(--color-card); border:1px solid var(--color-border); border-radius:var(--radius); box-shadow:0 8px 24px rgba(0,0,0,0.18); z-index:1000; overflow:hidden;">
<!-- Имя пользователя -->
<div style="padding:0.75rem 1rem 0.625rem; border-bottom:1px solid var(--color-border);">
<div style="font-size:0.8125rem; font-weight:600; color:var(--color-foreground);">{{ request.user.get_full_name|default:request.user.username }}</div>
<div style="font-size:0.6875rem; color:var(--color-muted-foreground); margin-top:0.125rem;">{{ request.user.email }}</div>
</div>
<!-- Пункты меню -->
<div style="padding:0.25rem 0;">
<a href="/employees/{{ request.user.pk }}/?tab=overview" hx-get="/employees/{{ request.user.pk }}/?tab=overview" hx-target="#main-content" hx-push-url="/employees/{{ request.user.pk }}/?tab=overview" hx-indicator="#loader" onclick="closeUserMenu()" style="display:flex; align-items:center; gap:0.625rem; padding:0.5rem 1rem; font-size:0.8125rem; color:var(--color-foreground); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--color-muted)'" onmouseout="this.style.background='transparent'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{% trans "Профиль" %}
</a>
<a href="/settings/" hx-get="/settings/" hx-target="#main-content" hx-push-url="/settings/" hx-indicator="#loader" onclick="closeUserMenu()" style="display:flex; align-items:center; gap:0.625rem; padding:0.5rem 1rem; font-size:0.8125rem; color:var(--color-foreground); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--color-muted)'" onmouseout="this.style.background='transparent'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
{% trans "Настройки" %}
</a>
<a href="/help/" hx-get="/help/" hx-target="#main-content" hx-push-url="/help/" hx-indicator="#loader" onclick="closeUserMenu()" style="display:flex; align-items:center; gap:0.625rem; padding:0.5rem 1rem; font-size:0.8125rem; color:var(--color-foreground); text-decoration:none; transition:background 0.15s;" onmouseover="this.style.background='var(--color-muted)'" onmouseout="this.style.background='transparent'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>
{% trans "Справка" %}
</a>
<div style="height:1px; background:var(--color-border); margin:0.25rem 0;"></div>
<form method="post" action="{% url 'logout' %}" style="margin:0;">
{% csrf_token %}
<button type="submit" style="width:100%; display:flex; align-items:center; gap:0.625rem; padding:0.5rem 1rem; font-size:0.8125rem; color:var(--color-destructive); background:none; border:none; cursor:pointer; text-align:left; transition:background 0.15s;" onmouseover="this.style.background='var(--color-muted)'" onmouseout="this.style.background='transparent'">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
{% trans "Выход" %}
</button>
</form>
</div>
</div>
</div>
</div>
</header>
{% if dev_access_preview_active %}
<div style="flex-shrink:0; background:color-mix(in srgb, #f59e0b 18%, var(--color-card)); border-bottom:1px solid color-mix(in srgb, #d97706 40%, var(--color-border)); padding:0.35rem 1.25rem; font-size:0.75rem; color:var(--color-foreground); display:flex; align-items:center; justify-content:space-between; gap:0.75rem;">
<span>{% trans "Режим разработки" %}: {% trans "меню и доступы как" %} <strong>{{ dev_access_preview_label }}</strong></span>
<form method="post" action="{% url 'access_preview' %}" style="margin:0;">
{% csrf_token %}
<input type="hidden" name="preview" value="">
<input type="hidden" name="next" value="">
<button type="submit" class="btn btn-ghost" style="font-size:0.7rem; padding:0.15rem 0.5rem; height:auto;"
onclick="this.form.next.value = window.location.pathname + window.location.search;">
{% trans "Сбросить" %}
</button>
</form>
</div>
{% endif %}
<!-- Контент (HTMX заменяет содержимое) -->
<div style="flex:1; min-height:0; position:relative; display:flex; flex-direction:column;">
<div id="page-preloader" class="page-preloader" aria-hidden="true" aria-live="polite">
<div class="page-preloader-spinner" role="status" aria-label="{% trans 'Загрузка' %}"></div>
</div>
<main id="main-content" style="flex:1; overflow-y:auto; background:var(--color-background); padding:1.5rem;">
{% if page_template %}
{% include page_template %}
{% else %}
<div id="page-loader" style="padding:2rem;color:var(--color-muted-foreground);font-size:0.875rem;">{% trans "Загрузка..." %}</div>
{% endif %}
</main>
</div>
</div>
<!-- Контейнер модалок -->
<div id="modal-container"></div>
<!-- ══════════════════ BOTTOM NAV (mobile) ══════════════════ -->
<nav class="bottom-nav" id="bottom-nav">
<a href="/dashboard/" class="bottom-nav-item"
hx-get="/dashboard/" hx-target="#main-content" hx-push-url="/dashboard/" hx-indicator="#loader"
onclick="bottomNavClick(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<span>{% trans "Главная" %}</span>
</a>
<a href="/tasks/" class="bottom-nav-item"
hx-get="/tasks/" hx-target="#main-content" hx-push-url="/tasks/" hx-indicator="#loader"
onclick="bottomNavClick(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect width="8" height="4" x="8" y="2" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="m9 14 2 2 4-4"/>
</svg>
<span>{% trans "Задачи" %}</span>
</a>
<a href="/hr-portal/" class="bottom-nav-item"
hx-get="/hr-portal/" hx-target="#main-content" hx-push-url="/hr-portal/" hx-indicator="#loader"
onclick="bottomNavClick(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span>{% trans "Люди" %}</span>
</a>
<a href="/chat/" class="bottom-nav-item"
hx-get="/chat/" hx-target="#main-content" hx-push-url="/chat/" hx-indicator="#loader"
onclick="bottomNavClick(this)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>
</svg>
<span>{% trans "Чат" %}</span>
</a>
<button class="bottom-nav-item" onclick="toggleSidebar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<span>{% trans "Меню" %}</span>
</button>
</nav>
</div>
<script>
(function () {
var loaderBar = document.getElementById('loader');
var pagePreloader = document.getElementById('page-preloader');
var pending = 0;
function isMainNav(detail) {
if (!detail) return false;
var target = detail.target;
if (target && target.id === 'main-content') return true;
// htmx.ajax / redirects may expose elt with hx-target
var elt = detail.elt;
if (!elt || !elt.getAttribute) return false;
var hxTarget = elt.getAttribute('hx-target');
return hxTarget === '#main-content';
}
function showPageLoader() {
pending += 1;
if (loaderBar) loaderBar.classList.add('is-active');
if (pagePreloader) {
pagePreloader.classList.add('is-active');
pagePreloader.setAttribute('aria-hidden', 'false');
}
}
function hidePageLoader() {
pending = Math.max(0, pending - 1);
if (pending > 0) return;
if (loaderBar) loaderBar.classList.remove('is-active');
if (pagePreloader) {
pagePreloader.classList.remove('is-active');
pagePreloader.setAttribute('aria-hidden', 'true');
}
}
document.body.addEventListener('htmx:beforeRequest', function (e) {
if (isMainNav(e.detail)) showPageLoader();
});
document.body.addEventListener('htmx:afterSwap', function (e) {
if (e.detail && e.detail.target && e.detail.target.id === 'main-content') {
hidePageLoader();
}
});
document.body.addEventListener('htmx:afterRequest', function (e) {
if (!isMainNav(e.detail)) return;
var xhr = e.detail.xhr;
var redirected = xhr && xhr.getResponseHeader && xhr.getResponseHeader('HX-Redirect');
if (e.detail.failed || redirected) hidePageLoader();
});
document.body.addEventListener('htmx:sendError', function (e) {
if (isMainNav(e.detail)) hidePageLoader();
});
window.__showPageLoader = showPageLoader;
window.__hidePageLoader = hidePageLoader;
})();
// Активный пункт меню по текущему URL
function setActiveNavItem() {
const currentPath = window.location.pathname;
document.querySelectorAll('.nav-item').forEach(item => {
const hxGet = item.getAttribute('hx-get');
item.classList.toggle('active', !!hxGet && hxGet === currentPath);
});
syncBottomNav(currentPath);
}
document.addEventListener('DOMContentLoaded', function() {
const mainContent = document.getElementById('main-content');
// Плейсхолдер определяем по элементу, а не по тексту (текст переводится)
const isPlaceholder = !!mainContent.querySelector('#page-loader');
// Если сервер не отдал страницу — грузим по URL
if (isPlaceholder) {
const currentPath = window.location.pathname;
const pageUrl = currentPath === '/' ? '/dashboard/' : currentPath;
htmx.ajax('GET', pageUrl, {target:'#main-content', swap:'innerHTML'});
}
setActiveNavItem();
// Восстановление темы и фона
var savedTheme = localStorage.getItem('theme') || 'light';
document.querySelectorAll('.theme-option').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-theme-value') === savedTheme);
});
var savedBg = localStorage.getItem('bg-image');
if (savedBg) {
var savedOpacity = parseFloat(localStorage.getItem('bg-opacity') || '0.88');
applyBgImage(savedBg, savedOpacity);
}
});
// После загрузки фрагмента — активный пункт и init страницы
document.body.addEventListener('htmx:afterSwap', function(e) {
if (e.detail.target.id !== 'main-content') return;
setActiveNavItem();
if (typeof pharmaInit === 'function') pharmaInit();
if (typeof empInit === 'function') empInit();
if (typeof ttInit === 'function') ttInit();
if (typeof hrPortalInit === 'function') hrPortalInit();
if (typeof empProfileInit === 'function') empProfileInit();
});
</script>
</body>
</html>