/* Редактор теста: рабочий минимум. * Работает с эндпоинтами /api/tests//{draft, ai/generate-test, ai/generate-question} * и /api/tests/ (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 aiStatusEl = $('#ai-status'); 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 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 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(); }); 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 = 'Вопрос будет удалён при сохранении' + ''; 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 loadInitial() { titleEl.value = initial.test.title || ''; descEl.value = initial.test.description || ''; autoResize(titleEl); autoResize(descEl); if (thresholdEl) { thresholdEl.addEventListener('input', syncThresholdMirror); thresholdEl.addEventListener('change', syncThresholdMirror); } 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); syncThresholdMirror(); 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); } 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; scheduleDirtyCheck(); }); }); if (hintsEl) { hintsEl.checked = !!initial.test.hintsEnabled; hintsEl.addEventListener('change', 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, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } // ─── 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) => ({ optionsCount: Math.max(2, $$('.opt-item', li).length || 4), hasMultipleAnswers: $('.q-multi', li).checked, })); } // ─── 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; } 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) { saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`; const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' }); const gd = await gr.json().catch(() => ({})); if (!gr.ok) { saveStatusEl.textContent = ''; alert(gd.error || 'Не удалось сгенерировать подсказки.'); if (saveMsg) saveMsg.textContent = msg + ' (часть подсказок не создана)'; if (saveModal) saveModal.showModal(); return; } const tail = gd.failed ? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось.` : ` Подсказки созданы (${gd.generated}).`; if (saveMsg) saveMsg.textContent = msg + tail; } 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 hasContent = questionsEl.children.length > 0 || titleEl.value.trim() || descEl.value.trim(); if (hasContent) { const ok = confirm( 'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?' ); 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 shape = Array.from({ length: nQ }, () => ({ optionsCount: nO, hasMultipleAnswers: false, })); aiStatusEl.textContent = 'Генерируем…'; 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) { titleEl.value = draft.title; if (aiTopicEl) aiTopicEl.value = draft.title; } if (draft.description) descEl.value = draft.description; questionsEl.innerHTML = ''; (draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q))); renumber(); scheduleDirtyCheck(); aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`; setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { aiStatusEl.textContent = ''; alert(e.message || 'AI: ошибка.'); } }); // ─── импорт документа с 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 = ''; 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(); } // Фаза 1: выбрать файл → извлечь текст, обновить метку дропзоны async function handleImportFile(file) { if (!file) return; aiStatusEl.textContent = `Загружаем «${file.name}»…`; importDropzone.classList.add('import-dropzone--loading'); try { const fd = new FormData(); fd.append('file', file); const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd }); const data = await r.json(); if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.'); _extractedText = data.extractedText || ''; _extractedFileName = file.name; aiStatusEl.textContent = `Файл загружен: «${file.name}» · ${data.textLength ?? 0} символов`; if (importDropzoneLabel) importDropzoneLabel.textContent = `✓ ${file.name}`; importDropzone.classList.add('import-dropzone--done'); } catch (e) { aiStatusEl.textContent = ''; openImportModal( 'Ошибка загрузки', `

${escHtml(e.message || 'Не удалось загрузить файл.')}

`, [{ 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 = 'Генерируем…'; aiStatusEl.textContent = 'Генерируем тест из документа…'; try { const r = await fetch('/api/tests/generate-from-extracted', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ extractedText: _extractedText, userHint }), }); const data = await r.json(); if (!r.ok) throw new Error(data.error || 'Ошибка генерации.'); const g = data.generation || {}; aiStatusEl.textContent = ''; if (!g.available) { openImportModal( 'AI недоступен', `

${escHtml(g.message || 'AI недоступен — ключ не настроен.')}

`, [{ label: 'Закрыть', onClick: () => importModal.close() }], ); return; } const draft = g.draft || {}; const qs = draft.questions || []; const qPreview = qs.slice(0, 4).map((q, i) => `
  • ${i + 1}. ${escHtml((q.text || '').slice(0, 80))}${(q.text || '').length > 80 ? '…' : ''}
  • ` ).join(''); const moreCount = qs.length > 4 ? qs.length - 4 : 0; const bodyHtml = ` ${draft.title ? `

    ${escHtml(draft.title)}

    ` : ''} ${draft.description ? `

    ${escHtml(draft.description)}

    ` : ''}

    Вопросов: ${qs.length}

    ${qs.length ? ` ${moreCount ? `

    …и ещё ${moreCount}

    ` : ''}` : ''}

    Текущие вопросы теста будут заменены.

    `; 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(); aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`; setTimeout(() => (aiStatusEl.textContent = ''), 4000); // Сброс зоны загрузки _extractedText = ''; _extractedFileName = ''; if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файл сюда или нажмите'; importDropzone.classList.remove('import-dropzone--done'); if (docUserHint) docUserHint.value = ''; aiStatusEl.textContent = ''; }, }, { label: 'Отмена', onClick: () => importModal.close() }, ], ); } catch (e) { aiStatusEl.textContent = ''; openImportModal( 'Ошибка генерации', `

    ${escHtml(e.message || 'Не удалось сгенерировать тест.')}

    `, [{ label: 'Закрыть', onClick: () => importModal.close() }], ); } finally { if (docGenerateBtn) { docGenerateBtn.disabled = false; docGenerateBtn.innerHTML = 'auto_awesome Сгенерировать из документа'; } } } if (docGenerateBtn) docGenerateBtn.addEventListener('click', () => { if (!_extractedText) { // Файл ещё не выбран — открываем picker, генерация запустится после загрузки const fileInput = $('#ai-import-file'); if (fileInput) { const onchange = async (ev) => { fileInput.removeEventListener('change', onchange); const f = ev.target.files && ev.target.files[0]; ev.target.value = ''; await handleImportFile(f); if (_extractedText) handleGenerateFromDoc(); }; fileInput.addEventListener('change', onchange); fileInput.click(); } } else { handleGenerateFromDoc(); } }); $('#ai-import-file').addEventListener('change', (ev) => { const file = ev.target.files && ev.target.files[0]; ev.target.value = ''; handleImportFile(file); }); // 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 file = e.dataTransfer?.files?.[0]; if (!file) return; const allowed = ['.pdf', '.docx', '.txt', '.md']; const ext = ('.' + file.name.split('.').pop()).toLowerCase(); if (!allowed.includes(ext)) { aiStatusEl.textContent = `Формат «${ext}» не поддерживается.`; setTimeout(() => (aiStatusEl.textContent = ''), 3000); return; } handleImportFile(file); }); } // Drag-and-drop на всю страницу (когда перетаскивают извне браузера) document.addEventListener('dragover', (e) => { e.preventDefault(); }); document.addEventListener('drop', (e) => { if (importDropzone && importDropzone.contains(e.target)) return; // уже обработано e.preventDefault(); const file = e.dataTransfer?.files?.[0]; if (!file) return; const allowed = ['.pdf', '.docx', '.txt', '.md']; const ext = ('.' + file.name.split('.').pop()).toLowerCase(); if (!allowed.includes(ext)) return; // Подсвечиваем зону и обрабатываем importDropzone?.classList.add('import-dropzone--over'); setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600); handleImportFile(file); }); // ─── 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); const cancelBtnInline = document.getElementById('btn-cancel-inline'); if (cancelBtnInline) cancelBtnInline.addEventListener('click', doCancel); // Кнопка «Сохранить» под вопросами — дублирует основную const saveDraftInlineBtn = document.getElementById('save-draft-inline'); const saveStatusInlineEl = document.getElementById('save-status-inline'); if (saveDraftInlineBtn) { saveDraftInlineBtn.addEventListener('click', () => { document.getElementById('save-draft')?.click(); }); } 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)); aiStatusEl.textContent = 'Генерируем по названию…'; 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) { aiStatusEl.textContent = ''; return aiAlert(data); } const draft = data.draft; const ok = confirm( `Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` + 'Применить как черновик? Текущие вопросы будут заменены.', ); if (!ok) { aiStatusEl.textContent = ''; 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(); aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`; setTimeout(() => (aiStatusEl.textContent = ''), 4000); } catch (e) { aiStatusEl.textContent = ''; aiAlert(null, e.message); } }); const aiCheckBtn = $('#ai-check'); if (aiCheckBtn) aiCheckBtn.addEventListener('click', async () => { const payload = collectPayload(); if (!payload.questions.length) { alert('В тесте нет вопросов — нечего проверять.'); return; } aiStatusEl.textContent = 'Анализируем…'; 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(); aiStatusEl.textContent = ''; if (!r.ok) return aiAlert(data); 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 = `
    ${verdictText}
    ${escHtml(rev.summary || '')}
    `; if (Array.isArray(rev.sections) && rev.sections.length) { html += rev.sections.map((s) => `
    ${escHtml(s.title)}
      ${s.items.map((it) => `
    • ${escHtml(it)}
    • `).join('')}
    `).join(''); } else { html += '

    Замечаний нет.

    '; } 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) { aiStatusEl.textContent = ''; aiAlert(null, e.message); } }); const aiImproveBtn = $('#ai-improve'); if (aiImproveBtn) aiImproveBtn.addEventListener('click', async () => { const payload = collectPayload(); if (!payload.questions.length) { alert('В тесте нет вопросов — нечего улучшать.'); return; } aiStatusEl.textContent = 'Улучшаем…'; 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, }), }); const data = await r.json(); aiStatusEl.textContent = ''; if (!r.ok) return aiAlert(data); const items = data.items || []; if (!items.length) { openModal('Улучшение теста', '

    Нечего улучшать.

    ', [ { label: 'Закрыть', onClick: () => modal.close() }, ]); return; } const changed = items.filter((i) => i.changed); if (!changed.length) { openModal('Улучшение теста', '

    AI не предложил изменений.

    ', [ { label: 'Закрыть', onClick: () => modal.close() }, ]); return; } let html = `

    Отметьте вопросы, к которым нужно применить улучшения. ${changed.length} из ${items.length}.

    `; html += changed.map((it) => `
    Было
    ${escHtml(it.original.text)}
      ${it.original.options.map((o) => `
    • ${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}
    • `).join('')}
    Стало
    ${escHtml(it.suggested.text)}
      ${it.suggested.options.map((o) => `
    • ${o.isCorrect ? '✓ ' : ''}${escHtml(o.text)}
    • `).join('')}
    `).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))); }); modal.close(); scheduleDirtyCheck(); aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.'; setTimeout(() => (aiStatusEl.textContent = ''), 5000); }, }, ]); } catch (e) { aiStatusEl.textContent = ''; aiAlert(null, e.message); } }); 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); // Показываем оверлей overlay?.classList.remove('hidden'); node.style.pointerEvents = 'none'; 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; const filledSlots = existingOptions.filter((o) => o.text).length; // Выбираем режим: // - нет текста вопроса → full // - есть вопрос + есть пустые варианты (и хоть один заполнен) → distractors // - есть вопрос, все варианты заполнены или вариантов нет → rephrase let requestMode; if (!qText) { requestMode = 'full'; } else if (emptySlots > 0 && filledSlots > 0) { requestMode = 'distractors'; } else { requestMode = 'rephrase'; } 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 !== 'distractors') { 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++; } }); } syncOptionInputTypes(node); updateOptionsCounter(node); updateAiButtonLabel(node); scheduleDirtyCheck(); } catch (e) { aiStatusEl.textContent = ''; alert(e.message || 'AI: ошибка.'); } finally { overlay?.classList.add('hidden'); node.style.pointerEvents = ''; } } // ─── 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 = '
  • Нет версий.
  • '; 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 = ` Версия ${r.version} ${r.is_active ? 'активная' : ''} ${fmtDt(r.created_at)} ${!r.is_active ? `` : ''}`; 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 = '
  • Прохождений ещё нет.
  • '; 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 = `

    ${when}

    ${escHtml(a.attempterName || a.attempterLogin || '—')}

    ${escHtml(result)}

    ${a.status === 'completed' ? `Разбор` : `${statusLabel[a.status] || ''}`}
    `; 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 = ` ${escHtml(p.fio || '—')} ${p.webLogin ? `` : ''} ${escHtml(p.department || '—')} `; 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 = '

    Никого не найдено.

    '; } 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 || 'Ошибка изменения видимости'); } }); } // ─── Создать шаблон ──────────────────────────────────────────── const createTemplateBtn = $('#create-template'); if (createTemplateBtn) { createTemplateBtn.addEventListener('click', () => { const qCount = Math.min(30, Math.max(1, parseInt($('#ai-q-count').value || '7', 10))); const oCount = Math.min(MAX_OPTIONS, Math.max(2, parseInt($('#ai-o-count').value || '3', 10))); const existing = $$('#questions .q-item').length; if (existing > 0) { 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 === 0 }); } questionsEl.appendChild(renderQuestion({ text: '', hasMultipleAnswers: false, options: opts })); } renumber(); scheduleDirtyCheck(); // Прокручиваем к первому вопросу questionsEl.firstElementChild?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); } 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); })();