497 lines
16 KiB
JavaScript
497 lines
16 KiB
JavaScript
/**
|
||
* UI - управление интерфейсом
|
||
*/
|
||
|
||
let state = {
|
||
currentScreen: 'input',
|
||
vacancyText: '',
|
||
resumeText: '',
|
||
criteria: [],
|
||
matchResults: null,
|
||
questions: []
|
||
};
|
||
|
||
function initUI() {
|
||
setupNavigation();
|
||
setupInputScreen();
|
||
setupCriteriaScreen();
|
||
setupMatchScreen();
|
||
setupInterviewScreen();
|
||
}
|
||
|
||
function setupNavigation() {
|
||
const navButtons = document.querySelectorAll('.hm-nav-btn');
|
||
navButtons.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const screen = btn.dataset.screen;
|
||
navigateTo(screen);
|
||
});
|
||
});
|
||
}
|
||
|
||
function navigateTo(screen) {
|
||
if (!['input', 'criteria', 'match', 'interview'].includes(screen)) {
|
||
return;
|
||
}
|
||
|
||
state.currentScreen = screen;
|
||
|
||
document.querySelectorAll('.hm-screen').forEach(el => {
|
||
el.classList.remove('active');
|
||
});
|
||
document.getElementById(`screen-${screen}`).classList.add('active');
|
||
|
||
document.querySelectorAll('.hm-nav-btn').forEach(btn => {
|
||
btn.classList.toggle('active', btn.dataset.screen === screen);
|
||
});
|
||
|
||
if (screen === 'criteria') {
|
||
renderCriteriaList();
|
||
} else if (screen === 'match') {
|
||
renderMatchResults();
|
||
} else if (screen === 'interview') {
|
||
renderQuestions();
|
||
}
|
||
}
|
||
|
||
function setupInputScreen() {
|
||
const vacancyField = document.getElementById('vacancyText');
|
||
const resumeField = document.getElementById('resumeText');
|
||
|
||
vacancyField.value = state.vacancyText;
|
||
resumeField.value = state.resumeText;
|
||
|
||
vacancyField.addEventListener('input', () => {
|
||
state.vacancyText = vacancyField.value;
|
||
});
|
||
|
||
resumeField.addEventListener('input', () => {
|
||
state.resumeText = resumeField.value;
|
||
});
|
||
|
||
document.getElementById('btnExtract').addEventListener('click', handleExtract);
|
||
document.getElementById('btnLoadExample').addEventListener('click', loadExample);
|
||
document.getElementById('btnClearAll').addEventListener('click', handleClearAll);
|
||
}
|
||
|
||
function handleExtract() {
|
||
if (!state.vacancyText.trim()) {
|
||
showNotification('Введите текст вакансии', 'error');
|
||
document.getElementById('vacancyText').focus();
|
||
return;
|
||
}
|
||
|
||
const result = window.matcher.extractRequirements(state.vacancyText);
|
||
|
||
if (result.error) {
|
||
showNotification(result.error, 'error');
|
||
return;
|
||
}
|
||
|
||
state.criteria = result.criteria;
|
||
navigateTo('criteria');
|
||
showNotification(`Найдено требований: ${result.criteria.length}`, 'success');
|
||
}
|
||
|
||
function loadExample() {
|
||
const exampleVacancy = `Требуется Frontend-разработчик
|
||
|
||
Обязанности:
|
||
- Разработка веб-интерфейсов на React и TypeScript
|
||
- Верстка адаптивных страниц (HTML, CSS, Sass)
|
||
- Работа с REST API
|
||
- Код-ревью и менторство младших разработчиков
|
||
|
||
Требования:
|
||
- Обязательно: опыт работы от 3 лет
|
||
- Уверенное знание JavaScript (ES6+) и TypeScript
|
||
- Опыт работы с React и экосистемой (Redux, React Router)
|
||
- Знание HTML, CSS, препроцессоров
|
||
- Опыт работы с Git
|
||
- Понимание принципов Agile/Scrum
|
||
- Английский язык не ниже Intermediate
|
||
|
||
Будет преимуществом:
|
||
- Опыт работы с GraphQL
|
||
- Знание Node.js
|
||
- Опыт настройки CI/CD`;
|
||
|
||
const exampleResume = `Иванов Иван
|
||
Frontend-разработчик
|
||
|
||
Опыт работы: 4 года
|
||
|
||
Навыки:
|
||
- JavaScript (ES6+), TypeScript — уверенное владение
|
||
- React, Redux, React Router — коммерческий опыт 3 года
|
||
- HTML5, CSS3, Sass, Less
|
||
- Git, GitHub, GitLab
|
||
- REST API, GraphQL (базово)
|
||
- Webpack, Vite
|
||
- Agile/Scrum, Jira, Confluence
|
||
|
||
Образование:
|
||
Высшее техническое, бакалавр информатики
|
||
|
||
Проекты:
|
||
2022-2024: Senior Frontend Developer в IT-компании
|
||
- Разработка CRM-системы на React + TypeScript
|
||
- Менторство двух junior-разработчиков
|
||
- Внедрение код-ревью практик
|
||
|
||
2020-2022: Middle Frontend Developer
|
||
- Создание лендингов и интернет-магазинов
|
||
- Работа с REST API
|
||
- Оптимизация производительности`;
|
||
|
||
state.vacancyText = exampleVacancy;
|
||
state.resumeText = exampleResume;
|
||
|
||
document.getElementById('vacancyText').value = exampleVacancy;
|
||
document.getElementById('resumeText').value = exampleResume;
|
||
|
||
showNotification('Пример загружен. Нажмите «Извлечь требования»', 'info');
|
||
}
|
||
|
||
function handleClearAll() {
|
||
if (!confirm('Вы уверены? Все данные будут удалены без возможности восстановления.')) {
|
||
return;
|
||
}
|
||
|
||
state = {
|
||
currentScreen: 'input',
|
||
vacancyText: '',
|
||
resumeText: '',
|
||
criteria: [],
|
||
matchResults: null,
|
||
questions: []
|
||
};
|
||
|
||
document.getElementById('vacancyText').value = '';
|
||
document.getElementById('resumeText').value = '';
|
||
|
||
window.storage.clearState();
|
||
|
||
navigateTo('input');
|
||
showNotification('Все данные очищены', 'info');
|
||
}
|
||
|
||
function setupCriteriaScreen() {
|
||
document.getElementById('btnAddCriterion').addEventListener('click', handleAddCriterion);
|
||
document.getElementById('btnApplyCriteria').addEventListener('click', handleApplyCriteria);
|
||
}
|
||
|
||
function renderCriteriaList() {
|
||
const container = document.getElementById('criteriaList');
|
||
|
||
if (state.criteria.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="hm-empty">
|
||
<svg class="hm-empty-icon" aria-hidden="true"><use href="#file"></use></svg>
|
||
<h3>Нет критериев</h3>
|
||
<p>Извлеките требования из текста вакансии или добавьте вручную</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = state.criteria.map((c, index) => `
|
||
<div class="hm-criterion" data-id="${c.id}">
|
||
<input type="text" value="${escapeHtml(c.label)}" data-field="label" placeholder="Название требования">
|
||
<select data-field="required">
|
||
<option value="true" ${c.required ? 'selected' : ''}>Обязательное</option>
|
||
<option value="false" ${!c.required ? 'selected' : ''}>Желательное</option>
|
||
</select>
|
||
<input type="number" min="1" max="5" value="${c.weight}" data-field="weight" title="Вес 1-5">
|
||
<button data-action="remove" title="Удалить критерий">
|
||
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#x"></use></svg>
|
||
</button>
|
||
<div class="hm-synonyms">
|
||
<span style="font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); min-width: 80px;">Синонимы:</span>
|
||
<input type="text" value="${escapeHtml((c.synonyms || []).join(', '))}" data-field="synonyms" placeholder="через запятую">
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
container.querySelectorAll('.hm-criterion').forEach(row => {
|
||
const id = row.dataset.id;
|
||
const criterion = state.criteria.find(c => c.id === id);
|
||
|
||
row.querySelectorAll('input, select').forEach(input => {
|
||
input.addEventListener('input', (e) => {
|
||
const field = e.target.dataset.field;
|
||
let value = e.target.value;
|
||
|
||
if (field === 'required') {
|
||
value = value === 'true';
|
||
} else if (field === 'weight') {
|
||
value = parseInt(value, 10) || 3;
|
||
} else if (field === 'synonyms') {
|
||
value = value.split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||
}
|
||
|
||
Object.assign(criterion, { [field]: value });
|
||
});
|
||
});
|
||
|
||
const removeBtn = row.querySelector('[data-action="remove"]');
|
||
removeBtn.addEventListener('click', () => {
|
||
state.criteria = state.criteria.filter(c => c.id !== id);
|
||
renderCriteriaList();
|
||
});
|
||
});
|
||
}
|
||
|
||
function handleAddCriterion() {
|
||
state.criteria = window.matcher.addCustomCriterion(state.criteria, 'Новое требование', true, 3, []);
|
||
renderCriteriaList();
|
||
}
|
||
|
||
function handleApplyCriteria() {
|
||
if (state.criteria.length === 0) {
|
||
showNotification('Добавьте хотя бы один критерий', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!state.resumeText.trim()) {
|
||
showNotification('Введите текст резюме', 'error');
|
||
navigateTo('input');
|
||
return;
|
||
}
|
||
|
||
const result = window.matcher.matchResumeToCriteria(state.criteria, state.resumeText);
|
||
state.matchResults = result;
|
||
|
||
navigateTo('match');
|
||
}
|
||
|
||
function setupMatchScreen() {
|
||
document.getElementById('btnBackToCriteria').addEventListener('click', () => {
|
||
navigateTo('criteria');
|
||
});
|
||
|
||
document.getElementById('btnGenerateQuestions').addEventListener('click', handleGenerateQuestions);
|
||
}
|
||
|
||
function renderMatchResults() {
|
||
const tbody = document.getElementById('matchTableBody');
|
||
const resultsDiv = document.getElementById('matchResults');
|
||
const emptyDiv = document.getElementById('matchEmpty');
|
||
|
||
if (!state.matchResults || !state.matchResults.results || state.matchResults.results.length === 0) {
|
||
resultsDiv.style.display = 'none';
|
||
emptyDiv.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
resultsDiv.style.display = 'block';
|
||
emptyDiv.style.display = 'none';
|
||
|
||
tbody.innerHTML = state.matchResults.results.map(r => {
|
||
const statusClass = `hm-status-${r.status}`;
|
||
const statusLabel = {
|
||
confirmed: 'Подтверждено',
|
||
clarify: 'Уточнить',
|
||
notfound: 'Не найдено'
|
||
}[r.status];
|
||
|
||
const evidenceHtml = r.evidence && r.evidence.length > 0
|
||
? `<div class="hm-quote">${escapeHtml(r.evidence[0])}</div>`
|
||
: '<span style="color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-xs)">Нет цитат</span>';
|
||
|
||
const statusIcons = {
|
||
confirmed: '<use href="#check-circle"></use>',
|
||
clarify: '<use href="#alert-circle"></use>',
|
||
notfound: '<use href="#help"></use>'
|
||
};
|
||
|
||
return `
|
||
<tr>
|
||
<td><strong>${escapeHtml(r.label)}</strong></td>
|
||
<td>${r.required ? '<span class="kt-ai-chip">Обяз.</span>' : '<span class="kt-ai-chip" data-variant="ghost">Желат.</span>'}</td>
|
||
<td style="text-align: center">${r.weight}</td>
|
||
<td>${evidenceHtml}</td>
|
||
<td>
|
||
<div class="hm-status ${statusClass}">
|
||
<svg class="kt-icon kt-icon-sm" aria-hidden="true">${statusIcons[r.status]}</svg>
|
||
${statusLabel}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
const coverage = state.matchResults.coverage || 0;
|
||
document.getElementById('coverageFill').style.width = `${coverage}%`;
|
||
document.getElementById('coverageText').textContent = `Покрытие требований: ${coverage}%`;
|
||
}
|
||
|
||
function handleGenerateQuestions() {
|
||
if (!state.matchResults || !state.matchResults.results) {
|
||
showNotification('Нет результатов для генерации вопросов', 'error');
|
||
return;
|
||
}
|
||
|
||
const result = window.matcher.generateInterviewQuestions(state.matchResults);
|
||
state.questions = result.questions;
|
||
|
||
navigateTo('interview');
|
||
|
||
if (result.questions.length === 0) {
|
||
showNotification('Все требования подтверждены. Вопросы не требуются.', 'info');
|
||
} else {
|
||
showNotification(`Сгенерировано вопросов: ${result.questions.length}`, 'success');
|
||
}
|
||
}
|
||
|
||
function setupInterviewScreen() {
|
||
document.getElementById('btnBackToMatch').addEventListener('click', () => {
|
||
navigateTo('match');
|
||
});
|
||
|
||
document.getElementById('btnCopyQuestions').addEventListener('click', handleCopyQuestions);
|
||
document.getElementById('btnExport').addEventListener('click', handleExport);
|
||
}
|
||
|
||
function renderQuestions() {
|
||
const container = document.getElementById('questionsList');
|
||
|
||
if (!state.questions || state.questions.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="hm-empty">
|
||
<svg class="hm-empty-icon" aria-hidden="true"><use href="#check-circle"></use></svg>
|
||
<h3>Вопросов нет</h3>
|
||
<p>Все требования подтверждены или ещё не сгенерированы</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = state.questions.map(q => `
|
||
<div class="hm-question-card">
|
||
<div class="hm-question-criterion">${escapeHtml(q.label)}</div>
|
||
<div class="hm-question-text">${escapeHtml(q.question)}</div>
|
||
${q.followUp && q.followUp.length > 0 ? `
|
||
<div style="margin-top: var(--kt-ai-space-2); font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted)">
|
||
<strong>Дополнительно:</strong> ${q.followUp.map(f => escapeHtml(f)).join('; ')}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
async function handleCopyQuestions() {
|
||
if (!state.questions || state.questions.length === 0) {
|
||
showNotification('Нет вопросов для копирования', 'error');
|
||
return;
|
||
}
|
||
|
||
const text = state.questions.map(q => {
|
||
let result = `${q.label}:\n${q.question}`;
|
||
if (q.followUp && q.followUp.length > 0) {
|
||
result += `\nДополнительно: ${q.followUp.join('; ')}`;
|
||
}
|
||
return result;
|
||
}).join('\n\n');
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
showNotification('Вопросы скопированы в буфер обмена', 'success');
|
||
} catch (error) {
|
||
showNotification('Не удалось скопировать. Выделите текст и скопируйте вручную.', 'error');
|
||
}
|
||
}
|
||
|
||
function handleExport() {
|
||
const summary = window.matcher.exportSummary(
|
||
state.vacancyText,
|
||
state.resumeText,
|
||
state.criteria,
|
||
state.matchResults,
|
||
{ questions: state.questions }
|
||
);
|
||
|
||
const textContent = window.matcher.formatExportText(summary);
|
||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||
|
||
window.matcher.downloadText(textContent, `hr-matcher-${timestamp}.txt`);
|
||
showNotification('Сводка экспортирована', 'success');
|
||
}
|
||
|
||
function showNotification(message, type = 'info') {
|
||
const existing = document.querySelector('.hm-notification');
|
||
if (existing) {
|
||
existing.remove();
|
||
}
|
||
|
||
const notification = document.createElement('div');
|
||
notification.className = `hm-notification kt-ai-card`;
|
||
notification.style.cssText = `
|
||
position: fixed;
|
||
bottom: var(--kt-ai-space-6);
|
||
right: var(--kt-ai-space-6);
|
||
padding: var(--kt-ai-space-3) var(--kt-ai-space-4);
|
||
background: var(--kt-ai-card-bg);
|
||
border: 1px solid var(--kt-ai-border);
|
||
border-radius: var(--kt-ai-radius-md);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
z-index: 1000;
|
||
animation: hm-slide-in 0.3s ease;
|
||
max-width: 400px;
|
||
`;
|
||
|
||
const colors = {
|
||
success: 'var(--kt-ai-primary)',
|
||
error: 'var(--kt-ai-danger)',
|
||
info: 'var(--kt-ai-fg-muted)'
|
||
};
|
||
|
||
notification.innerHTML = `
|
||
<div style="display: flex; align-items: center; gap: var(--kt-ai-space-2)">
|
||
<div style="width: 8px; height: 8px; border-radius: 50%; background: ${colors[type]}"></div>
|
||
<span style="font-size: var(--kt-ai-text-sm)">${escapeHtml(message)}</span>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(notification);
|
||
|
||
setTimeout(() => {
|
||
notification.style.animation = 'hm-slide-out 0.3s ease';
|
||
setTimeout(() => notification.remove(), 300);
|
||
}, 3000);
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (typeof text !== 'string') {
|
||
return '';
|
||
}
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
const style = document.createElement('style');
|
||
style.textContent = `
|
||
@keyframes hm-slide-in {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
@keyframes hm-slide-out {
|
||
from { transform: translateX(0); opacity: 1; }
|
||
to { transform: translateX(100%); opacity: 0; }
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
|
||
function getState() {
|
||
return state;
|
||
}
|
||
|
||
function setState(newState) {
|
||
state = { ...state, ...newState };
|
||
}
|
||
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = { initUI, navigateTo, getState, setState, showNotification };
|
||
}
|