Files
ACORE/static/assets/js/people-picker.js
T
lokses 78475b58e9 Инфраструктура: Celery, общие утилиты, виджеты, шелл портала
- acore: Celery-приложение (брокер из env, eager без брокера), настройки
  CELERY_*, регистрация всех новых приложений в INSTALLED_APPS, маршруты
- core/utils: render_page (HTMX-фрагмент/полный layout), is_owner/
  is_owner_or_staff; templatetags common_tags (initials, clean_excerpt,
  is_it_staff); UpdateLastSeenMiddleware
- static: people-picker (дерево отделов с аватарками), searchable-select
  (одиночный выбор с поиском), directory-quick-add, it-manager, pagination;
  тема Tailwind v4 пересобрана, лого
- templates: новый шелл index.html (мобильный сайдбар, темы, фон,
  user-dropdown) и login с брендингом

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:31:01 +03:00

343 lines
13 KiB
JavaScript

/**
* Пікер вибору людей у стилі Бітрікс: чіпи вибраних + пошук + дерево
* відділів з фото/посадами. Працює поверх звичайного <select multiple> —
* він лишається джерелом істини для відправки форми, віджет лише малює UI.
*
* Розмітка (одна секція):
* <div class="people-picker" data-tree-source="department-tree-data">
* <select multiple hidden>
* <option value="1" data-label="Іван Іванов" data-position="Касир" data-photo="/media/...">...</option>
* </select>
* </div>
* Дерево відділів передається один раз на сторінку через
* {{ department_tree|json_script:"department-tree-data" }}.
*
* Використання: initPeoplePickers(scopeEl) після вставки розмітки в DOM
* (в т.ч. після HTMX-свапу фрагмента).
*/
(function () {
function options(select) {
return Array.prototype.slice.call(select.options);
}
function initialsOf(label) {
var parts = (label || '').trim().split(/\s+/).filter(Boolean);
return parts.slice(0, 2).map(function (w) { return w[0]; }).join('').toUpperCase();
}
function buildAvatar(photo, label, size) {
size = size || '1.75rem';
if (photo) {
var img = document.createElement('img');
img.src = photo;
img.className = 'people-picker-avatar';
img.style.width = size;
img.style.height = size;
return img;
}
var span = document.createElement('span');
span.className = 'people-picker-avatar people-picker-avatar-fallback';
span.style.width = size;
span.style.height = size;
span.textContent = initialsOf(label);
return span;
}
function findOption(select, pk) {
return options(select).filter(function (o) { return o.value === String(pk); })[0];
}
function renderChips(picker) {
var select = picker._select;
var chips = picker._chips;
chips.innerHTML = '';
options(select).filter(function (o) { return o.selected; }).forEach(function (opt) {
var chip = document.createElement('span');
chip.className = 'people-picker-chip';
chip.textContent = opt.dataset.label || opt.textContent;
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'people-picker-chip-remove';
remove.innerHTML = '&times;';
remove.setAttribute('aria-label', 'Видалити');
remove.addEventListener('click', function () {
opt.selected = false;
select.dispatchEvent(new Event('change'));
renderChips(picker);
if (picker._refreshTree) picker._refreshTree();
});
chip.appendChild(remove);
chips.appendChild(chip);
});
}
function toggleOption(picker, pk) {
var opt = findOption(picker._select, pk);
if (!opt) return;
opt.selected = !opt.selected;
picker._select.dispatchEvent(new Event('change'));
renderChips(picker);
if (picker._refreshTree) picker._refreshTree();
}
function renderSearchResults(picker, query) {
var select = picker._select;
var results = picker._results;
var q = query.trim().toLowerCase();
var matches = options(select).filter(function (o) {
if (o.selected) return false;
var label = (o.dataset.label || o.textContent).toLowerCase();
return label.indexOf(q) !== -1;
}).slice(0, 30);
results.innerHTML = '';
matches.forEach(function (opt) {
var row = document.createElement('div');
row.className = 'people-picker-row';
row.appendChild(buildAvatar(opt.dataset.photo, opt.dataset.label));
var info = document.createElement('div');
info.className = 'people-picker-row-info';
var name = document.createElement('div');
name.className = 'people-picker-row-name';
name.textContent = opt.dataset.label || opt.textContent;
info.appendChild(name);
if (opt.dataset.position) {
var pos = document.createElement('div');
pos.className = 'people-picker-row-position';
pos.textContent = opt.dataset.position;
info.appendChild(pos);
}
row.appendChild(info);
row.addEventListener('mousedown', function (e) {
e.preventDefault();
toggleOption(picker, opt.value);
picker._search.value = '';
showTree(picker);
});
results.appendChild(row);
});
}
function renderEmployeeRow(picker, emp, container) {
var opt = findOption(picker._select, emp.pk);
var row = document.createElement('div');
row.className = 'people-picker-row';
if (opt && opt.selected) row.classList.add('is-selected');
row.appendChild(buildAvatar(emp.photo, emp.label));
var info = document.createElement('div');
info.className = 'people-picker-row-info';
var name = document.createElement('div');
name.className = 'people-picker-row-name';
name.textContent = emp.label;
info.appendChild(name);
if (emp.position) {
var pos = document.createElement('div');
pos.className = 'people-picker-row-position';
pos.textContent = emp.position;
info.appendChild(pos);
}
row.appendChild(info);
if (opt && opt.selected) {
var check = document.createElement('span');
check.className = 'people-picker-row-check';
check.innerHTML = '&check;';
row.appendChild(check);
}
row.addEventListener('mousedown', function (e) {
e.preventDefault();
toggleOption(picker, emp.pk);
row.classList.toggle('is-selected');
var existingCheck = row.querySelector('.people-picker-row-check');
if (row.classList.contains('is-selected') && !existingCheck) {
var c = document.createElement('span');
c.className = 'people-picker-row-check';
c.innerHTML = '&check;';
row.appendChild(c);
} else if (!row.classList.contains('is-selected') && existingCheck) {
existingCheck.remove();
}
});
container.appendChild(row);
}
function renderDeptNode(picker, node, container, depth, expanded) {
var row = document.createElement('div');
row.className = 'people-picker-dept';
row.style.paddingLeft = (depth * 0.875) + 'rem';
var toggle = document.createElement('span');
toggle.className = 'people-picker-dept-toggle';
toggle.textContent = expanded ? '▾' : '▸';
row.appendChild(toggle);
var folder = document.createElement('span');
folder.className = 'people-picker-dept-icon';
folder.innerHTML = '&#128193;';
row.appendChild(folder);
var label = document.createElement('span');
label.className = 'people-picker-dept-label';
label.textContent = node.name;
row.appendChild(label);
container.appendChild(row);
var childrenWrap = document.createElement('div');
childrenWrap.style.display = expanded ? 'block' : 'none';
container.appendChild(childrenWrap);
var built = false;
function build() {
if (built) return;
node.employees.forEach(function (emp) { renderEmployeeRow(picker, emp, childrenWrap); });
node.children.forEach(function (child) { renderDeptNode(picker, child, childrenWrap, depth + 1, false); });
built = true;
}
if (expanded) build();
row.addEventListener('click', function () {
build();
var isOpen = childrenWrap.style.display !== 'none';
childrenWrap.style.display = isOpen ? 'none' : 'block';
toggle.textContent = isOpen ? '▸' : '▾';
});
}
function showTree(picker) {
picker._search.value = '';
picker._results.style.display = 'none';
picker._results.innerHTML = '';
picker._tree.style.display = 'block';
if (picker._refreshTree) picker._refreshTree();
}
function buildTreeView(picker, treeData) {
var tree = picker._tree;
function draw() {
tree.innerHTML = '';
treeData.tree.forEach(function (node, i) {
renderDeptNode(picker, node, tree, 0, true);
});
if (treeData.no_department && treeData.no_department.length) {
var wrap = document.createElement('div');
var header = document.createElement('div');
header.className = 'people-picker-dept-label';
header.style.padding = '0.375rem 0.5rem';
header.style.opacity = '0.7';
header.textContent = 'Без відділу';
wrap.appendChild(header);
treeData.no_department.forEach(function (emp) { renderEmployeeRow(picker, emp, wrap); });
tree.appendChild(wrap);
}
}
picker._refreshTree = draw;
draw();
}
function loadTreeData(picker) {
var sourceId = picker.dataset.treeSource;
if (!sourceId) return null;
var el = document.getElementById(sourceId);
if (!el) return null;
try {
return JSON.parse(el.textContent);
} catch (e) {
return null;
}
}
function build(picker) {
var select = picker.querySelector('select[multiple]');
if (!select) return;
picker._select = select;
var wrap = document.createElement('div');
wrap.className = 'people-picker-ui';
var chips = document.createElement('div');
chips.className = 'people-picker-chips';
picker._chips = chips;
var search = document.createElement('input');
search.type = 'text';
search.className = 'input people-picker-search';
search.placeholder = picker.dataset.placeholder || '+ Додати...';
picker._search = search;
var dropdown = document.createElement('div');
dropdown.className = 'people-picker-dropdown';
dropdown.style.display = 'none';
var results = document.createElement('div');
results.className = 'people-picker-results';
results.style.display = 'none';
picker._results = results;
var tree = document.createElement('div');
tree.className = 'people-picker-tree';
picker._tree = tree;
dropdown.appendChild(results);
dropdown.appendChild(tree);
picker._dropdown = dropdown;
var treeData = loadTreeData(picker);
if (treeData) buildTreeView(picker, treeData);
function openDropdown() {
dropdown.style.display = 'block';
if (search.value.trim()) {
results.style.display = 'block';
tree.style.display = 'none';
renderSearchResults(picker, search.value);
} else {
showTree(picker);
}
}
search.addEventListener('focus', openDropdown);
search.addEventListener('input', function () {
if (search.value.trim()) {
results.style.display = 'block';
tree.style.display = 'none';
renderSearchResults(picker, search.value);
} else {
showTree(picker);
}
});
search.addEventListener('keydown', function (e) {
if (e.key === 'Backspace' && !search.value) {
var selected = options(select).filter(function (o) { return o.selected; });
var last = selected[selected.length - 1];
if (last) {
last.selected = false;
select.dispatchEvent(new Event('change'));
renderChips(picker);
if (picker._refreshTree) picker._refreshTree();
}
} else if (e.key === 'Escape') {
dropdown.style.display = 'none';
search.blur();
}
});
document.addEventListener('click', function (e) {
if (!picker.contains(e.target)) dropdown.style.display = 'none';
});
wrap.appendChild(chips);
wrap.appendChild(search);
picker.appendChild(wrap);
picker.appendChild(dropdown);
renderChips(picker);
}
window.initPeoplePickers = function (scope) {
(scope || document).querySelectorAll('.people-picker:not([data-picker-init])').forEach(function (picker) {
picker.setAttribute('data-picker-init', '1');
build(picker);
});
};
})();