684 lines
32 KiB
JavaScript
684 lines
32 KiB
JavaScript
/* Тест: вход (специалист / куратор), тест на 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) + '<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");
|