268 lines
15 KiB
JavaScript
268 lines
15 KiB
JavaScript
(() => {
|
||
const guide = document.querySelector('[data-guide]');
|
||
if (!guide) return;
|
||
|
||
const directory = '/admin/directory';
|
||
const council = '/admin/council';
|
||
const publications = '/admin/council/publications';
|
||
const councilAccess = guide.dataset.guideCouncil === 'true';
|
||
const steps = [
|
||
{id: 'welcome', title: 'Познакомьтесь с Workers',
|
||
intro: (councilAccess ? 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. '
|
||
: 'Найдите сотрудника и изучите его профиль. ') + 'Подсказки можно повторить из меню «Как пользоваться».'},
|
||
{id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filter-panel', tips: [
|
||
['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'],
|
||
['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'],
|
||
['[name="has_email"]', 'Чтобы найти рабочие контакты, выберите «Есть email».'],
|
||
]},
|
||
{id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filter-panel', tips: [
|
||
['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'],
|
||
['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'],
|
||
['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'],
|
||
]},
|
||
{id: 'employee-training', path: directory, title: 'Проверьте повышение квалификации', region: '.directory__filter-panel', tips: [
|
||
['[name="has_training"]', 'Выберите «Есть курсы» или «Нет курсов» по записям в профиле ВШЭ.'],
|
||
['[name="training_since_year"]', 'Например, 2024 включает 2024 год и все последующие.'],
|
||
]},
|
||
{id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filter-panel', tips: [
|
||
['[name="sort"]', '«Год начала» — начало работы в ВШЭ. «Впервые найден» — когда профиль обнаружила система.'],
|
||
['[name="direction"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'],
|
||
['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'],
|
||
]},
|
||
{id: 'employee-profile', path: directory, title: 'Откройте карточку сотрудника', region: '.directory__header', tips: [
|
||
['[data-columns-open]', 'Выберите столбцы: например, должности, контакты и число публикаций.'],
|
||
['.directory-table__cell[data-column="full_name"] a', 'Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.'],
|
||
]},
|
||
{id: 'council-filters', path: council, title: 'Задайте параметры подбора', region: '.council__search', tips: [
|
||
['#council-branch', 'Отрасль определяет публикационный минимум для доктора наук. Варианты зависят от специальности ВАК.'],
|
||
['#council-specialty', 'Фильтр ищет связь журнала со специальностью. Тематику отдельной статьи проверяют отдельно.'],
|
||
['#council-search', 'Введите фамилию или часть ФИО, чтобы сузить список кандидатов.'],
|
||
]},
|
||
{id: 'council-sort', path: council, title: 'Отсортируйте кандидатов', region: '.council__search', tips: [
|
||
['[name="sort"]', 'Выберите «Подтверждено», чтобы сравнить число работ с проверенными основаниями.'],
|
||
['[name="direction"]', 'Выберите «По убыванию» и нажмите «Применить», чтобы вывести наибольшие значения первыми.'],
|
||
]},
|
||
{id: 'council-counts', path: council, title: 'Прочитайте оценку сотрудника', region: '.council__list-heading',
|
||
intro: 'Нажмите на ФИО, чтобы открыть публикации и основания зачёта.', tips: [
|
||
['.council__candidate-identity', '«Подтверждено» — работы с проверенными видом, периодом и основанием зачёта.', 'inside'],
|
||
['[data-guide-count="total"]', 'Уникальные работы Q1/Q2/К1/RSCI. Для докторов учитывается также К2.', 'inside'],
|
||
['[data-guide-count="all"]', 'Публикации из ЛК и выгрузки ВШЭ за все годы, без дублей.', 'inside'],
|
||
]},
|
||
{id: 'council-sources', path: council, title: 'Сравните счётчики публикаций', region: '.council__list-heading',
|
||
intro: 'В раскрытой карточке можно проверить источник записи, DOI и основания зачёта.', tips: [
|
||
['[data-guide-count="q1"]', 'Одна работа может входить сразу в несколько счётчиков Q1, Q2 и К1.', 'inside'],
|
||
['[data-guide-count="hse"]', 'Здесь только публикации из личного кабинета сотрудника ВШЭ за все годы.', 'inside'],
|
||
]},
|
||
{id: 'council-export', path: council, title: 'Проверьте расчёт и выгрузите список', region: '.council__notice', tips: [
|
||
['.council__notice', 'Здесь описаны период оценки, минимумы по отраслям и правила учёта работ.'],
|
||
['[formaction="/admin/council/export.xlsx"]', 'В XLSX попадут все найденные сотрудники и их публикации, включая другие страницы списка.'],
|
||
]},
|
||
{id: 'publication-search', path: publications, title: 'Найдите нужную публикацию', region: '.council__search', tips: [
|
||
['#publications-employee', 'Укажите фамилию или часть ФИО автора.'],
|
||
['#publications-article', 'Введите несколько слов из названия статьи.'],
|
||
['#publications-journal', 'Укажите часть названия журнала. Фильтры можно сочетать.'],
|
||
]},
|
||
{id: 'publication-check', path: publications, title: 'Проверьте сведения о статье', region: '.council__articles',
|
||
intro: 'Выберите строку статьи, чтобы открыть кнопку «Редактировать статью». С клавиатуры нажмите Enter на строке.', tips: [
|
||
['.council__article-heading', 'Год определяет, за какой период можно учесть работу. Кнопка «Уточнение» показывает, что нужно проверить.'],
|
||
['[data-guide-publication="journal"]', 'Квартиль Q1–Q4 относится к году статьи. К1 и К2 — категории ВАК.'],
|
||
['[data-guide-publication="specialties"]', 'Ручная привязка действует для всех статей журнала за все годы.'],
|
||
], editorTips: [
|
||
['#article-edit-name', 'Укажите название так, как оно приведено в источнике.'],
|
||
['#article-edit-journal', 'Начните вводить название и выберите журнал из списка, если он там есть.'],
|
||
['#article-edit-quartile', 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'],
|
||
]},
|
||
].filter(step => councilAccess || (step.path !== council && step.path !== publications));
|
||
|
||
const main = document.querySelector('#main-content');
|
||
const trigger = document.querySelector('[data-guide-open]');
|
||
const fallback = document.querySelector('[data-guide-fallback]');
|
||
const heading = guide.querySelector('#guide-title');
|
||
const progress = guide.querySelector('[data-guide-progress]');
|
||
const intro = guide.querySelector('[data-guide-intro]');
|
||
const reference = guide.querySelector('[data-guide-reference]');
|
||
const back = guide.querySelector('[data-guide-back]');
|
||
const next = guide.querySelector('[data-guide-next]');
|
||
const close = guide.querySelector('[data-guide-close]');
|
||
const error = guide.querySelector('[data-guide-error]');
|
||
const unsaved = guide.querySelector('[data-guide-unsaved]');
|
||
const storageKey = 'workers-guide:' + guide.dataset.guideUser;
|
||
let index = 0;
|
||
let paused = false;
|
||
let saving = false;
|
||
let removeTips = [];
|
||
|
||
function addTip([selector, message, placement], id, allowHidden = false) {
|
||
const target = main.querySelector(selector);
|
||
if (!target || (!allowHidden && !target.getClientRects().length)) return;
|
||
const tip = document.createElement('span');
|
||
tip.className = 'guide__tip';
|
||
tip.id = 'guide-tip-' + id;
|
||
tip.textContent = message;
|
||
|
||
const description = target.getAttribute('aria-describedby');
|
||
target.setAttribute('aria-describedby', [description, tip.id].filter(Boolean).join(' '));
|
||
const label = target.labels?.[0];
|
||
let group;
|
||
if (label) {
|
||
// The description is announced separately from the label text.
|
||
tip.setAttribute('aria-hidden', 'true');
|
||
label.append(tip);
|
||
} else if (placement === 'inside') {
|
||
target.append(tip);
|
||
} else if (target.matches('button')) {
|
||
group = document.createElement('div');
|
||
group.className = 'guide__field';
|
||
target.before(group);
|
||
group.append(target, tip);
|
||
} else {
|
||
target.after(tip);
|
||
}
|
||
removeTips.push(() => {
|
||
tip.remove();
|
||
if (description === null) target.removeAttribute('aria-describedby');
|
||
else target.setAttribute('aria-describedby', description);
|
||
if (group) group.replaceWith(target);
|
||
});
|
||
}
|
||
|
||
function clearTips() {
|
||
removeTips.forEach(remove => remove());
|
||
removeTips = [];
|
||
}
|
||
|
||
function showStep(focus = true) {
|
||
const step = steps[index];
|
||
paused = Boolean(step.path && step.path !== location.pathname);
|
||
clearTips();
|
||
const region = !paused && step.region && main.querySelector(step.region);
|
||
if (region instanceof HTMLDetailsElement) region.open = true;
|
||
if (region) region.before(guide);
|
||
else main.prepend(guide);
|
||
heading.textContent = paused ? 'Продолжите обучение' : step.title;
|
||
progress.textContent = `Шаг ${index + 1} из ${steps.length}`;
|
||
intro.textContent = paused ? step.title + '.' : step.intro || '';
|
||
intro.hidden = !intro.textContent;
|
||
reference.hidden = index !== 0;
|
||
error.hidden = unsaved.hidden = true;
|
||
back.hidden = paused;
|
||
back.disabled = index === 0;
|
||
next.textContent = paused ? 'Продолжить' : index === steps.length - 1 ? 'Завершить подсказки' : 'Далее';
|
||
if (!paused) {
|
||
step.tips?.forEach((tip, number) => addTip(tip, number));
|
||
step.editorTips?.forEach((tip, number) => addTip(tip, 'editor-' + number, true));
|
||
}
|
||
guide.hidden = false;
|
||
if (focus) {
|
||
heading.focus({preventScroll: true});
|
||
guide.scrollIntoView({block: 'start'});
|
||
}
|
||
}
|
||
|
||
function goTo(number, path = steps[number].path || '/admin/account') {
|
||
if (!saveStep(steps[number].id)) {
|
||
location.assign(trigger.href);
|
||
return;
|
||
}
|
||
if (path !== location.pathname) {
|
||
location.assign(path);
|
||
return;
|
||
}
|
||
index = number;
|
||
showStep();
|
||
}
|
||
|
||
function hide(remember = true) {
|
||
clearTips();
|
||
guide.hidden = true;
|
||
saveStep(remember ? 'closed' : null);
|
||
trigger?.focus();
|
||
}
|
||
|
||
function saveStep(step) {
|
||
try {
|
||
if (step === null) sessionStorage.removeItem(storageKey);
|
||
else sessionStorage.setItem(storageKey, step);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function finish() {
|
||
if (saving) return;
|
||
if (guide.dataset.guidePending !== 'true') {
|
||
hide();
|
||
return;
|
||
}
|
||
saving = true;
|
||
guide.setAttribute('aria-busy', 'true');
|
||
close.disabled = next.disabled = back.disabled = true;
|
||
error.hidden = unsaved.hidden = true;
|
||
try {
|
||
const response = await fetch('/admin/guide/dismiss', {
|
||
method: 'POST', credentials: 'same-origin', redirect: 'error',
|
||
headers: {'X-CSRF-Token': guide.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000),
|
||
});
|
||
if (!response.ok) throw new Error();
|
||
guide.dataset.guidePending = 'false';
|
||
hide();
|
||
} catch (_failure) {
|
||
error.textContent = 'Не удалось сохранить закрытие. Повторите попытку или продолжите без сохранения.';
|
||
error.hidden = unsaved.hidden = false;
|
||
unsaved.focus();
|
||
} finally {
|
||
saving = false;
|
||
guide.removeAttribute('aria-busy');
|
||
close.disabled = next.disabled = false;
|
||
back.disabled = index === 0;
|
||
}
|
||
}
|
||
|
||
trigger?.addEventListener('click', event => {
|
||
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||
event.preventDefault();
|
||
if (saving) return;
|
||
goTo(0, location.pathname);
|
||
});
|
||
back.addEventListener('click', () => goTo(index - 1));
|
||
next.addEventListener('click', () => {
|
||
if (paused) goTo(index);
|
||
else if (index === steps.length - 1) finish();
|
||
else goTo(index + 1);
|
||
});
|
||
close.addEventListener('click', finish);
|
||
unsaved.addEventListener('click', () => hide(false));
|
||
guide.addEventListener('keydown', event => {
|
||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||
event.preventDefault();
|
||
finish();
|
||
});
|
||
function restore(allowAutoStart) {
|
||
let active;
|
||
try {
|
||
active = sessionStorage.getItem(storageKey);
|
||
} catch {
|
||
return;
|
||
}
|
||
const requested = steps.findIndex(step => step.id === active);
|
||
const autoStart = allowAutoStart && requested < 0 && active !== 'closed' && guide.dataset.guidePending === 'true';
|
||
if (autoStart && !saveStep(steps[0].id)) return;
|
||
if (fallback) fallback.hidden = true;
|
||
if (requested >= 0) {
|
||
index = requested;
|
||
showStep(!allowAutoStart || index > 0);
|
||
} else if (autoStart) {
|
||
showStep(false);
|
||
} else {
|
||
clearTips();
|
||
guide.hidden = true;
|
||
}
|
||
}
|
||
|
||
window.addEventListener('pageshow', event => {
|
||
if (event.persisted) restore(false);
|
||
});
|
||
restore(true);
|
||
})();
|