neobhodimo-sozdat-onlayn-say/app.js

684 lines
32 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.

/* Тест: вход (специалист / куратор), тест на 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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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) + '<button class="close" aria-label="Закрыть">×</button>';
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("Вопросы ещё не добавлены",
'<p style="font-size:var(--kt-ai-text-sm);color:var(--kt-ai-fg-muted)">Куратор ещё не добавил ни одного вопроса. Возвратитесь позже — список пополняется ежемесячно.</p>');
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) => `
<label class="opt" data-q="${esc(q.id)}" data-opt="${j}" tabindex="0" role="radio" aria-checked="${S.answers[q.id] === j}">
<input type="radio" name="q_${esc(q.id)}" value="${j}" ${S.answers[q.id] === j ? "checked" : ""}>
<span>${esc(o)}</span>
</label>`).join("");
return `
<li class="tq" id="tq-${esc(q.id)}">
<div class="tq-head"><span class="tq-num">${i + 1}</span><span class="tq-q">${esc(q.text)}</span></div>
<div class="tq-opts">${opts}</div>
</li>`;
}).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("Не все вопросы отвечены",
'<p style="font-size:var(--kt-ai-text-sm);color:var(--kt-ai-fg-muted)">Свободных осталось: ' + unanswered + '. Без ответа — за ошибку. Продолжить?</p>',
[
{ 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 = '<div class="kt-ai-empty"><div class="icon-spot"><svg class="kt-icon"><use href="#check-circle"></use></svg></div><div class="title">Здесь пусто</div>Не на что обратить внимание.</div>';
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 `
<article class="resq" data-wrong="${w ? "true" : "false"}" data-qid="${esc(q.id)}">
<div class="resq-head">
<span class="tq-num">${i + 1}</span>
<span class="resq-q">${esc(q.text)}</span>
${w ? '
<span class="resq-cat">' + esc(q.category) + '</span>
<button class="kt-ai-btn" data-size="sm" exp-btn>' + (w.explanation ? "Почему так?" : "Верный ответ") + '</button>'
: ""}
</div>
<div class="resq-opts">${opts}</div>
</article>`;
}).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 ? `<span class="mark">${mark}</span>` : "";
return `<div class="resq-opt ${cls}">${tag}<span>${esc(q.options[j])}</span></div>`;
}
function showExplanation(q, w) {
const you = w;
const yourRow = you
? `<div class="xp-row you"><span class="m">✕</span><div>Ваш ответ: <strong>${esc(q.options[you.chosen])}</strong></div></div>`
: "";
const rightRow = `<div class="xp-row right"><span class="m">✓</span><div>Правильный ответ: <strong>${esc(q.options[w ? w.correct : 0])}</strong></div></div>`;
openModal("Разбор вопроса", `
<div class="xp">
<div style="font-size:var(--kt-ai-text-sm);font-weight:var(--kt-ai-weight-medium)">${esc(q.text)}</div>
${yourRow}
${rightRow}
${w && w.explanation ? `<div class="xp-note">${esc(w.explanation)}</div>` : '<div class="xp-note">Объяснение по этому вопросу ещё не заполнено куратором.</div>'}
</div>`,
[{ 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 = '<div class="kt-ai-empty">Загрузка…</div>';
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 = '<div class="kt-ai-empty"><div class="icon-spot"><svg class="kt-icon"><use href="#alert-circle"></use></svg></div><div class="title">Не загрузилось</div>' + esc(err.message) + "</div>";
}
}
/* ── Анализ ───────────────────────────────────────────── */
function renderAnalysis() {
const st = S.stat;
if (!st || !st.people.length) {
return '<div class="kt-ai-empty"><div class="icon-spot"><svg class="kt-icon"><use href="#chart"></use></svg></div><div class="title">Пока нет данных</div>Столько, сколько специалистов пройдёт проверку — появятся здесь. Вопросы на '" + st.questionCount + "'.</div>";
}
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
? '<hr class="vsplit"><div class="vsplit-h">Ошибки по темам</div><div class="catgrid">' +
cats.map(([c, v]) => `<div class="cat"><span class="c">${esc(c)}</span><span class="v">${v.misses}</span><span class="c">ошибок</span></div>`).join("") + "</div>"
: "";
return `
<div class="kt-ai-kpi-strip">
<div class="kt-ai-kpi"><span class="value">${st.people.length}</span><span class="label">Специалистов</span></div>
<div class="kt-ai-kpi"><span class="value">${avg}%</span><span class="label">Средний лучший результат</span></div>
<div class="kt-ai-kpi"><span class="value">${notPassed}</span><span class="label">Ниже порога 70%</span></div>
<div class="kt-ai-kpi"><span class="value">${st.questionCount}</span><span class="label">Вопросов в базе</span></div>
</div>
<h2>По специалистам</h2>
<div class="kt-ai-table-wrap" data-framed="true">
<table class="kt-ai-table">
<thead><tr>
<th>Специалист</th>
<th>Лучший</th>
<th>Последний</th>
<th>Попытки</th>
<th>Статус</th>
</tr></thead>
<tbody>
${st.people.map((p) => `
<tr data-p="${esc(p.fio)}" data-d="${esc(p.dept)}" style="cursor:pointer">
<td>
<div style="display:flex;flex-direction:column;gap:1px">
<span style="font-size:var(--kt-ai-text-sm);font-weight:var(--kt-ai-weight-medium)">${esc(p.fio)}</span>
<span style="font-size:var(--kt-ai-text-2xs);color:var(--kt-ai-fg-muted)">${esc(p.dept)}</span>
</div>
</td>
<td style="text-align:right;font-variant-numeric:tabular-nums">${p.best >= 0 ? p.best + "%" : "—"}</td>
<td style="text-align:right;font-variant-numeric:tabular-nums">${p.lastPct}% · п.${p.lastTry}</td>
<td style="text-align:right;font-variant-numeric:tabular-nums">${p.count}</td>
<td><span class="kt-ai-status-pill" data-status="${p.best >= 70 ? "ok" : "risk"}">${p.best >= 70 ? "Сдан" : "Ниже 70%"}</span></td>
</tr>`).join("")}
</tbody>
</table>
</div>
${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(`
<div class="drawer-head">
<div class="t"><div style="font-size:var(--kt-ai-text-md);font-weight:var(--kt-ai-weight-semibold)">${esc(fio)}</div>
<div style="font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted)">${esc(dept)}</div></div>
<button class="kt-ai-icon-button" onclick="closeDrawerUi()" aria-label="Закрыть"><svg class="kt-icon"><use href="#x"></use></svg></button>
</div>
<div class="stat-card">
<div class="s"><span class="v">${best}%</span><span class="l">лучший</span></div>
<div class="s"><span class="v">${atts.map((a) => a.tryNo === 2 ? 2 : 1).includes(2) ? 2 : (atts.length ? 1 : 0)}</span><span class="l">попыток из 2</span></div>
<div class="s"><span class="v">${best >= 70 ? "Сдан" : "Ниже 70%"}</span><span class="l">итог</span></div>
</div>
<div class="vsplit-h">Попытки</div>
${atts.map((a) => `
<div class="attempt-line">
<span class="kt-ai-status-pill" data-status="${a.passed ? "ok" : "risk"}" style="font-size:var(--kt-ai-text-2xs)">${a.passed ? "Сдано" : "Провалено"}</span>
<span style="font-weight:var(--kt-ai-weight-semibold);font-variant-numeric:tabular-nums">${a.percent}%</span>
<span class="sp"></span>
<span class="d">${fmtDate(a.date)} · п.${a.tryNo}</span>
</div>`).join("") || '<div class="kt-ai-empty" style="padding:var(--kt-ai-space-4)">Нет попыток</div>'}
${Object.keys(cats).length ? `
<hr class="vsplit"><div class="vsplit-h">Слабые темы (количество ошибок)</div>
<div class="catgrid">${Object.entries(cats).map(([c, n]) => `<div class="cat"><span class="c">${esc(c)}</span><span class="v">${n}</span><span class="c">ошибок</span></div>`).join("")}</div>` : ""}
<div class="kt-ai-drawer-actions" style="margin-top:var(--kt-ai-space-8)">
<button class="kt-ai-btn" data-size="sm" onclick="closeDrawerUi()">Закрыть</button>
</div>`);
}
window.closeDrawerUi = closeDrawer;
/* ── Специалисты ──────────────────────────────────────── */
function renderPersons() {
const st = S.stat;
if (!st.people.length) return '<div class="kt-ai-empty"><div class="icon-spot"><svg class="kt-icon"><use href="#users"></use></svg></div><div class="title">Пока никто не проверялся</div>Как специалисты пройдут тест — они появятся в этом списке.</div>';
return `
<h2>Список специалистов</h2>
<div style="display:flex;flex-direction:column">
${st.people.map((p) => `
<div class="person-row" data-p="${esc(p.fio)}" data-d="${esc(p.dept)}">
<div class="person-info">
<div class="person-fio">${esc(p.fio)}</div>
<div class="person-dept">${esc(p.dept)}</div>
</div>
<div class="person-pct">${p.best >= 0 ? p.best + "%" : "—"}</div>
<div class="person-meta">${p.count} попыток<br>${p.lastDate ? fmtDate(p.lastDate) : "не было"}</div>
<span class="kt-ai-status-pill" data-status="${p.best >= 70 ? "ok" : "risk"}">${p.best >= 70 ? "Сдан" : "Ниже 70%"}</span>
</div>`).join("")}
</div>`;
}
$("#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 `
<h2>Банк вопросов</h2>
<div class="kt-ai-empty">
<div class="icon-spot"><svg class="kt-icon"><use href="#book"></use></svg></div>
<div class="title">Вопросов пока нет</div>
<div style="color:var(--kt-ai-fg-muted);font-size:var(--kt-ai-text-sm)">Добавьте первый — кнопка «Вопрос» слева.</div>
</div>`;
const byCat = {};
questions.forEach((q) => (byCat[q.category] = (byCat[q.category] || 0) + 1));
return `
<div class="kt-ai-kpi-strip">
<div class="kt-ai-kpi"><span class="value">${questions.length}</span><span class="label">Вопросов</span></div>
<div class="kt-ai-kpi"><span class="value">${Object.keys(byCat).length}</span><span class="label">Тем</span></div>
</div>
<h2>Банк вопросов</h2>
${Object.entries(byCat).map(([c, arr]) => {
const qs = questions.filter((q) => q.category === c);
return `<details open style="margin-bottom:var(--kt-ai-space-5)">
<summary style="cursor:pointer;font-size:var(--kt-ai-text-sm);font-weight:var(--kt-ai-weight-medium);list-style:none;display:flex;align-items:center;gap:var(--kt-ai-space-3)">
<span class="kt-ai-chip" data-tone="blue">${esc(c)}</span>
<span style="font-size:var(--kt-ai-text-2xs);color:var(--kt-ai-fg-muted)">${arr} ${arr === 1 ? "вопрос" : "вопросов"}</span>
</summary>
<div style="display:flex;flex-direction:column;gap:var(--kt-ai-space-3);margin-top:var(--kt-ai-space-4)">
${qs.map(qHtml).join("")}
</div>
</details>`;
}).join("")}`;
}
function qHtml(q) {
return `
<div class="q-card" style="border:1px solid var(--kt-ai-border);border-radius:var(--kt-ai-radius-xl);padding:var(--kt-ai-space-5);background:var(--kt-ai-bg)">
<div style="display:flex;gap:var(--kt-ai-space-4);align-items:flex-start">
<div style="flex:1;min-width:0">
<div style="font-size:var(--kt-ai-text-sm);font-weight:var(--kt-ai-weight-medium);line-height:var(--kt-ai-leading-normal)">${esc(q.text)}</div>
<div style="margin-top:var(--kt-ai-space-3);display:flex;flex-direction:column;gap:3px;font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted)">
${q.options.map((o, i) => `<div${i === q.correct ? ' style="color:var(--kt-ai-status-ok-fg);font-weight:var(--kt-ai-weight-medium)"' : ""}>${i === q.correct ? "✓ " : "· "}${esc(o)}</div>`).join("")}
</div>
${q.explanation ? `<div style="font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-faint);margin-top:var(--kt-ai-space-3)">${esc(q.explanation)}</div>` : ""}
</div>
<div style="display:flex;gap:var(--kt-ai-space-2);flex-shrink:0">
<button class="kt-ai-icon-button" onclick="editQ('${esc(q.id)}')" aria-label="Редактировать"><svg class="kt-icon"><use href="#pencil"></use></svg></button>
<button class="kt-ai-icon-button" onclick="delQ('${esc(q.id)}')" aria-label="Удалить"><svg class="kt-icon"><use href="#trash"></use></svg></button>
</div>
</div>
</div>`;
}
$("#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("Удалить вопрос",
'<p style="font-size:var(--kt-ai-text-sm);color:var(--kt-ai-fg-muted)">Вопрос будет удалён из банка. Результаты прошедших проверок не изменятся.</p>',
[
{ 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(`
<div class="drawer-head">
<div class="t">${editing ? "Редактировать вопрос" : "Новый вопрос"}</div>
<button class="kt-ai-icon-button" onclick="closeDrawerUi()" aria-label="Закрыть"><svg class="kt-icon"><use href="#x"></use></svg></button>
</div>
<div class="kt-ai-field">
<label>Тема</label>
<div class="kt-ai-select-wrap">
<select id="qf-cat" class="kt-ai-select">
${allCats.map((c) => `<option value="${esc(c)}" ${q && q.category === c ? "selected" : !q && c === "Тарифы" ? "selected" : ""}>${esc(c)}</option>`).join("")}
</select>
</div>
</div>
<div class="kt-ai-field">
<label>Вопрос</label>
<textarea id="qf-text" class="kt-ai-textarea" rows="3" placeholder="Формулировка вопроса…" required>${q ? esc(q.text) : ""}</textarea>
</div>
<div class="kt-ai-field">
<label>Варианты ответов <span style="color:var(--kt-ai-fg-faint);font-size:var(--kt-ai-text-2xs)">— отметьте точкой правильный</span></label>
<div id="qf-opts" style="display:flex;flex-direction:column;gap:var(--kt-ai-space-2)">
${(q ? q.options : ["", ""]).map((o, i) => optRowHtml(esc(o), i === (q ? q.correct : 0))).join("")}
</div>
<button type="button" class="kt-ai-btn" data-size="sm" style="margin-top:var(--kt-ai-space-2)" onclick="qAddOpt()">+ вариант</button>
</div>
<div class="kt-ai-field" style="margin-bottom:var(--kt-ai-space-3)">
<label>Объяснение</label>
<textarea id="qf-exp" class="kt-ai-textarea" rows="3" placeholder="Что увидит специалист в всплывающем окне после ошибки…">${q ? esc(q.explanation) : ""}</textarea>
</div>
<div class="kt-ai-drawer-actions">
<button class="kt-ai-btn" data-variant="primary" style="width:100%;justify-content:center" id="qf-save">${editing ? "Сохранить" : "Добавить вопрос"}</button>
</div>`);
$("#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 `
<div class="q-opt-row" style="align-items:center;gap:var(--kt-ai-space-3);flex-wrap:wrap">
<input type="radio" name="qf-correct" ${checked ? "checked" : ""} title="Правильный ответ" style="flex-shrink:0">
<input type="text" value="${text}" class="kt-ai-input" placeholder="Текст варианта" style="flex:1;min-width:0">
<button type="button" class="kt-ai-icon-button" onclick="this.closest('.q-opt-row').remove()" aria-label="Убрать вариант"><svg class="kt-icon"><use href="#x"></use></svg></button>
</div>`;
}
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");