ty-produktovyy-dizayner-i-se/ui.js

497 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 };
}