Files
TestingWebApp/flask_app/app/static/js/editor.js
T
Константин Лебединский 1ea83aa6b4 testingwebapp fixes, weeek tasks 2948-2958
2026-05-04 21:29:23 +05:00

2042 lines
82 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.
/* Редактор теста: рабочий минимум.
* Работает с эндпоинтами /api/tests/<id>/{draft, ai/generate-test, ai/generate-question}
* и /api/tests/<id> (PATCH chainActive).
*
* Полная мобильная отполировка UX (4 аккордеона, fixed footer, drag-n-drop)
* запланирована отдельным спринтом E1.7.
*/
(() => {
'use strict';
const root = document.getElementById('editor-root');
if (!root) return;
const TEST_ID = root.dataset.testId;
const initial = JSON.parse(root.dataset.initial);
const $ = (sel, parent = document) => parent.querySelector(sel);
const $$ = (sel, parent = document) => Array.from(parent.querySelectorAll(sel));
const MAX_OPTIONS = 8;
const titleEl = $('#test-title');
const descEl = $('#test-description');
const thresholdEl = $('#test-threshold');
const questionsEl = $('#questions');
const qCountEl = $('#q-count');
const saveStatusEl = $('#save-status');
const aiKeepTitleEl = $('#ai-keep-title');
const aiImproveFocusEl = $('#ai-improve-focus');
const aiImportClearBtn = $('#ai-import-clear');
const addQuestionAiBtn = $('#add-question-ai');
const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге»
const chainActiveDisplay = $('#chain-active-display');
const aiTopicEl = $('#ai-topic');
const aiQCountEl = $('#ai-q-count');
const aiOCountEl = $('#ai-o-count');
const templateGlobalMultiEl = $('#template-global-multi');
const templateMinCorrectEl = $('#template-min-correct');
const templateMaxCorrectEl = $('#template-max-correct');
const introUpdatedEl = $('#intro-updated');
const introForkBannerEl = $('#intro-fork-banner');
const versionsListEl = $('#versions-list');
const attemptsListEl = $('#attempts-list');
const visibilityBtn = $('#btn-toggle-visibility');
const assignSearchEl = $('#assign-search');
const assignDeptEl = $('#assign-dept');
const assignClinicEl = $('#assign-clinic');
const assignListEl = $('#assign-list');
const assignSelectAllBtn = $('#assign-select-all');
const assignSubmitBtn = $('#assign-submit');
const assignStatusEl = $('#assign-status');
const tplQ = $('#tpl-question');
const tplO = $('#tpl-option');
let chainActive = true;
let assignPeople = [];
let assignSelected = new Set();
let hasAnyAttempts = false;
let hasForkRisk = Boolean(initial?.test?.hasForkRisk)
|| (introForkBannerEl && introForkBannerEl.dataset.forkRisk === '1');
let baselineDraftKey = '';
let dirtyCheckQueued = false;
function getTemplateCorrectRange(optionsCount, hasMultipleAnswers) {
const maxOpt = Math.max(2, Number(optionsCount || 2));
const rawMin = Math.max(1, Number(templateMinCorrectEl?.value || 1) || 1);
const rawMax = Math.max(1, Number(templateMaxCorrectEl?.value || 1) || 1);
if (!hasMultipleAnswers) return { minCorrect: 1, maxCorrect: 1 };
const minCorrect = Math.min(maxOpt, rawMin);
const maxCorrect = Math.max(minCorrect, Math.min(maxOpt, rawMax));
return { minCorrect, maxCorrect };
}
function syncTemplateRangeUi() {
const hasMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const maxOpt = Math.min(MAX_OPTIONS, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
const range = getTemplateCorrectRange(maxOpt, hasMulti);
if (templateMinCorrectEl) {
templateMinCorrectEl.min = '1';
templateMinCorrectEl.max = String(maxOpt);
templateMinCorrectEl.value = String(range.minCorrect);
templateMinCorrectEl.disabled = !hasMulti;
}
if (templateMaxCorrectEl) {
templateMaxCorrectEl.min = '1';
templateMaxCorrectEl.max = String(maxOpt);
templateMaxCorrectEl.value = String(range.maxCorrect);
templateMaxCorrectEl.disabled = !hasMulti;
}
}
function currentDraftKey() {
return JSON.stringify(collectPayload());
}
function isDirty() {
return baselineDraftKey !== '' && baselineDraftKey !== currentDraftKey();
}
function updateForkBanner() {
if (!introForkBannerEl) return;
introForkBannerEl.style.display = (hasForkRisk && isDirty()) ? '' : 'none';
}
function scheduleDirtyCheck() {
if (dirtyCheckQueued) return;
dirtyCheckQueued = true;
requestAnimationFrame(() => {
dirtyCheckQueued = false;
updateForkBanner();
});
}
function resetBaselineDraft() {
baselineDraftKey = currentDraftKey();
updateForkBanner();
}
// ─── render ─────────────────────────────────────────────────────────
function syncOptionInputTypes(qNode) {
const isMulti = $('.q-multi', qNode).checked;
const qName = `q-correct-${Math.random().toString(36).slice(2)}`;
$$('.opt-correct', qNode).forEach((input) => {
input.type = isMulti ? 'checkbox' : 'radio';
if (isMulti) input.removeAttribute('name');
else input.setAttribute('name', qName);
input.classList.add('question-option-row__mark');
});
}
function renderQuestion(q) {
const node = tplQ.content.firstElementChild.cloneNode(true);
node._q = { id: q.id || null };
$('.q-text', node).value = q.text || '';
$('.q-multi', node).checked = !!q.hasMultipleAnswers;
const optsEl = $('.q-options', node);
(q.options || []).forEach((o) => optsEl.appendChild(renderOption(o, node)));
bindQuestionEvents(node);
syncOptionInputTypes(node);
updateOptionsCounter(node);
updateAiButtonLabel(node);
const hintEl = $('.q-hint', node);
if (hintEl) {
hintEl.value = q.aiHint || '';
const rh = () => autoResize(hintEl);
hintEl.addEventListener('input', () => { rh(); scheduleDirtyCheck(); });
requestAnimationFrame(rh);
}
return node;
}
function renderOption(o, qNode) {
const node = tplO.content.firstElementChild.cloneNode(true);
const textEl = $('.opt-text', node);
textEl.value = o.text || '';
$('.opt-correct', node).checked = !!o.isCorrect;
if (textEl && textEl.tagName === 'TEXTAREA') {
const resize = () => autoResize(textEl);
textEl.addEventListener('input', resize);
requestAnimationFrame(resize);
}
$('.opt-delete', node).addEventListener('click', () => {
node.remove();
if (qNode) updateOptionsCounter(qNode);
scheduleDirtyCheck();
});
const optAiBtn = $('.opt-ai', node);
if (optAiBtn && qNode) {
optAiBtn.addEventListener('click', async (ev) => {
ev.preventDefault();
const opts = $$('.opt-item', qNode);
const idx = opts.indexOf(node);
if (idx < 0) return;
await improveSingleOption(qNode, idx);
});
}
return node;
}
let dragSrc = null;
function bindDragEvents(node) {
const handle = $('.q-drag', node);
if (handle) {
handle.addEventListener('mousedown', () => { node.draggable = true; });
handle.addEventListener('mouseup', () => { node.draggable = true; });
}
node.addEventListener('dragstart', (e) => {
dragSrc = node;
node.classList.add('q-dragging');
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); } catch {}
});
node.addEventListener('dragend', () => {
node.classList.remove('q-dragging');
$$('#questions .q-item').forEach((li) => li.classList.remove('q-drop-before', 'q-drop-after'));
dragSrc = null;
renumber();
scheduleDirtyCheck();
});
node.addEventListener('dragover', (e) => {
if (!dragSrc || dragSrc === node) return;
e.preventDefault();
const rect = node.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
node.classList.toggle('q-drop-before', before);
node.classList.toggle('q-drop-after', !before);
});
node.addEventListener('dragleave', () => {
node.classList.remove('q-drop-before', 'q-drop-after');
});
node.addEventListener('drop', (e) => {
if (!dragSrc || dragSrc === node) return;
e.preventDefault();
const rect = node.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
node.classList.remove('q-drop-before', 'q-drop-after');
node.parentNode.insertBefore(dragSrc, before ? node : node.nextSibling);
});
}
function bindQuestionEvents(node) {
bindDragEvents(node);
$('.q-delete', node).addEventListener('click', () => {
markQuestionRemoved(node);
});
$('.q-up', node).addEventListener('click', () => {
if (node.previousElementSibling) {
node.parentNode.insertBefore(node, node.previousElementSibling);
renumber();
scheduleDirtyCheck();
}
});
$('.q-down', node).addEventListener('click', () => {
if (node.nextElementSibling) {
node.parentNode.insertBefore(node.nextElementSibling, node);
renumber();
scheduleDirtyCheck();
}
});
const addOptBtn = $('.q-add-option', node);
addOptBtn.addEventListener('click', () => {
const optsEl = $('.q-options', node);
const count = $$('.opt-item', node).length;
if (count >= MAX_OPTIONS) return;
optsEl.appendChild(renderOption({ text: '', isCorrect: false }, node));
syncOptionInputTypes(node);
updateOptionsCounter(node);
scheduleDirtyCheck();
});
// Кнопка очистки вопроса
const clearBtn = $('.q-clear', node);
if (clearBtn) {
clearBtn.addEventListener('click', () => {
const qTextEl = $('.q-text', node);
qTextEl.value = '';
autoResize(qTextEl);
const qh = $('.q-hint', node);
if (qh) { qh.value = ''; autoResize(qh); }
$$('.opt-text', node).forEach((t) => { t.value = ''; autoResize(t); });
$$('.opt-correct', node).forEach((c) => { c.checked = false; });
updateAiButtonLabel(node);
scheduleDirtyCheck();
});
}
// Умная кнопка AI — label зависит от наличия текста
const qTextEl2 = $('.q-text', node);
if (qTextEl2) {
qTextEl2.addEventListener('input', () => updateAiButtonLabel(node));
}
$('.q-ai', node).addEventListener('click', () => aiGenerateQuestion(node));
$('.q-multi', node).addEventListener('change', () => {
syncOptionInputTypes(node);
scheduleDirtyCheck();
});
// Счётчик символов у textarea вопроса
const qTextEl = $('.q-text', node);
const qCounter = $('.q-text-counter', node);
if (qTextEl && qCounter) {
const updateCounter = () => {
const len = qTextEl.value.length;
const max = parseInt(qTextEl.getAttribute('maxlength') || '500', 10);
qCounter.textContent = len > 200 ? `${len}/${max}` : '';
qCounter.style.color = len > 450 ? '#ef4444' : len > 350 ? '#f59e0b' : '';
autoResize(qTextEl);
};
qTextEl.addEventListener('input', () => { updateCounter(); scheduleDirtyCheck(); });
requestAnimationFrame(updateCounter);
}
}
function updateAiButtonLabel(node) {
const qText = $('.q-text', node);
const label = $('.q-ai-label', node);
if (!qText || !label) return;
const hasText = qText.value.trim().length > 0;
label.textContent = hasText ? 'Улучшить' : 'Сгенерировать';
}
function updateOptionsCounter(node) {
const count = $$('.opt-item', node).length;
const countEl = $('.q-options-count', node);
const addBtn = $('.q-add-option', node);
const labelEl = $('.q-add-option-label', node);
if (countEl) countEl.textContent = count > 0 ? `${count}/${MAX_OPTIONS}` : '';
if (addBtn) {
const atMax = count >= MAX_OPTIONS;
addBtn.disabled = atMax;
addBtn.style.opacity = atMax ? '0.4' : '';
if (labelEl) labelEl.textContent = atMax ? 'Лимит вариантов' : 'Добавить вариант';
}
}
function renumber() {
let i = 0;
$$('#questions .q-item').forEach((li) => {
const removed = li.classList.contains('q-removed');
if (removed) {
$$('.q-num', li).forEach((el) => { el.textContent = 'Удалён'; });
return;
}
i += 1;
$$('.q-num', li).forEach((el) => { el.textContent = `Вопрос #${i}`; });
});
if (qCountEl) qCountEl.textContent = i;
const mirror = document.getElementById('q-count-mirror');
if (mirror) mirror.textContent = i;
}
function markQuestionRemoved(node) {
if (node.classList.contains('q-removed')) return;
node.classList.add('q-removed');
node.draggable = false;
let banner = $('.q-removed-banner', node);
if (!banner) {
banner = document.createElement('div');
banner.className = 'q-removed-banner';
banner.innerHTML =
'<span>Вопрос будет удалён при сохранении</span>' +
'<button type="button" class="q-restore btn btn-ghost btn--sm">Отменить</button>';
node.prepend(banner);
$('.q-restore', banner).addEventListener('click', () => restoreQuestion(node));
}
renumber();
scheduleDirtyCheck();
}
function restoreQuestion(node) {
node.classList.remove('q-removed');
node.draggable = true;
const banner = $('.q-removed-banner', node);
if (banner) banner.remove();
renumber();
scheduleDirtyCheck();
}
function autoResize(el) {
if (!el) return;
el.style.height = 'auto';
el.style.height = el.scrollHeight + 'px';
}
function syncThresholdMirror() {
const m = document.getElementById('threshold-mirror');
if (!m) return;
const v = (thresholdEl && thresholdEl.value !== '') ? thresholdEl.value : '—';
m.textContent = v;
}
function syncEditorHeroExtra() {
const timeVal = document.getElementById('editor-hero-time-val');
const resVal = document.getElementById('editor-hero-result-val');
const hintsVal = document.getElementById('editor-hero-hints-val');
if (!timeVal || !resVal || !hintsVal) return;
const tlEl = document.getElementById('test-time-limit');
const raw = tlEl && tlEl.value !== '' ? Number(tlEl.value) : 0;
timeVal.textContent = (!raw || raw <= 0) ? 'без ограничения' : `${raw} мин`;
const mode = document.querySelector('input[name="result-mode"]:checked');
const imm = mode && mode.value === 'immediate';
resVal.textContent = imm ? 'сразу' : 'в конце';
const hintsCheckbox = document.getElementById('test-hints-enabled');
if (!imm) hintsVal.textContent = 'недоступны';
else if (hintsCheckbox && hintsCheckbox.checked) hintsVal.textContent = 'вкл';
else hintsVal.textContent = 'выкл';
}
function syncHeroMetaRow() {
syncThresholdMirror();
syncEditorHeroExtra();
}
function loadInitial() {
titleEl.value = initial.test.title || '';
descEl.value = initial.test.description || '';
autoResize(titleEl);
autoResize(descEl);
if (thresholdEl) {
thresholdEl.addEventListener('input', syncHeroMetaRow);
thresholdEl.addEventListener('change', syncHeroMetaRow);
}
if (titleEl && titleEl.tagName === 'TEXTAREA') {
titleEl.addEventListener('input', () => {
autoResize(titleEl);
// Синхронизируем поле темы, только если оно не было изменено вручную
if (aiTopicEl && aiTopicEl.dataset.userEdited !== '1') {
aiTopicEl.value = titleEl.value;
}
});
titleEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') e.preventDefault();
});
}
if (aiTopicEl) {
aiTopicEl.addEventListener('input', () => {
aiTopicEl.dataset.userEdited = '1';
autoResize(aiTopicEl);
});
}
if (descEl) descEl.addEventListener('input', () => autoResize(descEl));
if (docUserHint) docUserHint.addEventListener('input', () => autoResize(docUserHint));
thresholdEl.value =
initial.test.passingThreshold == null ? '' : Number(initial.test.passingThreshold);
syncHeroMetaRow();
const timeLimitEl = document.getElementById('test-time-limit');
const hintsEl = document.getElementById('test-hints-enabled');
const hintsRow = document.getElementById('test-hints-row');
const resultModeRadios = document.querySelectorAll('input[name="result-mode"]');
if (timeLimitEl) {
timeLimitEl.value = initial.test.timeLimit == null ? '' : Number(initial.test.timeLimit);
timeLimitEl.addEventListener('input', () => {
scheduleDirtyCheck();
syncEditorHeroExtra();
});
}
const initMode = (initial.test.resultMode === 'immediate') ? 'immediate' : 'end';
resultModeRadios.forEach((r) => {
r.checked = (r.value === initMode);
r.addEventListener('change', () => {
const mode = document.querySelector('input[name="result-mode"]:checked');
const isImmediate = mode && mode.value === 'immediate';
if (hintsRow) hintsRow.style.display = isImmediate ? '' : 'none';
if (hintsEl && !isImmediate) hintsEl.checked = false;
syncHeroMetaRow();
scheduleDirtyCheck();
});
});
if (hintsEl) {
hintsEl.checked = !!initial.test.hintsEnabled;
hintsEl.addEventListener('change', () => {
const mode = document.querySelector('input[name="result-mode"]:checked');
const isImmediate = mode && mode.value === 'immediate';
syncHeroMetaRow();
scheduleDirtyCheck();
});
}
if (hintsRow) hintsRow.style.display = (initMode === 'immediate') ? '' : 'none';
questionsEl.innerHTML = '';
(initial.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
if (aiTopicEl && !aiTopicEl.value.trim()) {
aiTopicEl.value = initial.test.title || '';
}
if (aiTopicEl) requestAnimationFrame(() => autoResize(aiTopicEl));
}
function fmtDt(iso) {
if (!iso) return '—';
try {
return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
} catch {
return '—';
}
}
function escHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/** Тост справа под шапкой: выезд справа, variant ok/err/info, авто-уезд по durationMs. */
function hideToastAnimated(el) {
if (!el) return;
clearTimeout(showAiToast._timer);
clearTimeout(showAiToast._fallbackHide);
el.classList.remove('editor-gen-toast--open');
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(showAiToast._fallbackHide);
el.removeEventListener('transitionend', onEnd);
el.hidden = true;
el.textContent = '';
el.removeAttribute('data-variant');
};
const onEnd = (ev) => {
if (ev.propertyName !== 'transform') return;
finish();
};
el.addEventListener('transitionend', onEnd);
showAiToast._fallbackHide = setTimeout(finish, 500);
}
function showAiToast(message, variant = 'info', durationMs = 4800) {
const el = document.getElementById('editor-gen-toast');
if (!el) return;
clearTimeout(showAiToast._timer);
clearTimeout(showAiToast._fallbackHide);
if (!message) {
if (!el.hidden) hideToastAnimated(el);
return;
}
if (el.classList.contains('editor-gen-toast--open') && !el.hidden) {
el.textContent = message;
el.dataset.variant = variant;
if (durationMs != null && durationMs > 0) {
showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs);
}
return;
}
el.textContent = message;
el.dataset.variant = variant;
el.hidden = false;
el.classList.remove('editor-gen-toast--open');
void el.offsetWidth;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.add('editor-gen-toast--open');
});
});
if (durationMs != null && durationMs > 0) {
showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs);
}
}
function formatEtaSeconds(sec) {
const s = Math.min(7200, Math.max(5, Math.round(sec)));
if (s < 60) return `примерно ${s} с`;
const m = Math.ceil(s / 60);
if (m === 1) return 'примерно 1 мин';
return `примерно ${m} мин`;
}
function etaFullGenerateSeconds(nQ) {
return Math.min(900, Math.max(25, 20 + nQ * 12));
}
async function runHintsProgressFromServer(startMissing) {
const total = Math.max(0, Number(startMissing) || 0);
if (!total) return;
let iter = 0;
const maxIter = Math.min(500, Math.max(total * 3, total + 15));
while (iter < maxIter) {
iter += 1;
const line = `Подсказки ИИ: шаг ${iter} из ~${total}`;
showAiToast(line, 'info', 0);
const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate-next`, { method: 'POST' });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.error || 'Не удалось сгенерировать подсказку.');
if (d.done || d.remaining === 0) break;
if (d.generated) continue;
break;
}
}
// ─── collect ───────────────────────────────────────────────────────
function collectPayload() {
const questions = $$('#questions .q-item:not(.q-removed)').map((li, i) => {
const hintVal = ($('.q-hint', li) && $('.q-hint', li).value.trim()) || '';
return {
text: $('.q-text', li).value.trim(),
question_order: i + 1,
hasMultipleAnswers: $('.q-multi', li).checked,
aiHint: hintVal || null,
options: $$('.opt-item', li).map((op, j) => ({
text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked,
option_order: j + 1,
})),
};
});
const payload = {
title: titleEl.value.trim() || null,
description: descEl.value.trim() || null,
questions,
};
const t = thresholdEl.value;
if (t !== '' && Number.isFinite(Number(t))) payload.passingThreshold = Number(t);
const timeLimitEl = document.getElementById('test-time-limit');
if (timeLimitEl) {
const tl = timeLimitEl.value;
payload.timeLimit = (tl === '' ? null : Math.max(0, Number(tl) || 0));
}
const modeEl = document.querySelector('input[name="result-mode"]:checked');
payload.resultMode = (modeEl && modeEl.value === 'immediate') ? 'immediate' : 'end';
const hintsEl = document.getElementById('test-hints-enabled');
payload.hintsEnabled = !!(hintsEl && hintsEl.checked && payload.resultMode === 'immediate');
return payload;
}
function collectShape() {
return $$('#questions .q-item').map((li) => {
const optionsCount = Math.max(2, $$('.opt-item', li).length || 4);
const hasMultipleAnswers = $('.q-multi', li).checked;
const range = getTemplateCorrectRange(optionsCount, hasMultipleAnswers);
return {
optionsCount,
hasMultipleAnswers,
minCorrect: hasMultipleAnswers ? range.minCorrect : 1,
maxCorrect: hasMultipleAnswers ? range.maxCorrect : 1,
};
});
}
async function saveCurrentDraftQuietly() {
const r = await fetch(`/api/tests/${TEST_ID}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectPayload()),
});
const data = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить черновик перед генерацией подсказок.');
resetBaselineDraft();
return data;
}
function applyServerTestState(test) {
if (!test || typeof test !== 'object') return;
if (typeof test.title === 'string' && titleEl) titleEl.value = test.title;
if (typeof test.description === 'string' && descEl) descEl.value = test.description;
if (thresholdEl && test.passingThreshold != null) {
thresholdEl.value = Number(test.passingThreshold);
}
const timeLimitEl = document.getElementById('test-time-limit');
if (timeLimitEl) timeLimitEl.value = test.timeLimit == null ? '' : Number(test.timeLimit);
const hintsEl = document.getElementById('test-hints-enabled');
const hintsRow = document.getElementById('test-hints-row');
const mode = (test.resultMode === 'immediate') ? 'immediate' : 'end';
const modeEl = document.querySelector(`input[name="result-mode"][value="${mode}"]`);
if (modeEl) modeEl.checked = true;
if (hintsRow) hintsRow.style.display = (mode === 'immediate') ? '' : 'none';
if (hintsEl) {
hintsEl.checked = !!test.hintsEnabled && mode === 'immediate';
}
autoResize(titleEl);
autoResize(descEl);
syncHeroMetaRow();
}
async function refreshMetaAfterSave() {
const [v, s, e] = await Promise.all([
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/editor`).then((r) => r.json()).catch(() => null),
]);
if (v && Array.isArray(v.versions)) {
renderVersions(v.versions);
hasForkRisk = hasForkRisk || (v.versions.length > 1);
if (typeof v.hasAttempts === 'boolean') {
hasAnyAttempts = hasAnyAttempts || v.hasAttempts;
hasForkRisk = hasForkRisk || v.hasAttempts;
}
}
if (s && s.test) {
if (introUpdatedEl) introUpdatedEl.textContent = fmtDt(s.test.updated_at || s.test.updatedAt);
const versionEl = document.getElementById('intro-version');
if (versionEl && s.test.version != null) versionEl.textContent = s.test.version;
if (typeof s.test.hasAttempts === 'boolean') {
hasAnyAttempts = hasAnyAttempts || s.test.hasAttempts;
hasForkRisk = hasForkRisk || s.test.hasAttempts;
}
}
if (e && e.test) applyServerTestState(e.test);
updateForkBanner();
}
async function refreshHintsInForm() {
const r = await fetch(`/api/tests/${TEST_ID}/editor`);
const data = await r.json().catch(() => ({}));
if (!r.ok || !Array.isArray(data.questions)) {
throw new Error(data.error || 'Не удалось обновить подсказки в форме.');
}
const byOrder = (data.questions || []).slice().sort(
(a, b) => Number(a.questionOrder || 0) - Number(b.questionOrder || 0)
);
// Надёжно подтягиваем подсказки: перерисовываем список вопросов с актуальными данными сервера.
questionsEl.innerHTML = '';
byOrder.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
}
// ─── actions ───────────────────────────────────────────────────────
$('#add-question').addEventListener('click', () => {
questionsEl.appendChild(
renderQuestion({
text: '',
hasMultipleAnswers: false,
options: [
{ text: '', isCorrect: true },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
],
}),
);
renumber();
scheduleDirtyCheck();
});
$('#save-draft').addEventListener('click', async () => {
saveStatusEl.textContent = 'Сохраняем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/draft`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(collectPayload()),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось сохранить.');
if (chainActiveEl.checked !== chainActive) {
const r2 = await fetch(`/api/tests/${TEST_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chainActive: chainActiveEl.checked }),
});
if (r2.ok) chainActive = chainActiveEl.checked;
}
await refreshMetaAfterSave();
resetBaselineDraft();
const msg = data.forked
? 'Сохранено. Создана новая версия — у теста есть попытки прохождения.'
: 'Изменения сохранены.';
const saveModal = document.getElementById('save-modal');
const saveMsg = document.getElementById('save-modal-msg');
const hintsEl = document.getElementById('test-hints-enabled');
const modeEl = document.querySelector('input[name="result-mode"]:checked');
const wantsHints = !!(hintsEl && hintsEl.checked) && modeEl && modeEl.value === 'immediate';
if (wantsHints) {
try {
const sr = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`);
const st = await sr.json().catch(() => ({}));
if (sr.ok && Number(st.missing) > 0) {
const okGen = confirm(
`Подсказок пока нет или заполнены не все: не хватает ${st.missing} из ${st.total}.\n\n`
+ 'Сгенерировать недостающие подсказки через ИИ?',
);
if (okGen) {
try {
await runHintsProgressFromServer(st.missing);
if (saveMsg) saveMsg.textContent = `${msg} Подсказки обновлены через ИИ.`;
try {
await refreshHintsInForm();
} catch (_) {
/* не блокируем успех сохранения */
}
showAiToast('Подсказки сгенерированы.', 'ok');
} catch (he) {
saveStatusEl.textContent = '';
alert(he.message || 'Не удалось сгенерировать подсказки.');
if (saveMsg) saveMsg.textContent = `${msg} (подсказки не созданы)`;
if (saveModal) saveModal.showModal();
return;
}
} else if (saveMsg) {
saveMsg.textContent = msg;
}
} else if (saveMsg) {
saveMsg.textContent = msg;
}
} catch (err) {
if (saveMsg) saveMsg.textContent = msg + ' (ИИ-подсказки не созданы)';
}
} else {
if (saveMsg) saveMsg.textContent = msg;
}
saveStatusEl.textContent = '';
if (saveModal) {
saveModal.showModal();
}
} catch (e) {
saveStatusEl.textContent = '';
alert(e.message || 'Не удалось сохранить.');
}
});
$('#ai-generate-test').addEventListener('click', async () => {
const topic = (aiTopicEl?.value || titleEl.value || '').trim();
if (!topic) {
alert('Укажите тему.');
return;
}
const keepTitle = !!(aiKeepTitleEl && aiKeepTitleEl.checked);
const hasContent = questionsEl.children.length > 0
|| titleEl.value.trim()
|| descEl.value.trim();
if (hasContent) {
const warn = keepTitle
? 'Полная генерация заменит описание и все вопросы. Название в редакторе не будет заменено, если включено «Не менять название».\n\nПродолжить?'
: 'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?';
const ok = confirm(warn);
if (!ok) return;
}
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const globalRange = getTemplateCorrectRange(nO, globalMulti);
const shape = Array.from({ length: nQ }, () => ({
optionsCount: nO,
hasMultipleAnswers: globalMulti,
minCorrect: globalMulti ? globalRange.minCorrect : 1,
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
}));
showAiToast(
`Генерация ${nQ} вопросов · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
'info',
0,
);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: topic,
testDescription: descEl.value,
shape,
}),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
const draft = data.draft;
if (draft.title && !keepTitle) {
titleEl.value = draft.title;
if (aiTopicEl) aiTopicEl.value = draft.title;
} else if (aiTopicEl) {
aiTopicEl.value = topic;
}
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
const hintsEl = document.getElementById('test-hints-enabled');
const modeEl = document.querySelector('input[name="result-mode"]:checked');
if (hintsEl && hintsEl.checked && modeEl && modeEl.value === 'immediate') {
showAiToast('Сохраняем черновик перед подсказками…', 'info', 0);
try {
await saveCurrentDraftQuietly();
const hs = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`).then((x) => x.json());
const miss = Number(hs.missing || 0);
if (miss > 0) {
await runHintsProgressFromServer(miss);
}
try {
await refreshHintsInForm();
} catch (_) {
/* не блокируем успех */
}
showAiToast(`Готово: ${draft.questions?.length || 0} вопросов и подсказки`, 'ok');
} catch (_) {
showAiToast(
`Вопросы готовы (${draft.questions?.length || 0}); подсказки не созданы`,
'err',
);
}
} else {
showAiToast(`Готово: ${draft.questions?.length || 0} вопросов`, 'ok');
}
} catch (e) {
showAiToast('', 'info');
const msg = e.message || 'AI: ошибка.';
showAiToast(msg, 'err', 7000);
alert(msg);
}
});
// ─── импорт документа с drag-and-drop (E1.3) ──────────────────
const importDropzone = $('#ai-import-dropzone');
const importDropzoneLabel = $('#ai-import-dropzone-label');
const docUserHint = $('#doc-user-hint');
const docGenerateBtn = $('#doc-generate-btn');
const importModal = $('#import-modal');
const importModalTitle = $('#import-modal-title');
const importModalBody = $('#import-modal-body');
const importModalActions = $('#import-modal-actions');
let _extractedText = '';
let _extractedFileName = '';
/** Имена всех загруженных файлов (несколько выборов из разных папок склеиваются). */
let _importFileNames = [];
/** HTML карточки вопроса в модалке предпросмотра импорта (как в разборе: текст, подсказка, все варианты). */
function buildImportPreviewQuestionHtml(q, index) {
const hint = (q.aiHint || '').trim();
const opts = Array.isArray(q.options) ? q.options : [];
const optionsHtml = opts.length
? `<ul class="attempt-review-options" role="list">${opts.map((o) => {
const correct = !!o.isCorrect;
const liCls = correct
? 'attempt-review-option attempt-review-option--correct'
: 'attempt-review-option';
const tag = correct ? '<span class="attempt-review-option__tag">верный ответ</span>' : '';
return `<li class="${liCls}">
<span class="attempt-review-option__text">
<span class="attempt-review-option__mark" aria-hidden="true">☐</span>
<span class="attempt-review-option__body">${escHtml(o.text || '')}${tag}</span>
</span>
</li>`;
}).join('')}</ul>`
: '';
const hintHtml = hint
? `<div class="attempt-review-hint">
<span class="attempt-review-hint__label">Подсказка</span>
<p class="attempt-review-hint__text">${escHtml(hint)}</p>
</div>`
: '';
return `<article class="attempt-card attempt-review-card import-modal-review__card">
<div class="attempt-review-card__head">
<span class="attempt-review-card__num">${index + 1}</span>
<span class="attempt-review-card__badge attempt-review-card__badge--preview">черновик</span>
</div>
<p class="attempt-review-card__question">${escHtml(q.text || '')}</p>
${hintHtml}
${optionsHtml}
</article>`;
}
function openImportModal(title, bodyHtml, actions) {
importModalTitle.textContent = title;
importModalBody.innerHTML = bodyHtml;
importModalActions.innerHTML = '';
actions.forEach(({ label, onClick, primary }) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = label;
btn.className = primary
? 'px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium'
: 'px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-50 text-ink-700 text-sm';
btn.addEventListener('click', onClick);
importModalActions.appendChild(btn);
});
importModal.showModal();
}
function clearImportState() {
_extractedText = '';
_extractedFileName = '';
_importFileNames = [];
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файлы сюда или нажмите';
importDropzone?.classList.remove('import-dropzone--done', 'import-dropzone--loading');
if (docUserHint) docUserHint.value = '';
}
if (aiImportClearBtn) {
aiImportClearBtn.addEventListener('click', () => {
clearImportState();
showAiToast('Загрузка сброшена.', 'info');
});
}
// Фаза 1: выбрать файл(ы) → извлечь текст, обновить метку дропзоны
async function handleImportFiles(fileList) {
const files = Array.from(fileList || []).filter(Boolean);
if (!files.length) return;
if (files.length > 5) {
showAiToast('Не более 5 файлов за раз.', 'err');
return;
}
const allowed = ['.pdf', '.docx', '.txt', '.md'];
for (const file of files) {
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
if (!allowed.includes(ext)) {
showAiToast(`Формат «${ext}» не поддерживается.`, 'err');
return;
}
}
const appendHint = _extractedText.trim() ? ' · добавляем к уже загруженным' : '';
showAiToast(
files.length > 1
? `Загружаем ${files.length} файла…${appendHint}`
: `Загружаем «${files[0].name}»…${appendHint}`,
'info',
0,
);
importDropzone?.classList.add('import-dropzone--loading');
try {
const fd = new FormData();
files.forEach((f) => fd.append('files', f));
const r = await fetch('/api/tests/import/document', {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
let data;
const raw = await r.text();
try {
data = raw.trim() ? JSON.parse(raw) : {};
} catch {
if (r.status === 413) throw new Error('Файл слишком большой для сервера или прокси.');
if (r.status === 401) throw new Error('Сессия истекла — войдите снова и повторите загрузку.');
throw new Error(
`Сервер вернул не JSON (код ${r.status}). Обновите страницу или войдите снова.`,
);
}
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.');
const batchText = (data.extractedText || '').trim();
const names = Array.isArray(data.originalNames) ? data.originalNames : [data.originalName || ''];
const batchNames = names.filter(Boolean);
const hadExisting = !!_extractedText.trim();
const batchLen = Number(data.textLength) || batchText.length;
if (batchText) {
if (hadExisting) {
_extractedText = `${_extractedText.trimEnd()}\n\n---\n\n${batchText}`;
} else {
_extractedText = batchText;
}
batchNames.forEach((n) => _importFileNames.push(n));
_extractedFileName = _importFileNames.join(', ');
if (importDropzoneLabel) {
const n = _importFileNames.length;
importDropzoneLabel.textContent =
n <= 1
? `${_importFileNames[0] || files[0]?.name || 'файл'} · можно добавить ещё`
: `${n} файлов · можно добавить ещё`;
}
importDropzone?.classList.add('import-dropzone--done');
const totalLen = _extractedText.length;
if (hadExisting) {
showAiToast(`Добавлено · ${batchLen} симв. · всего ${totalLen}`, 'ok');
} else {
showAiToast(`Загружено · ${totalLen} символов`, 'ok');
}
} else if (hadExisting) {
showAiToast('Текст из этих файлов пуст — уже загруженное не меняли.', 'info', 4500);
} else {
_extractedFileName = '';
showAiToast('Текст из файлов не извлечён.', 'info', 4500);
}
} catch (e) {
showAiToast('', 'info');
openImportModal(
'Ошибка загрузки',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось загрузить файл.')}</p></div>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
} finally {
importDropzone?.classList.remove('import-dropzone--loading');
}
}
// Фаза 2: сгенерировать тест из извлечённого текста + подсказки
async function handleGenerateFromDoc() {
if (!_extractedText) return;
const userHint = docUserHint ? docUserHint.value.trim() : '';
docGenerateBtn.disabled = true;
docGenerateBtn.textContent = 'Генерируем…';
const nQDoc = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
showAiToast(
`Документ: шаг 1/3 · подготовка · ~${formatEtaSeconds(etaFullGenerateSeconds(nQDoc))}`,
'info',
0,
);
try {
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const globalRange = getTemplateCorrectRange(nO, globalMulti);
const shape = Array.from({ length: nQ }, () => ({
optionsCount: nO,
hasMultipleAnswers: globalMulti,
minCorrect: globalMulti ? globalRange.minCorrect : 1,
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
}));
showAiToast('Документ: шаг 2/3 · генерация вопросов…', 'info', 0);
const r = await fetch('/api/tests/generate-from-extracted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ extractedText: _extractedText, userHint, shape }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка генерации.');
const g = data.generation || {};
showAiToast('Документ: шаг 3/3 · готово к предпросмотру', 'ok', 3200);
if (!g.available) {
openImportModal(
'AI недоступен',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--warn">${escHtml(g.message || 'AI недоступен — ключ не настроен.')}</p></div>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
return;
}
const draft = g.draft || {};
const qs = draft.questions || [];
const qCards = qs.map((q, i) => buildImportPreviewQuestionHtml(q, i)).join('');
const bodyHtml = `
<div class="import-modal-review">
${draft.title ? `<p class="import-modal-review__draft-title">${escHtml(draft.title)}</p>` : ''}
${draft.description ? `<p class="import-modal-review__desc attempt-review-page__params">${escHtml(draft.description)}</p>` : ''}
<p class="import-modal-review__stats attempt-review-page__params">Вопросов в черновике: <strong>${qs.length}</strong></p>
${qs.length ? `<div class="import-modal-review__list">${qCards}</div>` : ''}
<div class="import-modal-review__warn" role="status">Текущие вопросы теста будут <strong>заменены</strong> после «Применить».</div>
</div>`;
openImportModal(
`Черновик из «${escHtml(_extractedFileName)}»`,
bodyHtml,
[
{
label: 'Применить',
primary: true,
onClick: () => {
importModal.close();
if (draft.title) { titleEl.value = draft.title; autoResize(titleEl); }
if (draft.description) { descEl.value = draft.description; autoResize(descEl); }
questionsEl.innerHTML = '';
qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
showAiToast(`Применено: ${qs.length} вопросов`, 'ok');
clearImportState();
},
},
{ label: 'Отмена', onClick: () => importModal.close() },
],
);
} catch (e) {
showAiToast(e.message || 'Ошибка генерации', 'err', 7000);
openImportModal(
'Ошибка генерации',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p></div>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
} finally {
if (docGenerateBtn) {
docGenerateBtn.disabled = false;
docGenerateBtn.innerHTML = '<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span> Сгенерировать из документа';
}
}
}
if (docGenerateBtn) docGenerateBtn.addEventListener('click', () => {
if (!_extractedText) {
// Файл ещё не выбран — открываем picker, генерация запустится после загрузки
const fileInput = $('#ai-import-file');
if (fileInput) {
const onchange = async (ev) => {
fileInput.removeEventListener('change', onchange);
const picked = ev.target.files ? Array.from(ev.target.files) : [];
ev.target.value = '';
await handleImportFiles(picked);
if (_extractedText) handleGenerateFromDoc();
};
fileInput.addEventListener('change', onchange);
fileInput.click();
}
} else {
handleGenerateFromDoc();
}
});
$('#ai-import-file').addEventListener('change', (ev) => {
const picked = ev.target.files ? Array.from(ev.target.files) : [];
ev.target.value = '';
handleImportFiles(picked);
});
// Drag-and-drop на зону загрузки
if (importDropzone) {
importDropzone.addEventListener('dragenter', (e) => {
e.preventDefault();
importDropzone.classList.add('import-dropzone--over');
});
importDropzone.addEventListener('dragover', (e) => {
e.preventDefault();
importDropzone.classList.add('import-dropzone--over');
});
importDropzone.addEventListener('dragleave', (e) => {
if (!importDropzone.contains(e.relatedTarget)) {
importDropzone.classList.remove('import-dropzone--over');
}
});
importDropzone.addEventListener('drop', (e) => {
e.preventDefault();
importDropzone.classList.remove('import-dropzone--over');
const allowed = ['.pdf', '.docx', '.txt', '.md'];
const dropped = Array.from(e.dataTransfer?.files || []);
const batch = dropped.filter((f) => {
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
return allowed.includes(ext);
}).slice(0, 5);
if (!batch.length) {
if (dropped.length) {
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
}
return;
}
handleImportFiles(batch);
});
}
// Drag-and-drop на всю страницу (когда перетаскивают извне браузера)
document.addEventListener('dragover', (e) => { e.preventDefault(); });
document.addEventListener('drop', (e) => {
if (importDropzone && importDropzone.contains(e.target)) return;
e.preventDefault();
const allowed = ['.pdf', '.docx', '.txt', '.md'];
const dropped = Array.from(e.dataTransfer?.files || []);
const batch = dropped.filter((f) => {
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
return allowed.includes(ext);
}).slice(0, 5);
if (!batch.length) {
if (dropped.length) {
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
}
return;
}
importDropzone?.classList.add('import-dropzone--over');
setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600);
handleImportFiles(batch);
});
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
function aiAlert(data, fallback) {
const msg = (data && data.error) || fallback || 'AI: ошибка.';
if (data && data.settingsUrl) {
if (confirm(msg + '\n\nОткрыть Настройки?')) {
window.location.href = data.settingsUrl;
}
return;
}
alert(msg);
}
const modal = $('#ai-modal');
const modalTitle = $('#ai-modal-title');
const modalBody = $('#ai-modal-body');
const modalActions = $('#ai-modal-actions');
$('#ai-modal-close').addEventListener('click', () => modal.close());
const saveModalEl = document.getElementById('save-modal');
const saveStayBtn = document.getElementById('save-modal-stay');
const saveGoBtn = document.getElementById('save-modal-go');
if (saveStayBtn) saveStayBtn.addEventListener('click', () => saveModalEl.close());
if (saveGoBtn) saveGoBtn.addEventListener('click', () => { window.location.href = '/tests'; });
function doCancel() {
if (isDirty()) {
if (!confirm('Есть несохранённые изменения. Уйти без сохранения?')) return;
}
window.location.href = '/tests';
}
const cancelBtn = document.getElementById('btn-cancel');
if (cancelBtn) cancelBtn.addEventListener('click', doCancel);
function openModal(title, bodyHtml, actions) {
modalTitle.textContent = title;
modalBody.innerHTML = bodyHtml;
modalActions.innerHTML = '';
(actions || []).forEach((a) => {
const b = document.createElement('button');
b.textContent = a.label;
b.className = a.className
|| 'px-3 py-2 rounded-lg bg-ink-100 hover:bg-ink-200 text-sm';
b.addEventListener('click', () => a.onClick(b));
modalActions.appendChild(b);
});
modal.showModal();
}
const aiGenerateByTitleBtn = $('#ai-generate-by-title');
if (aiGenerateByTitleBtn) aiGenerateByTitleBtn.addEventListener('click', async () => {
const title = titleEl.value.trim();
if (!title) {
alert('Сначала заполните название теста.');
titleEl.focus();
return;
}
const nQRaw = prompt('Сколько вопросов сгенерировать?', '10');
if (nQRaw == null) return;
const nQ = Math.max(3, Math.min(40, parseInt(nQRaw, 10) || 10));
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
if (nORaw == null) return;
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
showAiToast(
`Генерация по названию · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
'info',
0,
);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: title,
testDescription: descEl.value,
questionsCount: nQ,
optionsCount: nO,
}),
});
const data = await r.json();
if (!r.ok) {
showAiToast('', 'info');
return aiAlert(data);
}
const draft = data.draft;
showAiToast('Черновик готов — подтвердите в диалоге', 'ok');
const ok = confirm(
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
'Применить как черновик? Текущие вопросы будут заменены.',
);
if (!ok) {
showAiToast('', 'info');
return;
}
if (draft.title) titleEl.value = draft.title;
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
showAiToast(`Применено: ${draft.questions.length} вопросов`, 'ok');
} catch (e) {
showAiToast('', 'info');
aiAlert(null, e.message);
}
});
const aiCheckBtn = $('#ai-check');
if (aiCheckBtn) aiCheckBtn.addEventListener('click', async () => {
const payload = collectPayload();
if (!payload.questions.length) {
alert('В тесте нет вопросов — нечего проверять.');
return;
}
showAiToast('Проверка теста…', 'info', 0);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questions: payload.questions,
}),
});
const data = await r.json();
showAiToast('', 'info');
if (!r.ok) return aiAlert(data);
showAiToast('Проверка готова — см. окно', 'ok');
const rev = data.review || {};
const verdict = rev.verdict || 'warn';
const verdictMap = {
ok: ['Годен', 'bg-green-50 text-green-800 border-green-200'],
warn: ['Есть замечания', 'bg-yellow-50 text-yellow-800 border-yellow-200'],
bad: ['Серьёзные проблемы', 'bg-red-50 text-red-800 border-red-200'],
};
const [verdictText, verdictCls] = verdictMap[verdict] || verdictMap.warn;
let html = `<div class="rounded-lg border ${verdictCls} p-3 text-sm">
<div class="font-semibold">${verdictText}</div>
<div class="mt-1">${escHtml(rev.summary || '')}</div></div>`;
if (Array.isArray(rev.sections) && rev.sections.length) {
html += rev.sections.map((s) => `
<div class="mt-4">
<div class="font-semibold">${escHtml(s.title)}</div>
<ul class="mt-1 list-disc pl-5 text-sm space-y-1">
${s.items.map((it) => `<li>${escHtml(it)}</li>`).join('')}
</ul>
</div>`).join('');
} else {
html += '<p class="mt-4 text-sm text-ink-500">Замечаний нет.</p>';
}
openModal('Проверка теста', html, [
{ label: 'Закрыть', onClick: () => modal.close(),
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
]);
} catch (e) {
showAiToast('', 'info');
aiAlert(null, e.message);
}
});
const aiImproveBtn = $('#ai-improve');
if (aiImproveBtn) aiImproveBtn.addEventListener('click', async () => {
const payload = collectPayload();
if (!payload.questions.length) {
alert('В тесте нет вопросов — нечего улучшать.');
return;
}
showAiToast('Подготовка улучшений…', 'info', 0);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questions: payload.questions,
focus: (aiImproveFocusEl && aiImproveFocusEl.value) || 'all',
}),
});
const data = await r.json();
showAiToast('', 'info');
if (!r.ok) return aiAlert(data);
showAiToast('Выберите в окне, что применить', 'ok');
const items = data.items || [];
if (!items.length) {
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
{ label: 'Закрыть', onClick: () => modal.close() },
]);
return;
}
const changed = items.filter((i) => i.changed);
if (!changed.length) {
openModal('Улучшение теста', '<p>AI не предложил изменений.</p>', [
{ label: 'Закрыть', onClick: () => modal.close() },
]);
return;
}
let html = `<p class="text-sm text-ink-500 mb-3">
Отметьте вопросы, к которым нужно применить улучшения. ${changed.length} из ${items.length}.</p>`;
html += changed.map((it) => `
<div class="rounded-xl border border-ink-300/60 p-3 mb-3" data-idx="${it.index}">
<label class="inline-flex items-center gap-2 text-sm font-medium">
<input type="checkbox" class="apply-q rounded border-ink-300 text-brand-600 focus:ring-brand-500" checked />
<span>Вопрос #${it.index + 1}</span>
</label>
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
<div>
<div class="text-xs uppercase text-ink-500">Было</div>
<div class="mt-1 ${it.textChanged ? 'line-through text-ink-500' : ''}">
${escHtml(it.original.text)}
</div>
<ul class="mt-1 list-disc pl-5">
${it.original.options.map((o) =>
`<li class="${it.optionsChanged ? 'text-ink-500' : ''}">
${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
</ul>
</div>
<div>
<div class="text-xs uppercase text-brand-700">Стало</div>
<div class="mt-1 ${it.textChanged ? 'font-medium' : ''}">
${escHtml(it.suggested.text)}
</div>
<ul class="mt-1 list-disc pl-5">
${it.suggested.options.map((o) =>
`<li>${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}</li>`).join('')}
</ul>
</div>
</div>
</div>`).join('');
openModal('Улучшение теста', html, [
{ label: 'Отмена', onClick: () => modal.close() },
{
label: 'Применить выбранное',
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm',
onClick: () => {
const qs = $$('#questions .q-item');
modalBody.querySelectorAll('[data-idx]').forEach((row) => {
if (!$('.apply-q', row).checked) return;
const idx = parseInt(row.dataset.idx, 10);
const it = items.find((x) => x.index === idx);
if (!it || !qs[idx]) return;
const node = qs[idx];
$('.q-text', node).value = it.suggested.text;
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
const optsEl = $('.q-options', node);
optsEl.innerHTML = '';
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
});
$$('#questions .q-item').forEach((node) => {
if (!node.classList.contains('q-removed')) syncOptionInputTypes(node);
});
renumber();
modal.close();
scheduleDirtyCheck();
showAiToast('Изменения применены — сохраните тест', 'ok');
},
},
]);
} catch (e) {
showAiToast('', 'info');
aiAlert(null, e.message);
}
});
async function improveSingleOption(qNode, optIndex) {
const rows = $$('.opt-item', qNode);
const row = rows[optIndex];
if (!row) return;
const overlay = $('.opt-ai-overlay', row);
const ta = $('.opt-text', row);
const aiBtn = $('.opt-ai', row);
const setLocalBusy = (on) => {
if (overlay) overlay.classList.toggle('hidden', !on);
if (ta) ta.toggleAttribute('readonly', !!on);
if (aiBtn) aiBtn.disabled = !!on;
row.classList.toggle('opt-item--ai-busy', !!on);
};
setLocalBusy(true);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve-option`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questionText: $('.q-text', qNode).value,
optionIndex: optIndex,
options: rows.map((op) => ({
text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked,
})),
}),
});
const data = await r.json();
if (!r.ok) {
setLocalBusy(false);
return aiAlert(data);
}
const t = $('.opt-text', row);
t.value = (data.text != null ? data.text : t.value) || '';
autoResize(t);
scheduleDirtyCheck();
setLocalBusy(false);
} catch (e) {
setLocalBusy(false);
aiAlert(null, e.message);
}
}
/** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */
function shuffleQuestionOptionsDom(qNode) {
const optsEl = $('.q-options', qNode);
if (!optsEl) return;
const rows = Array.from(optsEl.querySelectorAll('.opt-item'));
if (rows.length < 2) return;
for (let i = rows.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[rows[i], rows[j]] = [rows[j], rows[i]];
}
rows.forEach((el) => optsEl.appendChild(el));
}
function pickQuestionAiMode(canDistractors) {
const dlg = document.getElementById('dlg-q-ai-mode');
const distBtn = document.getElementById('q-ai-mode-distractors');
const qBtn = document.getElementById('q-ai-mode-question');
const optBtn = document.getElementById('q-ai-mode-options');
const cancelBtn = document.getElementById('q-ai-mode-cancel');
if (!dlg || typeof dlg.showModal !== 'function') {
return Promise.resolve('rephrase');
}
if (distBtn) {
distBtn.disabled = !canDistractors;
distBtn.style.opacity = canDistractors ? '' : '0.45';
distBtn.title = canDistractors ? '' : 'Нет пустых полей для дистракторов';
}
return new Promise((resolve) => {
let settled = false;
const safeResolve = (val) => {
if (settled) return;
settled = true;
resolve(val);
};
const onClose = () => {
safeResolve(null);
};
dlg.addEventListener('close', onClose, { once: true });
const pick = (mode) => () => {
dlg.removeEventListener('close', onClose);
safeResolve(mode);
dlg.close();
};
distBtn?.addEventListener('click', pick('distractors'), { once: true });
qBtn?.addEventListener('click', pick('rephrase'), { once: true });
optBtn?.addEventListener('click', pick('improve_options'), { once: true });
cancelBtn?.addEventListener('click', pick(null), { once: true });
dlg.showModal();
});
}
async function aiGenerateQuestion(node) {
const qTextEl = $('.q-text', node);
const qText = qTextEl.value.trim();
const existingOpts = $$('.opt-item', node);
const optsCount = Math.max(2, existingOpts.length || 4);
const multi = $('.q-multi', node).checked;
const overlay = $('.q-ai-overlay', node);
try {
const existingOptions = existingOpts.map((op) => ({
text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked,
}));
const emptySlots = existingOptions.filter((o) => !o.text).length;
let requestMode;
if (!qText) {
requestMode = 'full';
} else {
const choice = await pickQuestionAiMode(emptySlots > 0);
if (!choice) return;
requestMode = choice;
}
overlay?.classList.remove('hidden');
node.style.pointerEvents = 'none';
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questionText: qText,
optionsCount: optsCount,
hasMultipleAnswers: multi,
mode: requestMode,
existingOptions: qText ? existingOptions : undefined,
}),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
if (data.mode === 'rephrase' || data.mode === 'full') {
qTextEl.value = data.text || qText;
autoResize(qTextEl);
}
const optsEl = $('.q-options', node);
if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) {
optsEl.innerHTML = '';
data.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
} else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) {
let dIdx = 0;
existingOpts.forEach((op) => {
const t = $('.opt-text', op);
if (!t.value.trim() && dIdx < data.options.length) {
t.value = data.options[dIdx].text || '';
autoResize(t);
dIdx++;
}
});
shuffleQuestionOptionsDom(node);
} else if (data.mode === 'improve_options' && Array.isArray(data.options) && data.options.length) {
const rows = $$('.opt-item', node);
data.options.forEach((o, i) => {
if (!rows[i]) return;
const ta = $('.opt-text', rows[i]);
ta.value = o.text != null ? o.text : ta.value;
$('.opt-correct', rows[i]).checked = !!o.isCorrect;
autoResize(ta);
});
}
syncOptionInputTypes(node);
updateOptionsCounter(node);
updateAiButtonLabel(node);
scheduleDirtyCheck();
} catch (e) {
const msg = e.message || 'AI: ошибка.';
showAiToast(msg, 'err', 7000);
alert(msg);
} finally {
overlay?.classList.add('hidden');
node.style.pointerEvents = '';
}
}
if (addQuestionAiBtn) {
addQuestionAiBtn.addEventListener('click', async () => {
const node = renderQuestion({
text: '',
hasMultipleAnswers: false,
options: [
{ text: '', isCorrect: true },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
],
});
questionsEl.appendChild(node);
renumber();
scheduleDirtyCheck();
try {
node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (_) {
/* ok */
}
showAiToast('Добавлен блок — запускаем ИИ для одного вопроса…', 'info');
await aiGenerateQuestion(node);
});
}
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
function updateChainActiveDisplay(active) {
chainActive = !!active;
chainActiveEl.checked = chainActive;
if (chainActiveDisplay) {
chainActiveDisplay.textContent = chainActive ? '✓ Активна в каталоге' : 'Скрыта из каталога';
chainActiveDisplay.style.color = chainActive ? '' : 'var(--ink-500, #6b7280)';
}
}
fetch(`/api/tests/${TEST_ID}/summary`)
.then((r) => r.json())
.then((data) => {
if (data && data.test && typeof data.test.chainActive === 'boolean') {
updateChainActiveDisplay(data.test.chainActive);
} else {
updateChainActiveDisplay(true);
}
})
.catch(() => { updateChainActiveDisplay(true); });
function renderVersions(rows) {
if (!versionsListEl) return;
versionsListEl.innerHTML = '';
if (!(rows || []).length) {
versionsListEl.innerHTML = '<li class="muted" style="font-size:.85rem;">Нет версий.</li>';
return;
}
(rows || []).forEach((r) => {
const li = document.createElement('li');
li.className = 'version-item';
li.dataset.versionId = r.id;
li.dataset.active = r.is_active ? '1' : '0';
li.innerHTML = `
<div class="version-item__main">
<span class="version-item__label">Версия ${r.version}</span>
<span class="version-item__date">${fmtDt(r.created_at)}</span>
</div>
<div class="version-item__actions">
${r.is_active
? '<span class="version-item__badge">активная</span>'
: `<button class="btn btn-ghost btn--sm version-item__activate" type="button"
data-version-id="${escHtml(r.id)}">Сделать активной</button>`}
</div>`;
versionsListEl.appendChild(li);
});
versionsListEl.querySelectorAll('.version-item__activate').forEach((btn) => {
btn.addEventListener('click', async () => {
const vid = btn.dataset.versionId;
btn.disabled = true;
btn.textContent = '…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/versions/${vid}/activate`, { method: 'POST' });
if (!r.ok) throw new Error('Не удалось активировать');
// обновить список
const v = await fetch(`/api/tests/${TEST_ID}/versions`).then((x) => x.json()).catch(() => null);
if (v && Array.isArray(v.versions)) renderVersions(v.versions);
} catch (e) {
btn.disabled = false;
btn.textContent = 'Сделать активной';
alert(e.message);
}
});
});
}
function renderAttempts(rows) {
if (!attemptsListEl) return;
attemptsListEl.innerHTML = '';
if (!(rows || []).length) {
attemptsListEl.innerHTML = '<li class="muted" style="padding:.5rem 0; font-size:.85rem;">Прохождений ещё нет.</li>';
return;
}
const statusLabel = {
completed: null, // handled by score
in_progress: 'Идёт',
expired: 'Истекло',
};
(rows || []).forEach((a) => {
const when = a.completedAt ? fmtDt(a.completedAt) : (a.startedAt ? fmtDt(a.startedAt) : '—');
let result;
if (a.status === 'completed' && a.totalQuestions != null) {
const verdict = a.passed ? '✓ Сдано' : '✗ Не сдано';
const score = `${a.correctCount} из ${a.totalQuestions}`;
result = `${verdict} · ${score}`;
} else {
result = statusLabel[a.status] || a.status;
}
const passedCls = a.status === 'completed'
? (a.passed ? 'color:#166534;' : 'color:#991b1b;')
: 'color:#6b7280;';
const li = document.createElement('li');
li.className = 'surface-card attempts-card-list__item';
li.innerHTML = `
<div class="attempts-card-list__row">
<div class="attempts-card-list__main">
<p class="muted" style="margin:0; font-size:.8rem;">${when}</p>
<p style="margin:.3rem 0 0; font-weight:600;">${escHtml(a.attempterName || a.attempterLogin || '—')}</p>
<p style="margin:.2rem 0 0; font-size:.85rem; ${passedCls}">${escHtml(result)}</p>
</div>
${a.status === 'completed'
? `<a class="btn btn-ghost btn--sm attempts-card-list__action" href="/tests/${TEST_ID}/attempts/${a.id}/review">Разбор</a>`
: `<span class="muted" style="font-size:.8rem;">${statusLabel[a.status] || ''}</span>`}
</div>`;
attemptsListEl.appendChild(li);
});
}
function renderAssignList() {
if (!assignListEl) return;
assignListEl.innerHTML = '';
assignPeople.forEach((p) => {
const row = document.createElement('label');
row.className = `assign-row${assignSelected.has(String(p.staffId)) ? ' assign-row--selected' : ''}`;
row.innerHTML = `
<input type="checkbox" ${assignSelected.has(String(p.staffId)) ? 'checked' : ''} />
<span class="assign-row__text">
<span class="assign-row__fio">${escHtml(p.fio || '—')}</span>
${p.webLogin ? `<span class="assign-row__login">${escHtml(p.webLogin)}</span>` : ''}
<span class="assign-row__meta">${escHtml(p.department || '—')}</span>
</span>`;
const cb = row.querySelector('input');
cb.addEventListener('change', () => {
const k = String(p.staffId);
if (cb.checked) assignSelected.add(k); else assignSelected.delete(k);
row.classList.toggle('assign-row--selected', cb.checked);
});
assignListEl.appendChild(row);
});
if (!assignPeople.length) assignListEl.innerHTML = '<p class="muted" style="padding:.75rem;">Никого не найдено.</p>';
}
async function loadDirectory() {
if (!assignListEl) return;
assignStatusEl.textContent = 'Загружаем…';
try {
const params = new URLSearchParams();
if (assignSearchEl.value.trim()) params.set('q', assignSearchEl.value.trim());
if (assignDeptEl.value && assignDeptEl.value !== '__all__') params.set('department', assignDeptEl.value);
params.set('clinic', assignClinicEl.value || 'all');
const r = await fetch(`/api/auth/dev/assignment-directory?${params.toString()}`);
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить сотрудников');
assignPeople = data.people || [];
const depts = data.departments || [];
if (assignDeptEl.options.length <= 1) {
depts.forEach((d) => {
const o = document.createElement('option');
o.value = d;
o.textContent = d;
assignDeptEl.appendChild(o);
});
}
assignSelected = new Set();
renderAssignList();
assignStatusEl.textContent = '';
} catch (e) {
assignStatusEl.textContent = e.message || 'Ошибка загрузки';
}
}
if (assignSearchEl) {
let t = null;
assignSearchEl.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(loadDirectory, 350);
});
assignDeptEl.addEventListener('change', loadDirectory);
assignClinicEl.addEventListener('change', loadDirectory);
assignSelectAllBtn.addEventListener('click', () => {
assignPeople.forEach((p) => assignSelected.add(String(p.staffId)));
renderAssignList();
});
assignSubmitBtn.addEventListener('click', async () => {
const selectedRows = assignPeople.filter((p) => assignSelected.has(String(p.staffId)));
const userIds = selectedRows.filter((p) => p.clinicUserId).map((p) => p.clinicUserId);
const staffIds = selectedRows.filter((p) => !p.clinicUserId && p.staffId != null).map((p) => p.staffId);
if (!userIds.length && !staffIds.length) return;
assignStatusEl.textContent = 'Назначаем…';
try {
const r = await fetch(`/api/tests/${TEST_ID}/assign`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds, staffIds }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка назначения');
assignStatusEl.textContent = `Назначено: ${data.count ?? selectedRows.length}`;
} catch (e) {
assignStatusEl.textContent = e.message || 'Ошибка назначения';
}
});
loadDirectory();
}
if (visibilityBtn) {
visibilityBtn.addEventListener('click', async () => {
const next = !chainActive;
try {
const r = await fetch(`/api/tests/${TEST_ID}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chainActive: next }),
});
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка изменения видимости');
updateChainActiveDisplay(next);
visibilityBtn.textContent = chainActive ? 'Скрыть из списка' : 'Снова показать в списке';
} catch (e) {
alert(e.message || 'Ошибка изменения видимости');
}
});
}
// ─── Автосоздание шаблона ──────────────────────────────────────
let templateRebuildTimer = null;
let lastAppliedTemplateKey = '';
function hasMeaningfulQuestions() {
return $$('#questions .q-item').some((node) => {
const qText = ($('.q-text', node)?.value || '').trim();
if (qText) return true;
return $$('.opt-text', node).some((o) => (o.value || '').trim());
});
}
function buildTemplateFromControls({ askConfirm } = { askConfirm: true }) {
const qCount = Math.min(30, Math.max(1, parseInt(aiQCountEl?.value || '7', 10)));
const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt(aiOCountEl?.value || '3', 10)));
const globalMulti = !!(templateGlobalMultiEl && templateGlobalMultiEl.checked);
const range = getTemplateCorrectRange(oCount, globalMulti);
const key = JSON.stringify({ qCount, oCount, globalMulti, min: range.minCorrect, max: range.maxCorrect });
if (key === lastAppliedTemplateKey) return;
if (askConfirm && hasMeaningfulQuestions()) {
const ok = confirm(
`Обновить шаблон: ${qCount} вопросов × ${oCount} вариантов?\n` +
'Текущие вопросы будут заменены.'
);
if (!ok) return;
}
questionsEl.innerHTML = '';
for (let qi = 0; qi < qCount; qi++) {
const opts = [];
for (let oi = 0; oi < oCount; oi++) {
opts.push({ text: '', isCorrect: oi < range.minCorrect });
}
questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: globalMulti, options: opts }));
}
renumber();
scheduleDirtyCheck();
lastAppliedTemplateKey = key;
}
function scheduleTemplateRebuild() {
if (templateRebuildTimer) clearTimeout(templateRebuildTimer);
templateRebuildTimer = setTimeout(() => buildTemplateFromControls({ askConfirm: true }), 200);
}
if (templateGlobalMultiEl) templateGlobalMultiEl.addEventListener('change', () => {
syncTemplateRangeUi();
scheduleTemplateRebuild();
});
if (templateMinCorrectEl) templateMinCorrectEl.addEventListener('change', () => {
syncTemplateRangeUi();
scheduleTemplateRebuild();
});
if (templateMaxCorrectEl) templateMaxCorrectEl.addEventListener('change', () => {
syncTemplateRangeUi();
scheduleTemplateRebuild();
});
if (aiOCountEl) aiOCountEl.addEventListener('change', () => {
syncTemplateRangeUi();
scheduleTemplateRebuild();
});
if (aiQCountEl) aiQCountEl.addEventListener('change', scheduleTemplateRebuild);
syncTemplateRangeUi();
Promise.all([
fetch(`/api/tests/${TEST_ID}/versions`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/attempts`).then((r) => r.json()).catch(() => null),
fetch(`/api/tests/${TEST_ID}/summary`).then((r) => r.json()).catch(() => null),
]).then(([v, a, s]) => {
if (v && Array.isArray(v.versions)) {
renderVersions(v.versions);
hasForkRisk = hasForkRisk || (v.versions.length > 1);
if (typeof v.hasAttempts === 'boolean') {
hasAnyAttempts = hasAnyAttempts || v.hasAttempts;
hasForkRisk = hasForkRisk || v.hasAttempts;
}
}
if (a && Array.isArray(a.attempts)) {
renderAttempts(a.attempts);
hasAnyAttempts = a.attempts.length > 0;
}
if (s && s.test) {
if (introUpdatedEl) introUpdatedEl.textContent = fmtDt(s.test.updated_at || s.test.updatedAt);
const versionEl = document.getElementById('intro-version');
if (versionEl && s.test.version != null) versionEl.textContent = s.test.version;
if (typeof s.test.hasAttempts === 'boolean') {
hasAnyAttempts = hasAnyAttempts || s.test.hasAttempts;
hasForkRisk = hasForkRisk || s.test.hasAttempts;
}
if (typeof s.test.versionCount === 'number') {
hasForkRisk = hasForkRisk || s.test.versionCount > 1;
}
}
updateForkBanner();
});
loadInitial();
resetBaselineDraft();
root.addEventListener('input', scheduleDirtyCheck);
root.addEventListener('change', scheduleDirtyCheck);
})();