/* Тест: вход (специалист / куратор), тест на 2 попытки, разбор ошибок, анализ по специалистам, управление вопросами. Данные — fetch к server.js. */ const $ = (s, r) => (r || document).querySelector(s); const $$ = (s, r) => [...(r || document).querySelectorAll(s)]; const esc = (v) => String(v == null ? "" : v).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); const S = { role: "spec", person: null, // { fio, dept } — специалист в сессии tryNo: 1, best: null, // лучший известный результат { percent, passed } questions: [], // без решений answers: {}, // qid -> index выбранного варианта showAll: false, curator: null, curatorView: "analysis", stat: null, lastResult: null }; /* ═══ API ═══════════════════════════════════════════════ */ async function api(path, opts) { const res = await fetch(path, Object.assign({ headers: { "Content-Type": "application/json" } }, opts || {})); const j = await res.json().catch(() => ({})); if (!res.ok) throw new Error(j.error || "Ошибка сервера (" + res.status + ")"); return j; } /* ═══ Тосты ═════════════════════════════════════════════ */ function toast(msg, tone) { const t = document.createElement("div"); t.className = "kt-ai-toast"; if (tone) t.dataset.tone = tone; t.innerHTML = esc(msg) + ''; t.querySelector(".close").onclick = () => t.remove(); $("#toasts").appendChild(t); setTimeout(() => t.remove(), 5000); } /* ═══ Модалка ═══════════════════════════════════════════ */ function openModal(title, bodyHtml, actions) { $("#modal-title").textContent = title; $("#modal-body").innerHTML = bodyHtml; const m = $("#modal-actions"); m.innerHTML = ""; (actions || [{ label: "Закрыть" }]).forEach((a) => { const b = document.createElement("button"); b.className = "kt-ai-btn"; if (a.primary) b.dataset.variant = "primary"; if (a.danger) b.dataset.variant = "danger"; b.textContent = a.label; b.onclick = () => a.onClick ? a.onClick() : closeModal(); m.appendChild(b); }); $("#modal-wrap").hidden = false; } function closeModal() { $("#modal-wrap").hidden = true; } $("#modal-wrap").addEventListener("click", (e) => { if (e.target.id === "modal-wrap") closeModal(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeModal(); closeDrawer(); } }); /* ═══ Drawer ════════════════════════════════════════════ */ function openDrawer(html) { $("#drawer-body").innerHTML = html; $("#drawer").hidden = false; $("#drawer-scrim").hidden = false; } function closeDrawer() { $("#drawer").hidden = true; $("#drawer-scrim").hidden = true; } $("#drawer-scrim").addEventListener("click", closeDrawer); /* ═══ Навигация между экранами ══════════════════════════ */ function show(id) { $$("#app > section").forEach((s) => (s.hidden = s.id !== id)); window.scrollTo(0, 0); } /* ═══ ВХОД: роли ════════════════════════════════════════ */ $$("#roleTabs .kt-ai-tab").forEach((tab) => { tab.onclick = () => { S.role = tab.dataset.role; $$("#roleTabs .kt-ai-tab").forEach((t) => (t.dataset.active = t === tab)); $("#form-spec").hidden = S.role !== "spec"; $("#form-curator").hidden = S.role !== "curator"; }; }); $("#form-spec").addEventListener("submit", async (e) => { e.preventDefault(); const fio = $("#spec-fio").value.trim(); const dept = $("#spec-dept").value.trim(); if (!fio || !dept) return; S.person = { fio, dept }; S.tryNo = 1; S.best = null; await startTest(); }); $("#form-curator").addEventListener("submit", async (e) => { e.preventDefault(); const fio = $("#cur-fio").value.trim(); const code = $("#cur-code").value; try { const r = await api("/api/curators/check", { method: "POST", body: JSON.stringify({ fio, code }) }); if (!r.ok) return toast("Код не подошёл. Проверьте код куратора.", "risk"); S.curator = { fio }; show("scr-curator"); await curatorEnter(); } catch (err) { toast(err.message, "risk"); } }); $("#spec-fio").value = localStorage.getItem("ck_fio") || ""; $("#spec-dept").value = localStorage.getItem("ck_dept") || ""; function logout() { S.person = null; S.curator = null; S.lastResult = null; show("scr-entry"); } $("#btn-logout").onclick = logout; $("#btn-cur-logout").onclick = logout; /* ═══ ТЕСТ ══════════════════════════════════════════════ */ async function startTest() { try { const r = await api("/api/questions"); if (!r.questions.length) { openModal("Вопросы ещё не добавлены", '

Куратор ещё не добавил ни одного вопроса. Возвратитесь позже — список пополняется ежемесячно.

'); return; } S.questions = r.questions; S.answers = {}; S.showAll = false; renderTest(); show("scr-test"); } catch (err) { toast(err.message, "risk"); } } function renderTest() { $("#test-fio").textContent = S.person.fio; $("#test-dept").textContent = S.person.dept; $("#test-chip-try").textContent = "Попытка " + S.tryNo + " из 2"; if (S.best) $("#test-score").textContent = "Лучший результат: " + S.best.percent + "%"; else $("#test-score").textContent = ""; const list = $("#test-list"); list.innerHTML = S.questions.map((q, i) => { const opts = q.options.map((o, j) => ` `).join(""); return `
  • ${i + 1}${esc(q.text)}
    ${opts}
  • `; }).join(""); $$("#test-list .opt input").forEach((inp) => { inp.addEventListener("change", () => { const qid = inp.name.slice(2); S.answers[qid] = parseInt(inp.value, 10); const label = inp.closest(".opt"); label.parentElement.querySelectorAll(".opt").forEach((o) => (o.setAttribute("aria-checked", o === label ? "true" : "false"))); updateTestFoot(); }); const label = inp.closest(".opt"); label.addEventListener("keydown", (e) => { if (e.key === " " || e.key === "Enter") { e.preventDefault(); inp.checked = true; inp.dispatchEvent(new Event("change")); } }); }); updateTestFoot(); } function updateTestFoot() { const total = S.questions.length; const done = Object.keys(S.answers).length; $("#test-count").textContent = "Отвечено " + done + " из " + total; const canSubmit = total > 0 && (done === total ? true : done >= total * 0.7); $("#btn-submit-test").disabled = !canSubmit; $("#btn-submit-test").textContent = "Пройти проверку"; } $("#btn-submit-test").onclick = async () => { const unanswered = S.questions.filter((q) => !(q.id in S.answers)).length; if (unanswered > 0) { openModal("Не все вопросы отвечены", '

    Свободных осталось: ' + unanswered + '. Без ответа — за ошибку. Продолжить?

    ', [ { label: "К вопросам", onClick: () => {} }, { label: "Отправить", primary: true, onClick: () => { closeModal(); submitTest(); } } ]); return; } submitTest(); }; async function submitTest() { const btn = $("#btn-submit-test"); btn.dataset.loading = "true"; btn.disabled = true; try { const r = await api("/api/attempts", { method: "POST", body: JSON.stringify({ fio: S.person.fio, dept: S.person.dept, tryNo: S.tryNo, choices: S.answers })}); // загрузить полный разбор для экрана результата const pd = await api("/api/person/" + encodeURIComponent(S.person.fio + "‖" + S.person.dept)); const attempts = pd.attempts; const mine = attempts.find((a) => a.id === r.id) || attempts.find((a) => a.tryNo === S.tryNo) || attempts[0]; S.lastResult = { percent: r.percent, passed: r.passed, tryNo: r.tryNo, correct: mine.correct, total: mine.total, wrong: mine.wrong || [] }; S.best = r.percent > (S.best ? S.best.percent : -1) ? { percent: r.percent, passed: r.passed, try: r.tryNo } : S.best; renderResult(); show("scr-result"); } catch (err) { toast(err.message, "risk"); } finally { btn.dataset.loading = "false"; btn.disabled = false; } } /* ═══ РЕЗУЛЬТАТ + РАЗБОР ════════════════════════════════ */ function renderResult() { const r = S.lastResult; if (!r) return; $("#res-title").textContent = r.passed ? "Проверка пройдена" : "Проверка завершена"; $("#res-sub").innerHTML = S.person.fio + " · " + S.person.dept + " · попытка " + r.tryNo + " из 2" + (r.passed ? "" : " — порог 70%, есть вторая попытка"); $("#res-score").textContent = r.percent + "%"; const pill = $("#res-pill"); pill.textContent = r.passed ? "Сдано" : "Не сдано"; pill.dataset.status = r.passed ? "ok" : "risk"; const wrongs = r.wrong || []; $("#res-wrong-count").textContent = wrongs.length ? wrongs.length + (wrongCountWord(wrongs.length)) + " — нажмите на вопрос, чтобы посмотреть объяснение" : "Ошибок нет. Отлично!"; $("#btn-show-all").textContent = r.showAll ? "Только ошибки" : "Показать все вопросы"; renderResList(); const btn = $("#btn-retry"); const canRetry = !r.passed && r.tryNo === 1; btn.hidden = !canRetry; } function wrongCountWord(n) { const m10 = n % 10, m100 = n % 100; if (m10 === 1 && m100 !== 11) return " ошибка"; if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return " ошибки"; return " ошибок"; } function renderResList() { const r = S.lastResult; const full = S.questions; const wrongById = {}; (r.wrong || []).forEach((w) => (wrongById[w.qid] = w)); // Для пройденных вопросов правильный вариант — тот, что пользователь выбрал (не ошибся) const chosenCorrect = {}; full.forEach((q) => { const v = S.answers[q.id]; if (typeof v === "number" && !wrongById[q.id]) chosenCorrect[q.id] = v; }); const list = $("#res-list"); const items = r.showAll ? full : full.filter((q) => wrongById[q.id]); if (!items.length) { list.innerHTML = '
    Здесь пусто
    Не на что обратить внимание.
    '; return; } list.innerHTML = items.map((q, i) => { const w = wrongById[q.id] || null; const opts = q.options.map((o, j) => resOptHtml(q, j, w, chosenCorrect[q.id])).join(""); return `
    ${i + 1} ${esc(q.text)} ${w ? ' ' + esc(q.category) + ' ' : ""}
    ${opts}
    `; }).join(""); $$("#res-list .resq [exp-btn]").forEach((b) => { b.onclick = () => { const qid = b.closest(".resq").dataset.qid; const w = wrongById[qid]; const q = full.find((x) => x.id === qid); showExplanation(q, w); }; }); } function resOptHtml(q, j, w, chosenCorrect) { // w != null → ошибка: w.correct — верный вариант, w.chosen — выбор пользователя // w == null → вопрос пройден: правильный = выбранный пользователем (chosenCorrect) let cls = ""; let mark = ""; if (w) { if (j === w.correct) { cls = "is-correct"; mark = "✓"; } else if (j === w.chosen) { cls = "is-wrong"; mark = "✕"; } } else if (chosenCorrect == j) { cls = "is-correct"; mark = "✓"; } const tag = cls ? `${mark}` : ""; return `
    ${tag}${esc(q.options[j])}
    `; } function showExplanation(q, w) { const you = w; const yourRow = you ? `
    ✕
    Ваш ответ: ${esc(q.options[you.chosen])}
    ` : ""; const rightRow = `
    ✓
    Правильный ответ: ${esc(q.options[w ? w.correct : 0])}
    `; openModal("Разбор вопроса", `
    ${esc(q.text)}
    ${yourRow} ${rightRow} ${w && w.explanation ? `
    ${esc(w.explanation)}
    ` : '
    Объяснение по этому вопросу ещё не заполнено куратором.
    '}
    `, [{ label: "Закрыть", primary: true }]); } $("#btn-show-all").onclick = () => { S.showAll = !S.showAll; renderResList(); $("#btn-show-all").textContent = S.showAll ? "Только ошибки" : "Показать все вопросы"; }; $("#btn-retry").onclick = async () => { S.tryNo = 2; await startTest(); }; /* ═══ КУРАТОР ═══════════════════════════════════════════ */ $("[data-cur-view]") && $$("[data-cur-view]").forEach((a) => { a.onclick = async (e) => { e.preventDefault(); S.curatorView = a.dataset.curView; await renderCurator(); }; }); $("#btn-cur-logout").onclick = logout; async function curatorEnter() { document.querySelector('[data-cur-view]').dispatchEvent(new Event("click")); } async function renderCurator() { const titles = { analysis: "Анализ", persons: "Специалисты", questions: "Вопросы" }; $("#cur-title").textContent = titles[S.curatorView] || ""; $("#cur-sub").textContent = ""; $$("#scr-curator .kt-ai-nav-item[data-cur-view]").forEach((a) => (a.classList.toggle("is-active", a.dataset.curView === S.curatorView))); const view = $("#cur-view"); view.innerHTML = '
    Загрузка…
    '; try { if (S.curatorView === "analysis") { S.stat = await api("/api/stat"); $("#nav-count-q").textContent = S.stat.questionCount; $("#cur-sub").textContent = S.stat.persons && (S.stat.people ? "· " + S.stat.people.length + " специалист(ов)" : ""); view.innerHTML = renderAnalysis(); } else if (S.curatorView === "persons") { S.stat = await api("/api/stat"); view.innerHTML = renderPersons(); } else if (S.curatorView === "questions") { const r = await api("/api/admin/questions"); S.stat = { questionCount: r.questions.length, questions: r.questions, people: [] }; $("#nav-count-q").textContent = r.questions.length; view.innerHTML = renderQuestions(r.questions); } } catch (err) { view.innerHTML = '
    Не загрузилось
    ' + esc(err.message) + "
    "; } } /* ── Анализ ───────────────────────────────────────────── */ function renderAnalysis() { const st = S.stat; if (!st || !st.people.length) { return '
    Пока нет данных
    Столько, сколько специалистов пройдёт проверку — появятся здесь. Вопросы на '" + st.questionCount + "'.
    "; } const avg = Math.round(st.people.reduce((s, p) => s + p.best, 0) / st.people.length); const passedPeople = st.people.filter((p) => p.best >= 70).length; const notPassed = st.people.length - passedPeople; const cats = Object.entries(st.byCategory || {}); const catHtml = cats.length ? '
    Ошибки по темам
    ' + cats.map(([c, v]) => `
    ${esc(c)}${v.misses}ошибок
    `).join("") + "
    " : ""; return `
    ${st.people.length}Специалистов
    ${avg}%Средний лучший результат
    ${notPassed}Ниже порога 70%
    ${st.questionCount}Вопросов в базе

    По специалистам

    ${st.people.map((p) => ` `).join("")}
    Специалист Лучший Последний Попытки Статус
    ${esc(p.fio)} ${esc(p.dept)}
    ${p.best >= 0 ? p.best + "%" : "—"} ${p.lastPct}% · п.${p.lastTry} ${p.count} ${p.best >= 70 ? "Сдан" : "Ниже 70%"}
    ${catHtml}`; } $("#cur-view").addEventListener("click", (e) => { const tr = e.target.closest("tr[data-p]"); if (tr) openPersonDrawer(tr.dataset.p, tr.dataset.d); }); async function openPersonDrawer(fio, dept) { const d = await api("/api/person/" + encodeURIComponent(fio + "‖" + dept)); const atts = d.attempts || []; const best = Math.max(0, ...atts.map((a) => a.percent)); const cats = {}; atts.forEach((a) => (a.wrong || []).forEach((w) => { const q = d.questions.find((x) => x.id === w.qid); if (q) cats[q.category] = (cats[q.category] || 0) + 1; })); openDrawer(`
    ${esc(fio)}
    ${esc(dept)}
    ${best}%лучший
    ${atts.map((a) => a.tryNo === 2 ? 2 : 1).includes(2) ? 2 : (atts.length ? 1 : 0)}попыток из 2
    ${best >= 70 ? "Сдан" : "Ниже 70%"}итог
    Попытки
    ${atts.map((a) => `
    ${a.passed ? "Сдано" : "Провалено"} ${a.percent}% ${fmtDate(a.date)} · п.${a.tryNo}
    `).join("") || '
    Нет попыток
    '} ${Object.keys(cats).length ? `
    Слабые темы (количество ошибок)
    ${Object.entries(cats).map(([c, n]) => `
    ${esc(c)}${n}ошибок
    `).join("")}
    ` : ""}
    `); } window.closeDrawerUi = closeDrawer; /* ── Специалисты ──────────────────────────────────────── */ function renderPersons() { const st = S.stat; if (!st.people.length) return '
    Пока никто не проверялся
    Как специалисты пройдут тест — они появятся в этом списке.
    '; return `

    Список специалистов

    ${st.people.map((p) => `
    ${esc(p.fio)}
    ${esc(p.dept)}
    ${p.best >= 0 ? p.best + "%" : "—"}
    ${p.count} попыток
    ${p.lastDate ? fmtDate(p.lastDate) : "не было"}
    ${p.best >= 70 ? "Сдан" : "Ниже 70%"}
    `).join("")}
    `; } $("#scr-curator").addEventListener("click", (e) => { const row = e.target.closest(".person-row"); if (row) openPersonDrawer(row.dataset.p, row.dataset.d); }); /* ── Вопросы ──────────────────────────────────────────── */ function renderQuestions(questions) { if (!questions.length) return `

    Банк вопросов

    Вопросов пока нет
    Добавьте первый — кнопка «Вопрос» слева.
    `; const byCat = {}; questions.forEach((q) => (byCat[q.category] = (byCat[q.category] || 0) + 1)); return `
    ${questions.length}Вопросов
    ${Object.keys(byCat).length}Тем

    Банк вопросов

    ${Object.entries(byCat).map(([c, arr]) => { const qs = questions.filter((q) => q.category === c); return `
    ${esc(c)} ${arr} ${arr === 1 ? "вопрос" : "вопросов"}
    ${qs.map(qHtml).join("")}
    `; }).join("")}`; } function qHtml(q) { return `
    ${esc(q.text)}
    ${q.options.map((o, i) => `${i === q.correct ? "✓ " : "· "}${esc(o)}
    `).join("")}
    ${q.explanation ? `
    ${esc(q.explanation)}
    ` : ""}
    `; } $("#btn-new-q").onclick = () => qForm(null); window.editQ = (id) => { const q = (S.stat && Array.isArray(S.stat.questions) ? S.stat.questions : []).find((x) => x.id === id); if (q) qForm(q); }; window.delQ = (id) => { openModal("Удалить вопрос", '

    Вопрос будет удалён из банка. Результаты прошедших проверок не изменятся.

    ', [ { label: "Отмена" }, { label: "Удалить", danger: true, onClick: async () => { closeModal(); await api("/api/admin/questions/" + id, { method: "DELETE" }); toast("Вопрос удалён", "ok"); renderCurator(); } } ]); }; function qForm(q) { const editing = !!q; const cats = ["Тарифы", "Услуги", "Правила", "Контракты", "Прочее"]; const curCats = (S.stat.questions || []).map((x) => x.category).filter((c) => !cats.includes(c)); const allCats = [...cats, ...curCats]; openDrawer(`
    ${editing ? "Редактировать вопрос" : "Новый вопрос"}
    ${(q ? q.options : ["", ""]).map((o, i) => optRowHtml(esc(o), i === (q ? q.correct : 0))).join("")}
    `); $("#qf-save").onclick = async () => { const cat = $("#qf-cat").value; const text = $("#qf-text").value.trim(); const opts = $$("#qf-opts .q-opt-row input[type=text]").map((i) => i.value.trim()); const correct = Math.max(0, $$("#qf-opts input[type=radio]").findIndex((r) => r.checked)); const explanation = $("#qf-exp").value.trim(); if (!text || opts.filter(Boolean).length < 2) { toast("Нужны вопрос и минимум 2 варианта", "risk"); return; } const payload = { category: cat, text, options: opts, correct, explanation }; try { if (editing) await api("/api/admin/questions/" + q.id, { method: "POST", body: JSON.stringify(payload) }); else await api("/api/admin/questions", { method: "POST", body: JSON.stringify(payload) }); } catch (err) { toast(err.message, "risk"); return; } closeDrawer(); toast(editing ? "Вопрос обновлён" : "Вопрос добавлен", "ok"); renderCurator(); }; } function optRowHtml(text, checked) { return `
    `; } window.qAddOpt = () => { $("#qf-opts").insertAdjacentHTML("beforeend", optRowHtml("", false)); }; /* ═══ Утилиты ═══════════════════════════════════════════ */ function fmtDate(iso) { if (!iso) return "—"; const d = new Date(iso); return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric" }) + " " + d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }); } /* ═══ Старт ═════════════════════════════════════════════ */ show("scr-entry");