/* Тест: вход (специалист / куратор), тест на 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) => `
|
${esc(p.fio)}
${esc(p.dept)}
|
${p.best >= 0 ? p.best + "%" : "—"} |
${p.lastPct}% · п.${p.lastTry} |
${p.count} |
${p.best >= 70 ? "Сдан" : "Ниже 70%"} |
`).join("")}
${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(`
${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");