BREAKING CHANGES: - Removed OU=Roles from LDAP structure, role groups now created directly in department OU - Role groups use ROLE_<Name> naming convention (similar to DEP_, POS_) New features: - RoleAssignment model: allows assigning roles to ANY employee, not just department members - New API endpoints: /assign/, /revoke/, /assignments/ on DepartmentRoleViewSet - Permission system updated: has_dept_perm() checks RoleAssignment first - Employee profile shows external roles with 'внешняя роль' badge - Employee list filter includes role-only employees with department_relation field - Role assignment modal now searches all employees via AJAX LDAP changes: - _ensure_role_group(): creates ROLE_<Name> group in department OU - assign_role(), revoke_role(): manage RoleAssignment + LDAP sync - rename_role_group(), delete_role_group(): full lifecycle support - Removed all references to OU=Roles Migrations: - 0035: Create RoleAssignment model - 0036: Data migration from EmployeeDepartment.role Tests: - 7 new tests for role assignment (all passing) Docs: - 9 detailed reports on architecture, models, API, LDAP, tests
347 lines
12 KiB
HTML
347 lines
12 KiB
HTML
{% load static %}
|
||
{# ==== JS: круг с автоскроллом/ручным управлением и индикатором ==== #}
|
||
<script type="module">
|
||
import { initTeamWheel } from '{% static "js/components/teamWheel.js" %}';
|
||
|
||
initTeamWheel({
|
||
wheelId: 'teamWheel-{{ dept.id }}',
|
||
dataSourceId: 'teamData-{{ dept.id }}',
|
||
fallback: {
|
||
avatar: "{{ dept.head.avatar|default:'' }}",
|
||
name: "{% if dept.head %}{{ dept.head.display_name|default:dept.head.last_name }}{% else %}Руководитель{% endif %}",
|
||
role: "Руководитель отдела",
|
||
email: "{% if dept.head and dept.head.email %}{{ dept.head.email }}{% endif %}"
|
||
}
|
||
});
|
||
</script>
|
||
|
||
{# ===== Переключатель панели настроек (скролл к блоку при открытии) ===== #}
|
||
<script type="module">
|
||
import { smoothScrollTo } from '{% static "js/utils/scroll.js" %}';
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const wrap = document.getElementById('deptEditWrap-{{ dept.id }}');
|
||
if (!wrap) return;
|
||
|
||
const isMobile = () => window.matchMedia('(max-width: 767.98px)').matches;
|
||
|
||
function headerOffset(){
|
||
const sticky = document.querySelector('.ios-header');
|
||
return (sticky?.offsetHeight || 0) + 8;
|
||
}
|
||
|
||
wrap.addEventListener('shown.bs.collapse', () => {
|
||
if (isMobile()) smoothScrollTo(wrap, { offset: headerOffset() });
|
||
});
|
||
|
||
if (wrap.classList.contains('show') && isMobile()) {
|
||
setTimeout(() => smoothScrollTo(wrap, { offset: headerOffset() }), 50);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
{# ===== Модал «Добавить участника»: AJAX-поиск ===== #}
|
||
<script type="module">
|
||
{% url 'employees:employees_list' as employees_list_url %}
|
||
import { esc } from '{% static "js/utils/stringUtils.js" %}';
|
||
import { debounce } from '{% static "js/utils/timing.js" %}';
|
||
|
||
(function(){
|
||
const modal = document.getElementById('addMemberModal-{{ dept.id }}');
|
||
if (!modal) return;
|
||
|
||
const form = modal.querySelector('form');
|
||
const input = modal.querySelector('[data-add-member-q]');
|
||
const listEl = modal.querySelector('[data-add-member-list]');
|
||
const tpl = modal.querySelector('#addMemberItemTpl-{{ dept.id }}');
|
||
const hidden = form ? form.querySelector('input[name="employee_id"]') : null;
|
||
const submit = modal.querySelector('[data-add-member-submit]');
|
||
if (!form || !input || !listEl || !tpl || !hidden || !submit) return;
|
||
|
||
const excludeAttr = (modal.getAttribute('data-exclude') || '').trim();
|
||
const exclude = new Set(
|
||
excludeAttr ? excludeAttr.split(',').map(x => parseInt(x, 10)).filter(n => !Number.isNaN(n)) : []
|
||
);
|
||
|
||
let aborter = null;
|
||
|
||
async function searchEmployees(q){
|
||
if (aborter) aborter.abort();
|
||
aborter = new AbortController();
|
||
|
||
const params = new URLSearchParams({
|
||
format: 'json',
|
||
q: q,
|
||
limit: '20',
|
||
exclude: Array.from(exclude).join(',')
|
||
});
|
||
|
||
const r = await fetch('{{ employees_list_url }}?' + params.toString(), {
|
||
credentials: 'same-origin',
|
||
signal: aborter.signal
|
||
});
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
const data = await r.json().catch(()=>({results:[]}));
|
||
return Array.isArray(data) ? data : (data.results || []);
|
||
}
|
||
|
||
function render(items){
|
||
listEl.innerHTML = '';
|
||
if (!items.length){
|
||
const empty = document.createElement('div');
|
||
empty.className = 'list-group-item text-muted small py-3';
|
||
empty.textContent = 'Ничего не найдено';
|
||
listEl.appendChild(empty);
|
||
return;
|
||
}
|
||
items.forEach(e => {
|
||
const node = tpl.content.firstElementChild.cloneNode(true);
|
||
node.dataset.id = e.id;
|
||
const ava = node.querySelector('.card-icon');
|
||
if (ava){
|
||
ava.innerHTML = e.avatar
|
||
? `<img src="${esc(e.avatar)}" alt="" style="width:100%;height:100%;object-fit:cover;">`
|
||
: '<i class="bi-person"></i>';
|
||
}
|
||
const nameEl = node.querySelector('.js-name');
|
||
if (nameEl){
|
||
nameEl.textContent = e.display_name || (e.last_name ? (e.last_name + (e.first_name ? ' ' + e.first_name : '')) : ('Сотрудник #' + e.id));
|
||
}
|
||
const emailEl = node.querySelector('.js-email');
|
||
if (emailEl) emailEl.textContent = e.email || '';
|
||
|
||
node.addEventListener('click', () => {
|
||
listEl.querySelectorAll('.list-group-item').forEach(li => li.classList.remove('active'));
|
||
node.classList.add('active');
|
||
hidden.value = String(e.id);
|
||
submit.disabled = false;
|
||
submit.removeAttribute('disabled');
|
||
});
|
||
listEl.appendChild(node);
|
||
});
|
||
}
|
||
|
||
const run = debounce(async (q) => {
|
||
submit.disabled = true;
|
||
hidden.value = '';
|
||
try { render(await searchEmployees(q)); }
|
||
catch { listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Ошибка загрузки</div>'; }
|
||
}, 200);
|
||
|
||
input.addEventListener('input', () => run(input.value));
|
||
input.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter'){
|
||
const cur = listEl.querySelector('.list-group-item.active') || listEl.querySelector('.list-group-item');
|
||
if (cur && cur.dataset.id){
|
||
e.preventDefault();
|
||
cur.click();
|
||
}
|
||
}
|
||
});
|
||
|
||
modal.addEventListener('shown.bs.modal', () => {
|
||
input.value = '';
|
||
hidden.value = '';
|
||
submit.disabled = true;
|
||
input.focus();
|
||
run('');
|
||
});
|
||
|
||
form.addEventListener('submit', (e) => {
|
||
if (!hidden.value){
|
||
e.preventDefault();
|
||
input.focus();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
{# ===== Модал «Удалить участника»: локальный выбор ===== #}
|
||
<script type="module">
|
||
import { norm } from '{% static "js/utils/stringUtils.js" %}';
|
||
|
||
(function(){
|
||
const modal = document.getElementById('removeMemberModal-{{ dept.id }}');
|
||
if (!modal) return;
|
||
|
||
const form = modal.querySelector('form');
|
||
const input = document.getElementById('removeMemberSearch-{{ dept.id }}');
|
||
const listBox = document.getElementById('removeMemberResults-{{ dept.id }}');
|
||
const hiddenEmp = document.getElementById('removeMemberChosen-{{ dept.id }}');
|
||
const hiddenLink= document.getElementById('removeMemberLink-{{ dept.id }}');
|
||
const submit = document.getElementById('removeMemberSubmit-{{ dept.id }}');
|
||
if (!form || !input || !listBox || !hiddenEmp || !hiddenLink || !submit) return;
|
||
|
||
function clearActive(){ listBox.querySelectorAll('.active').forEach(el => el.classList.remove('active')); }
|
||
function matches(item, q){
|
||
const name = norm(item.getAttribute('data-name') || '');
|
||
const email = norm(item.getAttribute('data-email') || '');
|
||
const id = String(item.getAttribute('data-id') || '');
|
||
return !q || name.includes(q) || email.includes(q) || id.includes(q);
|
||
}
|
||
function applyFilter(){
|
||
const q = norm(input.value);
|
||
listBox.querySelectorAll('.list-group-item, .list-group-item-action').forEach(it => {
|
||
const ok = matches(it, q);
|
||
it.classList.toggle('d-none', !ok);
|
||
});
|
||
}
|
||
|
||
input.addEventListener('input', applyFilter);
|
||
|
||
listBox.addEventListener('click', (e) => {
|
||
const it = e.target.closest('[data-id]');
|
||
if (!it) return;
|
||
clearActive();
|
||
it.classList.add('active');
|
||
hiddenEmp.value = it.getAttribute('data-id') || '';
|
||
hiddenLink.value = it.getAttribute('data-link-id') || '';
|
||
submit.disabled = !(hiddenEmp.value || hiddenLink.value);
|
||
if (!submit.disabled) submit.removeAttribute('disabled');
|
||
});
|
||
|
||
modal.addEventListener('shown.bs.modal', () => {
|
||
input.value = '';
|
||
clearActive();
|
||
applyFilter();
|
||
hiddenEmp.value = '';
|
||
hiddenLink.value = '';
|
||
submit.disabled = true;
|
||
input.focus();
|
||
});
|
||
|
||
form.addEventListener('submit', (e) => {
|
||
if (!hiddenEmp.value && !hiddenLink.value){
|
||
e.preventDefault();
|
||
input.focus();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
{# ===== Модал «Назначить роль»: AJAX-поиск по всем сотрудникам ===== #}
|
||
<script type="module">
|
||
{% url 'employees:employees_list' as employees_list_url %}
|
||
import { esc } from '{% static "js/utils/stringUtils.js" %}';
|
||
import { debounce } from '{% static "js/utils/timing.js" %}';
|
||
|
||
(function(){
|
||
const modal = document.getElementById('setRoleModal-{{ dept.id }}');
|
||
if (!modal) return;
|
||
|
||
const form = modal.querySelector('form');
|
||
const actionTpl = form ? form.getAttribute('data-action-template') : null;
|
||
const input = modal.querySelector('[data-set-role-q]');
|
||
const listEl = modal.querySelector('[data-set-role-list]');
|
||
const tpl = modal.querySelector('#setRoleItemTpl-{{ dept.id }}');
|
||
const hidden = document.getElementById('setRoleChosen-{{ dept.id }}');
|
||
const select = document.getElementById('setRoleSelect-{{ dept.id }}');
|
||
const submit = document.getElementById('setRoleSubmit-{{ dept.id }}');
|
||
if (!form || !input || !listEl || !tpl || !hidden || !select || !submit) return;
|
||
|
||
let aborter = null;
|
||
|
||
function setAction(empId){
|
||
if (!actionTpl) return;
|
||
// /employees/0/departments/<deptId>/set-role/ -> /employees/<empId>/departments/<deptId>/set-role/
|
||
const newAction = actionTpl.replace(/(\/employees\/)(\d+)(\/)/, '$1' + String(empId) + '$3');
|
||
form.setAttribute('action', newAction);
|
||
}
|
||
|
||
async function searchEmployees(q){
|
||
if (aborter) aborter.abort();
|
||
aborter = new AbortController();
|
||
|
||
const params = new URLSearchParams({
|
||
format: 'json',
|
||
q: q,
|
||
limit: '20'
|
||
});
|
||
|
||
const r = await fetch('{{ employees_list_url }}?' + params.toString(), {
|
||
credentials: 'same-origin',
|
||
signal: aborter.signal
|
||
});
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
const data = await r.json().catch(()=>({results:[]}));
|
||
return Array.isArray(data) ? data : (data.results || []);
|
||
}
|
||
|
||
function render(items){
|
||
listEl.innerHTML = '';
|
||
if (!items.length){
|
||
const empty = document.createElement('div');
|
||
empty.className = 'list-group-item text-muted small py-3';
|
||
empty.textContent = 'Ничего не найдено';
|
||
listEl.appendChild(empty);
|
||
return;
|
||
}
|
||
items.forEach(e => {
|
||
const node = tpl.content.firstElementChild.cloneNode(true);
|
||
node.dataset.id = e.id;
|
||
const ava = node.querySelector('.card-icon');
|
||
if (ava){
|
||
ava.innerHTML = e.avatar
|
||
? `<img src="${esc(e.avatar)}" alt="" style="width:100%;height:100%;object-fit:cover;">`
|
||
: '<i class="bi-person"></i>';
|
||
}
|
||
const nameEl = node.querySelector('.js-name');
|
||
if (nameEl){
|
||
nameEl.textContent = e.display_name || (e.last_name ? (e.last_name + (e.first_name ? ' ' + e.first_name : '')) : ('Сотрудник #' + e.id));
|
||
}
|
||
const emailEl = node.querySelector('.js-email');
|
||
if (emailEl) emailEl.textContent = e.email || '';
|
||
|
||
node.addEventListener('click', () => {
|
||
listEl.querySelectorAll('.list-group-item').forEach(li => li.classList.remove('active'));
|
||
node.classList.add('active');
|
||
hidden.value = String(e.id);
|
||
setAction(e.id);
|
||
submit.disabled = false;
|
||
submit.removeAttribute('disabled');
|
||
});
|
||
listEl.appendChild(node);
|
||
});
|
||
}
|
||
|
||
const run = debounce(async (q) => {
|
||
submit.disabled = true;
|
||
hidden.value = '';
|
||
if (!q || q.length < 2) {
|
||
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Введите минимум 2 символа для поиска…</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = '<div class="list-group-item text-muted small py-3"><span class="spinner-border spinner-border-sm me-2"></span>Поиск...</div>';
|
||
try { render(await searchEmployees(q)); }
|
||
catch (err) {
|
||
if (err.name !== 'AbortError') {
|
||
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Ошибка загрузки</div>';
|
||
}
|
||
}
|
||
}, 300);
|
||
|
||
input.addEventListener('input', () => run(input.value.trim()));
|
||
|
||
modal.addEventListener('shown.bs.modal', () => {
|
||
input.value = '';
|
||
listEl.innerHTML = '<div class="list-group-item text-muted small py-3">Начните вводить для поиска…</div>';
|
||
hidden.value = '';
|
||
submit.disabled = true;
|
||
input.focus();
|
||
if (actionTpl) form.setAttribute('action', actionTpl);
|
||
});
|
||
|
||
form.addEventListener('submit', (e) => {
|
||
if (!hidden.value){
|
||
e.preventDefault();
|
||
input.focus();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
<script type="module">
|
||
import { initTooltips } from "{% static 'js/utils/tooltipInitializer.js' %}";
|
||
initTooltips();
|
||
</script>
|