Files
ACORE/static/assets/js/hr-portal.js
T
lokses ac0418083b hiring: вакансии и найм, HR-портал
Модель вакансий, флоу рекрутинга и панели HR-портала (заявки/вакансии).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-09 18:03:32 +03:00

536 lines
22 KiB
JavaScript

function hrPortalRoot() {
return document.querySelector('#main-content .page-content[data-hr-active-tab]');
}
function hrTabSwitch(tab) {
var root = hrPortalRoot();
if (!root) return;
root.querySelectorAll('.hr-panel').forEach(function (panel) {
panel.style.display = panel.id === 'hr-panel-' + tab ? 'block' : 'none';
});
root.querySelectorAll('.hr-tab-btn').forEach(function (btn) {
var active = btn.getAttribute('data-tab') === tab;
btn.classList.toggle('hr-tab-active', active);
btn.style.background = active ? 'var(--color-card)' : 'transparent';
btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)';
btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : '';
});
var viewToggle = root.querySelector('#hr-view-toggle');
if (viewToggle) {
viewToggle.style.display = (tab === 'employees') ? 'flex' : 'none';
}
if (tab === 'hiring') hrHiringInit();
if (tab === 'employees') hrFilterEmployees();
}
function hrViewSwitch(view) {
var cardsView = document.getElementById('hr-emp-cards-view');
var tableView = document.getElementById('hr-emp-table-view');
if (cardsView) cardsView.style.display = view === 'cards' ? '' : 'none';
if (tableView) tableView.style.display = view === 'table' ? '' : 'none';
document.querySelectorAll('.hr-view-btn').forEach(function (btn) {
var active = btn.getAttribute('data-view') === view;
btn.classList.toggle('hr-view-active', active);
btn.style.background = active ? 'var(--color-card)' : 'transparent';
btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)';
btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : '';
});
}
function hrLocFilter(loc) {
document.querySelectorAll('.hr-loc-btn').forEach(function (btn) {
var active = btn.getAttribute('data-loc') === loc;
btn.classList.toggle('hr-loc-active', active);
btn.style.background = active ? 'var(--color-card)' : 'transparent';
btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)';
btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : '';
});
hrFilterListPosts(loc);
hrFilterEmployees();
}
/** У фільтрі списку працівників показує лише посади для обраного типу локації. */
function hrFilterListPosts(locationType) {
var select = document.getElementById('hr-post-select');
var dataEl = document.getElementById('hr-post-loc-types-data');
if (!select || !dataEl) return;
var map = {};
try { map = JSON.parse(dataEl.textContent); } catch (e) { map = {}; }
Array.prototype.forEach.call(select.options, function (opt) {
if (!opt.value) return;
var types = map[opt.value] || [];
var ok = !locationType || types.length === 0 || types.indexOf(locationType) !== -1;
opt.hidden = !ok;
opt.disabled = !ok;
});
var sel = select.options[select.selectedIndex];
if (select.value && sel && (sel.hidden || sel.disabled)) {
select.value = '';
}
}
function hrFilterEmployees() {
var searchInput = document.getElementById('hr-search-input');
var deptSelect = document.getElementById('hr-department-select');
var postSelect = document.getElementById('hr-post-select');
var activeLocBtn = document.querySelector('.hr-loc-btn.hr-loc-active');
var query = searchInput ? searchInput.value.trim().toLowerCase() : '';
var department = deptSelect ? deptSelect.value : '';
var post = postSelect ? postSelect.value : '';
var loc = activeLocBtn ? activeLocBtn.getAttribute('data-loc') : '';
var departmentIds = department ? department.split(',') : [];
function matches(el) {
var name = (el.getAttribute('data-name') || '').toLowerCase();
if (query && name.indexOf(query) === -1) return false;
if (departmentIds.length && departmentIds.indexOf(el.getAttribute('data-department')) === -1) return false;
if (post && el.getAttribute('data-post') !== post) return false;
if (loc && el.getAttribute('data-loc') !== loc) return false;
return true;
}
var cards = document.querySelectorAll('.hr-emp-card');
var visibleCards = 0;
cards.forEach(function (card) {
var match = matches(card);
card.style.display = match ? '' : 'none';
if (match) visibleCards++;
});
var rows = document.querySelectorAll('.hr-emp-row');
var visibleRows = 0;
rows.forEach(function (row) {
var match = matches(row);
row.style.display = match ? '' : 'none';
if (match) visibleRows++;
});
var hasFilter = !!(query || department || post || loc);
var emptyCards = document.getElementById('hr-search-empty');
if (emptyCards) emptyCards.style.display = (hasFilter && cards.length && visibleCards === 0) ? '' : 'none';
var emptyTable = document.getElementById('hr-search-empty-table');
if (emptyTable) emptyTable.style.display = (hasFilter && rows.length && visibleRows === 0) ? '' : 'none';
var countEl = document.getElementById('hr-filter-count');
if (countEl) {
var total = cards.length || rows.length;
var visible = cards.length ? visibleCards : visibleRows;
countEl.textContent = hasFilter ? ('Знайдено: ' + visible + ' з ' + total) : '';
}
}
/* Деревовидный пикер отделов: фильтр сотрудников и форма найма. */
(function () {
function collectIds(node, acc) {
acc.push(String(node.id));
(node.children || []).forEach(function (child) { collectIds(child, acc); });
return acc;
}
function buildPathLabel(node) {
var parts = [];
var current = node;
while (current) {
parts.unshift(current.name);
current = current.parentRef;
}
return parts.join(' → ');
}
function attachParentRefs(nodes, parent) {
nodes.forEach(function (node) {
node.parentRef = parent || null;
if (node.children && node.children.length) {
attachParentRefs(node.children, node);
}
});
}
function readTree(treeDataId) {
var dataEl = document.getElementById(treeDataId);
var tree = [];
if (dataEl) {
try { tree = JSON.parse(dataEl.textContent) || []; } catch (e) { tree = []; }
}
attachParentRefs(tree, null);
return tree;
}
function renderNode(node, container, depth, picker) {
var row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.gap = '0.25rem';
row.style.padding = '0.375rem 0.5rem';
row.style.paddingLeft = (0.5 + depth * 1) + 'rem';
row.style.borderRadius = 'calc(var(--radius) - 2px)';
row.style.cursor = 'pointer';
row.style.fontSize = '0.8125rem';
row.onmouseover = function () { row.style.background = 'var(--color-muted)'; };
row.onmouseout = function () { row.style.background = ''; };
var hasChildren = node.children && node.children.length > 0;
var childrenWrap = null;
var toggle = document.createElement('span');
toggle.textContent = hasChildren ? '▸' : '';
toggle.style.width = '0.875rem';
toggle.style.flexShrink = '0';
toggle.style.color = 'var(--color-muted-foreground)';
toggle.style.userSelect = 'none';
row.appendChild(toggle);
var label = document.createElement('span');
label.textContent = node.name;
label.style.flex = '1';
label.style.color = 'var(--color-foreground)';
row.appendChild(label);
if (picker.showCounts && node.count) {
var count = document.createElement('span');
count.textContent = node.count;
count.style.fontSize = '0.6875rem';
count.style.color = 'var(--color-muted-foreground)';
row.appendChild(count);
}
container.appendChild(row);
if (hasChildren) {
childrenWrap = document.createElement('div');
childrenWrap.style.display = 'none';
container.appendChild(childrenWrap);
node.children.forEach(function (child) { renderNode(child, childrenWrap, depth + 1, picker); });
toggle.addEventListener('click', function (e) {
e.stopPropagation();
var open = childrenWrap.style.display !== 'none';
childrenWrap.style.display = open ? 'none' : 'block';
toggle.textContent = open ? '▸' : '▾';
});
}
label.addEventListener('click', function (e) {
e.stopPropagation();
picker.selectNode(node);
});
}
function createDeptPicker(config) {
var picker = {
inputId: config.inputId,
labelId: config.labelId,
panelId: config.panelId,
treeDataId: config.treeDataId || 'hr-dept-tree-data',
allLabel: config.allLabel || 'Всі відділи',
emptyLabel: config.emptyLabel || '—',
mode: config.mode || 'filter',
showCounts: config.showCounts !== false,
showAllOption: config.showAllOption !== false,
onChange: config.onChange || function () {},
selectNode: function (node) {
var input = document.getElementById(picker.inputId);
var label = document.getElementById(picker.labelId);
if (!input || !label) return;
if (node) {
input.value = picker.mode === 'single'
? String(node.id)
: collectIds(node, []).join(',');
label.textContent = picker.mode === 'single'
? buildPathLabel(node)
: node.name;
} else {
input.value = '';
label.textContent = picker.mode === 'single'
? (label.getAttribute('data-empty-label') || picker.emptyLabel)
: picker.allLabel;
}
picker.close();
picker.onChange(node);
},
close: function () {
var panel = document.getElementById(picker.panelId);
if (panel) panel.style.display = 'none';
},
buildPanel: function () {
var panel = document.getElementById(picker.panelId);
if (!panel || panel.dataset.built) return;
panel.dataset.built = '1';
panel.innerHTML = '';
if (picker.showAllOption) {
var allRow = document.createElement('div');
allRow.textContent = picker.mode === 'single' ? picker.emptyLabel : picker.allLabel;
allRow.style.padding = '0.375rem 0.5rem';
allRow.style.borderRadius = 'calc(var(--radius) - 2px)';
allRow.style.cursor = 'pointer';
allRow.style.fontSize = '0.8125rem';
allRow.style.fontWeight = '500';
allRow.style.color = 'var(--color-foreground)';
allRow.style.marginBottom = '0.25rem';
allRow.onmouseover = function () { allRow.style.background = 'var(--color-muted)'; };
allRow.onmouseout = function () { allRow.style.background = ''; };
allRow.addEventListener('click', function (e) {
e.stopPropagation();
picker.selectNode(null);
});
panel.appendChild(allRow);
}
readTree(picker.treeDataId).forEach(function (node) {
renderNode(node, panel, 0, picker);
});
},
toggle: function (e) {
if (e) e.stopPropagation();
picker.buildPanel();
var panel = document.getElementById(picker.panelId);
if (!panel) return;
var opening = panel.style.display === 'none';
panel.style.display = opening ? 'block' : 'none';
if (opening) {
document.addEventListener('click', function onDocClick(ev) {
var wrap = panel.closest('.hr-dept-picker');
if (wrap && !wrap.contains(ev.target)) {
picker.close();
document.removeEventListener('click', onDocClick);
}
});
}
},
};
return picker;
}
var hrFilterDeptPicker = createDeptPicker({
inputId: 'hr-department-select',
labelId: 'hr-dept-picker-label',
panelId: 'hr-dept-picker-panel',
mode: 'filter',
allLabel: 'Всі відділи',
onChange: function () { hrFilterEmployees(); },
});
var hrHiringDeptPicker = createDeptPicker({
inputId: 'hr-hiring-department-select',
labelId: 'hr-hiring-dept-picker-label',
panelId: 'hr-hiring-dept-picker-panel',
mode: 'single',
showCounts: false,
showAllOption: true,
emptyLabel: '—',
onChange: function () {},
});
window.hrDeptPickerToggle = hrFilterDeptPicker.toggle;
window.hrHiringDeptPickerToggle = hrHiringDeptPicker.toggle;
})();
function hrHiringLocToggle(locationType) {
var deptField = document.getElementById('hr-hiring-department-field');
var pharmField = document.getElementById('hr-hiring-pharmacy-field');
var locField = document.getElementById('hr-hiring-location-field');
var deptSelect = document.getElementById('hr-hiring-department-select');
var pharmSelect = document.getElementById('hr-hiring-pharmacy-select');
var locSelect = document.getElementById('hr-hiring-location-select');
var reqOffice = document.querySelectorAll('.hr-req-office');
var isPharmacy = locationType === 'pharmacy';
var isOfficeLike = locationType === 'office' || locationType === 'warehouse';
if (deptField) deptField.style.display = isPharmacy ? 'none' : '';
if (pharmField) pharmField.style.display = isPharmacy ? '' : 'none';
if (locField) locField.style.display = isOfficeLike ? '' : 'none';
Array.prototype.forEach.call(reqOffice, function (el) {
el.style.display = isOfficeLike ? '' : 'none';
});
if (deptSelect) {
deptSelect.required = isOfficeLike;
if (isPharmacy) {
deptSelect.value = '';
var deptLabel = document.getElementById('hr-hiring-dept-picker-label');
if (deptLabel) {
deptLabel.textContent = deptLabel.getAttribute('data-empty-label') || '—';
}
}
}
if (pharmSelect) {
pharmSelect.required = isPharmacy;
if (!isPharmacy) pharmSelect.value = '';
}
if (locSelect) {
locSelect.required = isOfficeLike;
Array.prototype.forEach.call(locSelect.options, function (opt) {
if (!opt.value) return;
var ok = !locationType || opt.getAttribute('data-type') === locationType;
opt.hidden = !ok;
opt.disabled = !ok;
});
var sel = locSelect.options[locSelect.selectedIndex];
if (locSelect.value && sel && sel.hidden) locSelect.value = '';
if (!isOfficeLike) locSelect.value = '';
}
hrHiringFilterPosts(locationType);
hrHiringFilterAccesses(locationType);
}
// Показує чекбокси доступів лише для обраного типу локації.
// Порожній список типів у системи = доступна скрізь (як у посад).
function hrHiringFilterAccesses(locationType) {
var dataEl = document.getElementById('hr-access-loc-types-data');
var options = document.querySelectorAll('.hr-access-option');
if (!dataEl || !options.length) return;
var map = {};
try { map = JSON.parse(dataEl.textContent); } catch (e) { map = {}; }
Array.prototype.forEach.call(options, function (label) {
var id = label.getAttribute('data-access-id');
var types = map[id] || [];
var ok = !locationType || types.length === 0 || types.indexOf(locationType) !== -1;
label.style.display = ok ? 'flex' : 'none';
var cb = label.querySelector('input[type=checkbox]');
if (cb && !ok) cb.checked = false;
});
}
// Показує в селекті «Посада» лише посади, доступні для обраного типу локації.
// Порожній список типів у посади = доступна скрізь (як в Employee.clean()).
function hrHiringFilterPosts(locationType) {
var select = document.getElementById('hr-hiring-post-select');
var dataEl = document.getElementById('hr-post-loc-types-data');
if (!select || !dataEl) return;
var map = JSON.parse(dataEl.textContent);
Array.prototype.forEach.call(select.options, function (opt) {
if (!opt.value) return; // «—» лишається завжди
var types = map[opt.value] || [];
var ok = !locationType || types.length === 0 || types.indexOf(locationType) !== -1;
opt.hidden = !ok;
opt.disabled = !ok;
});
// Якщо раніше обрана посада більше не підходить під локацію — скидаємо вибір.
var sel = select.options[select.selectedIndex];
if (select.value && sel && sel.hidden) select.value = '';
}
/** Ініціалізація форми найму: за замовчуванням «Аптека» + застосування фільтрів. */
function hrHiringInit() {
var sel = document.getElementById('hr-hiring-loc-type')
|| document.querySelector('#hr-panel-hiring select[name="user_location_type"]');
if (!sel) return;
if (!sel.value) sel.value = 'pharmacy';
hrHiringLocToggle(sel.value);
}
/** Показати форму кадрової заявки на вкладці «Заявки». */
function hrShowRequestForm(type, label) {
hrTabSwitch('requests');
var wrap = document.getElementById('hr-request-form-wrap');
var input = document.getElementById('hr-request-type');
var labelEl = document.getElementById('hr-request-type-label');
if (wrap) wrap.style.display = '';
if (input && type) {
input.value = type;
hrRequestTypeToggle(type);
}
if (labelEl && label) labelEl.textContent = label;
}
function hrHideRequestForm() {
var wrap = document.getElementById('hr-request-form-wrap');
if (wrap) wrap.style.display = 'none';
}
function hrShowVacancyForm() {
var wrap = document.getElementById('hr-vacancy-form-wrap');
if (wrap) wrap.style.display = 'block';
var sel = document.getElementById('hr-vacancy-loc-type');
if (sel) hrVacancyLocToggle(sel.value || 'pharmacy');
}
function hrHideVacancyForm() {
var wrap = document.getElementById('hr-vacancy-form-wrap');
if (wrap) wrap.style.display = 'none';
}
function hrToggleVacancyForm() {
var wrap = document.getElementById('hr-vacancy-form-wrap');
if (!wrap) return;
if (wrap.style.display === 'none' || !wrap.style.display) {
hrShowVacancyForm();
} else {
hrHideVacancyForm();
}
}
function hrVacancyLocToggle(locType) {
var isPharmacy = locType === 'pharmacy';
var pharm = document.getElementById('hr-vacancy-pharmacy-field');
var dept = document.getElementById('hr-vacancy-department-field');
if (pharm) pharm.style.display = isPharmacy ? '' : 'none';
if (dept) dept.style.display = isPharmacy ? 'none' : '';
}
function hrRequestTypeToggle(type) {
var needsDates = ['vacation', 'additional_vacation', 'sick_leave', 'day_off', 'substitution'].indexOf(type) !== -1;
var datesWrap = document.getElementById('hr-request-dates-wrap');
var dateToWrap = document.getElementById('hr-request-date-to-wrap');
var dateFrom = document.getElementById('hr-request-date-from');
var dateTo = document.getElementById('hr-request-date-to');
if (datesWrap) datesWrap.style.display = needsDates ? '' : 'none';
if (dateToWrap) dateToWrap.style.display = needsDates ? '' : 'none';
if (dateFrom) dateFrom.required = needsDates;
if (dateTo) dateTo.required = needsDates;
}
/** Ініціалізація HR-порталу після завантаження / HTMX-swap. */
function hrPortalInit() {
var root = hrPortalRoot();
if (!root) return;
var activeTab = root.getAttribute('data-hr-active-tab') || 'employees';
hrTabSwitch(activeTab);
var activeLoc = root.querySelector('.hr-loc-btn.hr-loc-active');
var reqType = root.querySelector('#hr-request-type');
if (reqType) {
hrRequestTypeToggle(reqType.value);
var labelEl = root.querySelector('#hr-request-type-label');
if (labelEl && !labelEl.textContent.trim()) {
var btn = root.querySelector('.hr-request-type-btn[data-request-type="' + reqType.value + '"]');
if (btn) labelEl.textContent = btn.getAttribute('data-request-label') || btn.textContent.trim();
}
}
if (root.getAttribute('data-hr-request-form-open') === '1') {
var formWrap = root.querySelector('#hr-request-form-wrap');
if (formWrap) formWrap.style.display = 'block';
}
var loc = activeLoc ? activeLoc.getAttribute('data-loc') : '';
if (!activeLoc) {
hrLocFilter('');
} else {
hrFilterListPosts(loc || '');
hrFilterEmployees();
}
var hiringPanel = root.querySelector('#hr-panel-hiring');
if (hiringPanel && hiringPanel.style.display !== 'none' && activeTab === 'hiring') {
hrHiringInit();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hrPortalInit);
} else {
hrPortalInit();
}
// Слушаем на document: скрипт в <head>, document.body ещё может быть null.
document.addEventListener('htmx:afterSwap', function (ev) {
var target = ev.detail && ev.detail.target;
if (target && target.id === 'main-content') {
hrPortalInit();
}
});