324 lines
13 KiB
JavaScript
324 lines
13 KiB
JavaScript
/* HR Candidate Matcher — UI Layer */
|
||
/* global window, document */
|
||
(function () {
|
||
'use strict';
|
||
|
||
var APP = window.HRMatcher = window.HRMatcher || {};
|
||
var $ = function (sel) { return document.querySelector(sel); };
|
||
var $$ = function (sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); };
|
||
|
||
var toastTimer = null;
|
||
var confirmCallback = null;
|
||
var currentFilter = 'all';
|
||
var criteriaEditState = [];
|
||
|
||
APP.ui = {
|
||
showToast: function (message, type) {
|
||
var container = $('#toast-container');
|
||
if (!container) return;
|
||
var toast = document.createElement('div');
|
||
toast.className = 'toast' + (type ? ' toast-' + type : '');
|
||
toast.textContent = message;
|
||
container.appendChild(toast);
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(function () {
|
||
toast.classList.add('toast-out');
|
||
setTimeout(function () { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 200);
|
||
}, 3000);
|
||
},
|
||
|
||
showConfirm: function (message, onConfirm) {
|
||
var modal = $('#modal-confirm');
|
||
var desc = $('#confirm-desc');
|
||
if (!modal || !desc) return;
|
||
desc.textContent = message;
|
||
modal.hidden = false;
|
||
confirmCallback = onConfirm;
|
||
$('#btn-confirm-ok').focus();
|
||
},
|
||
|
||
hideConfirm: function () {
|
||
var modal = $('#modal-confirm');
|
||
if (modal) modal.hidden = true;
|
||
confirmCallback = null;
|
||
},
|
||
|
||
resolveConfirm: function () {
|
||
if (confirmCallback) confirmCallback();
|
||
},
|
||
|
||
showError: function (fieldId, message) {
|
||
var field = $('#field-' + fieldId);
|
||
var error = $('#' + fieldId + '-error');
|
||
if (field) field.classList.add('has-error');
|
||
if (error) {
|
||
error.textContent = message;
|
||
error.hidden = false;
|
||
}
|
||
},
|
||
|
||
clearError: function (fieldId) {
|
||
var field = $('#field-' + fieldId);
|
||
var error = $('#' + fieldId + '-error');
|
||
if (field) field.classList.remove('has-error');
|
||
if (error) {
|
||
error.textContent = '';
|
||
error.hidden = true;
|
||
}
|
||
},
|
||
|
||
clearAllErrors: function () {
|
||
$$('.field').forEach(function (f) { f.classList.remove('has-error'); });
|
||
$$('.field-error').forEach(function (e) { e.textContent = ''; e.hidden = true; });
|
||
},
|
||
|
||
showScreen: function (screenName) {
|
||
$$('.screen').forEach(function (s) { s.hidden = true; });
|
||
var screen = $('#screen-' + screenName);
|
||
if (screen) {
|
||
screen.hidden = false;
|
||
screen.removeAttribute('hidden');
|
||
}
|
||
|
||
var nav = $('#app-nav');
|
||
var clearBtn = $('#btn-clear-all');
|
||
if (screenName === 'welcome') {
|
||
if (nav) nav.hidden = true;
|
||
if (clearBtn) clearBtn.hidden = true;
|
||
} else {
|
||
if (nav) nav.hidden = false;
|
||
if (clearBtn) clearBtn.hidden = false;
|
||
}
|
||
|
||
this.updateStepsIndicator(screenName);
|
||
window.scrollTo(0, 0);
|
||
},
|
||
|
||
updateStepsIndicator: function (current) {
|
||
var steps = ['input', 'criteria', 'match', 'interview'];
|
||
var currentIdx = steps.indexOf(current);
|
||
$$('.step').forEach(function (el) {
|
||
var stepName = el.getAttribute('data-step');
|
||
var stepIdx = steps.indexOf(stepName);
|
||
el.classList.remove('step-active', 'step-done');
|
||
if (stepIdx === currentIdx) el.classList.add('step-active');
|
||
else if (stepIdx < currentIdx) el.classList.add('step-done');
|
||
});
|
||
},
|
||
|
||
renderCriteriaTable: function (criteria) {
|
||
var tbody = $('#criteria-tbody');
|
||
var emptyMsg = $('#criteria-empty');
|
||
var tableWrap = $('.criteria-table-wrap');
|
||
if (!tbody) return;
|
||
|
||
criteriaEditState = criteria.map(function (c) {
|
||
return {
|
||
id: c.id,
|
||
label: c.label,
|
||
required: c.required,
|
||
weight: c.weight,
|
||
synonyms: (c.synonyms || []).join(', ')
|
||
};
|
||
});
|
||
|
||
if (criteria.length === 0) {
|
||
tbody.innerHTML = '';
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
if (emptyMsg) emptyMsg.hidden = false;
|
||
return;
|
||
}
|
||
|
||
if (tableWrap) tableWrap.hidden = false;
|
||
if (emptyMsg) emptyMsg.hidden = true;
|
||
|
||
var html = '';
|
||
for (var i = 0; i < criteriaEditState.length; i++) {
|
||
var c = criteriaEditState[i];
|
||
html += '<tr data-id="' + c.id + '">';
|
||
html += '<td><input class="input-inline" type="text" value="' + escapeAttr(c.label) + '" data-field="label" aria-label="Название требования"></td>';
|
||
html += '<td><select class="select-inline" data-field="required" aria-label="Тип требования">';
|
||
html += '<option value="true"' + (c.required ? ' selected' : '') + '>Обязательное</option>';
|
||
html += '<option value="false"' + (!c.required ? ' selected' : '') + '>Желательное</option>';
|
||
html += '</select></td>';
|
||
html += '<td><select class="select-inline" data-field="weight" aria-label="Вес требования">';
|
||
for (var w = 1; w <= 5; w++) {
|
||
html += '<option value="' + w + '"' + (c.weight === w ? ' selected' : '') + '>' + w + '</option>';
|
||
}
|
||
html += '</select></td>';
|
||
html += '<td><input class="input-inline" type="text" value="' + escapeAttr(c.synonyms) + '" data-field="synonyms" aria-label="Синонимы через запятую" placeholder="синоним1, синоним2"></td>';
|
||
html += '<td><button class="btn-icon" data-action="delete-criterion" aria-label="Удалить требование «' + escapeAttr(c.label) + '»">';
|
||
html += '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
|
||
html += '</button></td>';
|
||
html += '</tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
},
|
||
|
||
readCriteriaFromTable: function () {
|
||
var rows = $$('#criteria-tbody tr');
|
||
var criteria = [];
|
||
for (var i = 0; i < rows.length; i++) {
|
||
var row = rows[i];
|
||
var id = row.getAttribute('data-id');
|
||
var label = row.querySelector('[data-field="label"]').value.trim();
|
||
var required = row.querySelector('[data-field="required"]').value === 'true';
|
||
var weight = parseInt(row.querySelector('[data-field="weight"]').value, 10);
|
||
var synonymsStr = row.querySelector('[data-field="synonyms"]').value;
|
||
var synonyms = synonymsStr.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
|
||
criteria.push({ id: id, label: label, required: required, weight: weight, synonyms: synonyms });
|
||
}
|
||
return criteria;
|
||
},
|
||
|
||
renderMatchTable: function (criteria, matchResults, filter) {
|
||
var tbody = $('#match-tbody');
|
||
var emptyMsg = $('#match-empty');
|
||
var tableWrap = $('.match-table-wrap');
|
||
if (!tbody) return;
|
||
|
||
var filtered = APP.logic.getFilteredResults(matchResults, filter);
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = '';
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
if (emptyMsg) emptyMsg.hidden = false;
|
||
return;
|
||
}
|
||
|
||
if (tableWrap) tableWrap.hidden = false;
|
||
if (emptyMsg) emptyMsg.hidden = true;
|
||
|
||
var html = '';
|
||
for (var i = 0; i < filtered.length; i++) {
|
||
var r = filtered[i];
|
||
var c = APP.logic.getCriterionById(criteria, r.criterionId);
|
||
if (!c) continue;
|
||
|
||
var statusClass = 'status-' + r.status.replace('_', '-');
|
||
var statusIcon = '';
|
||
var statusText = '';
|
||
if (r.status === APP.STATUS.CONFIRMED) {
|
||
statusIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||
statusText = 'Подтверждено';
|
||
} else if (r.status === APP.STATUS.NOT_FOUND) {
|
||
statusIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
|
||
statusText = 'Не найдено';
|
||
} else {
|
||
statusIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
|
||
statusText = 'Уточнить';
|
||
}
|
||
|
||
var evidenceHtml = '';
|
||
if (r.evidence && r.evidence.length > 0) {
|
||
evidenceHtml = '<ul class="evidence-list">';
|
||
for (var e = 0; e < r.evidence.length; e++) {
|
||
evidenceHtml += '<li class="evidence-fragment">' + escapeHtml(r.evidence[e].fragment) + '</li>';
|
||
}
|
||
evidenceHtml += '</ul>';
|
||
} else {
|
||
evidenceHtml = '<span style="color:var(--color-text-secondary);font-size:var(--text-xs)">Фрагменты не найдены</span>';
|
||
}
|
||
|
||
html += '<tr data-criterion-id="' + r.criterionId + '">';
|
||
html += '<td>' + escapeHtml(c.label) + '</td>';
|
||
html += '<td>' + evidenceHtml + '</td>';
|
||
html += '<td>';
|
||
html += '<select class="status-select" data-action="change-status" aria-label="Статус для «' + escapeAttr(c.label) + '»">';
|
||
html += '<option value="' + APP.STATUS.CONFIRMED + '"' + (r.status === APP.STATUS.CONFIRMED ? ' selected' : '') + '>Подтверждено</option>';
|
||
html += '<option value="' + APP.STATUS.NOT_FOUND + '"' + (r.status === APP.STATUS.NOT_FOUND ? ' selected' : '') + '>Не найдено</option>';
|
||
html += '<option value="' + APP.STATUS.UNCLEAR + '"' + (r.status === APP.STATUS.UNCLEAR ? ' selected' : '') + '>Уточнить</option>';
|
||
html += '</select>';
|
||
html += '</td>';
|
||
html += '<td><input class="note-input" type="text" value="' + escapeAttr(r.note || '') + '" data-action="change-note" placeholder="Примечание…" aria-label="Примечание для «' + escapeAttr(c.label) + '»"></td>';
|
||
html += '</tr>';
|
||
}
|
||
tbody.innerHTML = html;
|
||
},
|
||
|
||
renderCoverage: function (coverage) {
|
||
var bar = $('#coverage-bar-fill');
|
||
var value = $('#coverage-value');
|
||
if (bar) {
|
||
bar.style.width = coverage + '%';
|
||
bar.setAttribute('aria-valuenow', coverage);
|
||
}
|
||
if (value) {
|
||
value.textContent = coverage + '%';
|
||
}
|
||
},
|
||
|
||
renderInterview: function (interviewData) {
|
||
var container = $('#interview-questions');
|
||
var emptyMsg = $('#interview-empty');
|
||
if (!container) return;
|
||
|
||
if (interviewData.length === 0) {
|
||
container.innerHTML = '';
|
||
if (emptyMsg) emptyMsg.hidden = false;
|
||
return;
|
||
}
|
||
|
||
if (emptyMsg) emptyMsg.hidden = true;
|
||
|
||
var statusLabels = {};
|
||
statusLabels[APP.STATUS.NOT_FOUND] = 'Не найдено';
|
||
statusLabels[APP.STATUS.UNCLEAR] = 'Требует уточнения';
|
||
|
||
var html = '';
|
||
var globalNum = 1;
|
||
for (var i = 0; i < interviewData.length; i++) {
|
||
var section = interviewData[i];
|
||
html += '<div class="interview-section">';
|
||
html += '<div class="interview-section-title">' + escapeHtml(section.criterion.label);
|
||
html += ' <span class="status-badge status-' + section.status.replace('_', '-') + '">' + (statusLabels[section.status] || section.status) + '</span>';
|
||
html += '</div>';
|
||
html += '<div class="interview-card">';
|
||
html += '<div class="interview-card-criterion">';
|
||
if (section.criterion.required) html += 'Обязательное';
|
||
else html += 'Желательное';
|
||
html += ' · Вес ' + section.criterion.weight;
|
||
html += '</div>';
|
||
html += '<ol class="interview-questions-list">';
|
||
for (var q = 0; q < section.questions.length; q++) {
|
||
html += '<li class="interview-question"><span class="interview-question-num">' + globalNum + '.</span> ' + escapeHtml(section.questions[q]) + '</li>';
|
||
globalNum++;
|
||
}
|
||
html += '</ol>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
container.innerHTML = html;
|
||
},
|
||
|
||
updateFilterChips: function (activeFilter) {
|
||
$$('.chip').forEach(function (chip) {
|
||
var f = chip.getAttribute('data-filter');
|
||
if (f === activeFilter) {
|
||
chip.classList.add('chip-active');
|
||
} else {
|
||
chip.classList.remove('chip-active');
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function escapeAttr(str) {
|
||
if (!str) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
})();
|