78475b58e9
- 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>
343 lines
13 KiB
JavaScript
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 = '×';
|
|
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 = '✓';
|
|
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 = '✓';
|
|
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 = '📁';
|
|
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);
|
|
});
|
|
};
|
|
})();
|