qonaev-compliance/script.js

663 lines
39 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.

/* ============================================================
Qonaev TV — Комплаенс порталы
Режим 1: сервер арқылы (run) — нақты деректер, логин, статистика
Режим 2: Предпросмотр (офлайн) — localStorage арқылы демо
============================================================ */
var state = {
user: null,
token: null,
offline: false,
content: null,
complaints: [],
testResults: [],
curPage: "home",
curTest: null,
curTestAnswers: {},
};
var NAV = [
{ id: "home", ico: "🏠", title: "Басты бет" },
{ id: "org", ico: "🏛️", title: "Басшылық құрылымы" },
{ id: "contacts", ico: "📞", title: "Мемлекеттік органдар" },
{ id: "video", ico: "🎬", title: "Видео сабақтар" },
{ id: "laws", ico: "⚖️", title: "Заң нормалары" },
{ id: "complaints", ico: "✉️", title: "Шағым жәшігі" },
{ id: "tests", ico: "📝", title: "Тесттер" },
{ id: "stats", ico: "📊", title: "Дашборд", admin: true },
];
/* ---------------- ДЕМО ДЕРЕКТЕР (офлайн режим) ---------------- */
var MOCK_USERS = [
{ login: "1001", password: "Admin123", name: "Асет (Комплаенс офицер)", role: "admin" },
{ login: "2001", password: "Worker123", name: "Айгүл Серікқызы", role: "user" },
{ login: "2002", password: "Worker123", name: "Бауыржан Нұрланұлы", role: "user" },
{ login: "2003", password: "Worker123", name: "Дана Қайратқызы", role: "user" },
];
var MOCK_CONTENT = {
announcements: [
{ id: 1, title: "Жылдық комплаенс оқуы басталды", text: "Барлық қызметкерлер 1 қарашаға дейін «Сыбайлас жемқорлыққа қарсы» тестін тапсыруы қажет. Видео сабақтар мен заңдар бөлімін қарап шығыңыз.", date: "2026-08-11" },
{ id: 2, title: "Мүдделер қақтығысын декларациялау", text: "Қызметкерлер жыл сайын мүдделер қақтығысы туралы декларация толтырады. Нысан шағым жәшігі бөлімінен алуға болады.", date: "2026-08-05" },
],
org: [
{ level: 1, name: "Бас директор", person: "Ерлан Қасымов", email: "e.kasymov@qonaev.kz", phone: "+7 (727) 300-10-01" },
{ level: 2, name: "Басқарма төрағасы", person: "Гүлнара Әбенова", email: "g.abenova@qonaev.kz", phone: "+7 (727) 300-10-02" },
{ level: 3, name: "Қаржы дирекциясы", person: "Серік Жұмабаев", email: "s.zhumabaev@qonaev.kz", phone: "+7 (727) 300-10-03" },
{ level: 3, name: "Эфир дирекциясы", person: "Айжан Төлегенова", email: "a.tolegenova@qonaev.kz", phone: "+7 (727) 300-10-04" },
{ level: 3, name: "Жаңалықтар дирекциясы", person: "Марат Бекенов", email: "m.bekenov@qonaev.kz", phone: "+7 (727) 300-10-05" },
{ level: 3, name: "Техникалық дирекция", person: "Нұрлан Сыдықов", email: "n.sydykov@qonaev.kz", phone: "+7 (727) 300-10-06" },
{ level: 3, name: "HR және әкімшілік", person: "Ләззат Қалиева", email: "l.kalieva@qonaev.kz", phone: "+7 (727) 300-10-07" },
{ level: 4, name: "Комплаенс қызметі", person: "Асет (сіз)", email: "compliance@qonaev.kz", phone: "+7 (727) 300-10-08" },
],
contacts: [
{ org: "Антикор (ҚР Антикоррупциялық қызметі) Алматы қаласы бойынша департаменті", type: "Ресми телефон", phone: "8 (727) 278-73-24", email: "almaty@nab.gov.kz", note: "Мекенжай: Жібек Жолы даңғылы, 15" },
{ org: "Антикор бірыңғай Call-орталығы", type: "Сенім телефоны", phone: "1424", email: "", note: "Қазақстан бойынша қоңырау тегін" },
{ org: "Антикор WhatsApp-чат-бот", type: "WhatsApp", phone: "8 777 000 1424", email: "", note: "Хабарлама жіберуге болады" },
{ org: "Алматы қаласы прокуратурасы", type: "Кеңсе", phone: "8 (727) 225-40-86", email: "", note: "Мекенжай: Желтоқсан көшесі, 189" },
{ org: "Алматы қаласы прокуратурасы", type: "Кезекші прокурор", phone: "8 (727) 330-80-51", email: "", note: "Тәулік бойы" },
{ org: "ҚР Бас прокуратурасы Call-орталығы", type: "Сенім телефоны", phone: "115", email: "", note: "Дс-жм 09:00–18:30, қоңырау тегін" },
{ org: "Алматы қаласы Еңбек инспекциясы басқармасы", type: "Ресми телефон", phone: "+7 (727) 338-22-42", email: "upr_gitm@mail.ru", note: "Мекенжай: Достық даңғылы, 85" },
{ org: "Алматы қаласы Еңбек инспекциясы (канцелярия)", type: "Телефон", phone: "8 (727) 374-94-47", email: "kgu2016@mail.ru", note: "" },
{ org: "Алматы қаласы бойынша Мемлекеттік кірістер департаменті", type: "Ресми телефон", phone: "8 (7272) 267-15-54", email: "", note: "Мекенжай: Бөгенбай батыр көшесі, 132" },
],
videos: [
{ id: 1, title: "Комплаенс деген не?", desc: "Кіріспе: комплаенс жүйесінің негіздері, мақсаттары және рөлі.", dur: "12 мин", thumb: "https://picsum.photos/seed/compl1/480/270" },
{ id: 2, title: "Сыбайлас жемқорлыққа қарсы заңнама", desc: "ҚР заңдары, тыйымдар мен жауапкершілік туралы негізгі ережелер.", dur: "18 мин", thumb: "https://picsum.photos/seed/compl2/480/270" },
{ id: 3, title: "Мүдделер қақтығысы", desc: "Мүдделер қақтығысын қалай тану және декларациялау керек.", dur: "15 мин", thumb: "https://picsum.photos/seed/compl3/480/270" },
{ id: 4, title: "Сыйлықтар мен белгілер ережелері", desc: "Қызметкерлерге қандай сыйлықтар қабылдауға рұқсат етіледі.", dur: "10 мин", thumb: "https://picsum.photos/seed/compl4/480/270" },
],
laws: [
{ id: 1, cat: "Антикор", title: "«Сыбайлас жемқорлыққа қарсы іс-қимыл туралы» ҚР Заңы", text: "Қызметкерлердің жемқорлық құқық бұзушылықтар жасауына тыйым салады, кірістер декларациясын міндеттейді." },
{ id: 2, cat: "Еңбек", title: "ҚР Еңбек кодексі", text: "Қызметкерлердің құқықтары мен міндеттері, еңбек тәртібі, жауапкершілік нормалары." },
{ id: 3, cat: "Антикор", title: "Мүдделер қақтығысы туралы ереже", text: "Компания ішіндегі мүдделер қақтығысын анықтау, жариялау және шешу тәртібі." },
{ id: 4, cat: "Ақпарат", title: "«Ақпаратқа қол жеткізу туралы» ҚР Заңы", text: "Қоғамдық ақпаратқа қол жеткізу, құпия ақпаратты қорғау талаптары." },
{ id: 5, cat: "Этика", title: "Компанияның Іскерлік этика кодексі", text: "Қызметкерлердің мінез-құлық стандарттары, қарым-қатынас ережелері." },
],
tests: [
{
id: 1, title: "Сыбайлас жемқорлыққа қарсы негіздер", dur: "10 сұрақ", pass: 70,
questions: [
{ q: "Сыбайлас жемқорлық құқық бұзушылық деген не?", options: ["Қызметкердің өз өкілеттігін жеке байыту үшін пайдалануы", "Жұмысқа уақытында келу", "Каникулға шығу", "Басшымен сөйлесу"], correct: 0 },
{ q: "Қызметкерге қандай сыйлық қабылдауға рұқсат?", options: ["Кез келген уақытта кез келген сыйлық", "Құны 3 АЕК-тен аспайтын және есепке алынған сыйлық", "Тек ақшалай", "Барлық сыйлыққа тыйым салынған"], correct: 1 },
{ q: "Мүдделер қақтығысы дегеніміз?", options: ["Жұмыстағы дау", "Жеке мүдде мен қызметтік міндеттердің қайшылығы", "Екі қызметкердің таласы", "Жұмыс орнын ауыстыру"], correct: 1 },
{ q: "Жемқорлық фактісін қайда хабарлау керек?", options: ["Барлығына айту", "Комплаенс қызметіне немесе шағым жәшігіне", "Әлеуметтік желіде жазу", "Үндемеу"], correct: 1 },
{ q: "Кірістер мен мүлік туралы декларация кімге міндетті?", options: ["Тек басшыларға", "Заңда көрсетілген қызметкерлерге", "Ешкімге", "Тек шетелге шығатындарға"], correct: 1 },
],
},
{
id: 2, title: "Іскерлік этика кодексі", dur: "10 сұрақ", pass: 70,
questions: [
{ q: "Іскерлік этика кодексінің мақсаты?", options: ["Қызметкерлерді жазалау", "Мінез-құлық стандарттарын белгілеу", "Жалақыны азайту", "Каникулды қысқарту"], correct: 1 },
{ q: "Алқаптас әріптеске қалай қарау керек?", options: ["Құрметпен және теңдікте", "Менсінбеу", "Елемеу", "Тек басшыларға ғана құрмет"], correct: 0 },
{ q: "Конфиденциалды ақпаратты не істеуге болмайды?", options: ["Қауіпсіз жерде сақтау", "Үшінші тұлғаларға тарату", "Пароль қою", "Құлыптап қою"], correct: 1 },
{ q: "Жемқорлық әрекетке қатысуды сұраса, не істеу керек?", options: ["Келісу", "Бас тартып, комплаенс қызметіне хабарлау", "Ойланып жауап беру", "Ешкімге айтпау"], correct: 1 },
{ q: "Тест нәтижелері мен оқу материалдары кімге қажет?", options: ["Тек HR-ға", "Барлық қызметкерлерге", "Тек админге", "Тек басшыларға"], correct: 1 },
],
},
],
};
/* ---------------- API ҚАБАТЫ ---------------- */
function apiUrl() { return ""; }
async function apiPost(path, body) {
try {
var r = await fetch(apiUrl() + path, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": state.token ? "Bearer " + state.token : "" },
body: JSON.stringify(body),
});
try {
var ct = r.headers.get("content-type") || "";
if (ct.indexOf("json") < 0) { state.offline = true; return { offline: true, ok: false }; }
return await r.json();
} catch (e) { state.offline = true; return { offline: true, ok: false }; }
} catch (e) {
state.offline = true;
return { offline: true, ok: false };
}
}
async function apiGet(path) {
try {
var r = await fetch(apiUrl() + path, {
headers: { "Authorization": state.token ? "Bearer " + state.token : "" },
});
try {
var ct = r.headers.get("content-type") || "";
if (ct.indexOf("json") < 0) { state.offline = true; return { offline: true, ok: false }; }
return await r.json();
} catch (e) { state.offline = true; return { offline: true, ok: false }; }
} catch (e) {
state.offline = true;
return { offline: true, ok: false };
}
}
/* ---------------- ЛОКАЛДЫҚ САҚТАУ (офлайн) ---------------- */
function lsGet(k, def) { try { var v = JSON.parse(localStorage.getItem("qcom_" + k)); return v === null ? def : v; } catch (e) { return def; } }
function lsSet(k, v) { localStorage.setItem("qcom_" + k, JSON.stringify(v)); }
/* ---------------- КІРУ ---------------- */
document.getElementById("loginForm").addEventListener("submit", async function (e) {
e.preventDefault();
var login = document.getElementById("loginNum").value.trim();
var pass = document.getElementById("loginPass").value;
var errEl = document.getElementById("loginError");
errEl.textContent = "";
var res = await apiPost("/api/login", { login: login, password: pass });
if (res.offline) {
var mu = MOCK_USERS.find(function (u) { return u.login === login && u.password === pass; });
if (!mu) { errEl.textContent = "Логин немесе құпия сөз қате. (Демо: 1001 / Admin123)"; return; }
state.user = { login: mu.login, name: mu.name, role: mu.role };
state.offline = true;
state.content = MOCK_CONTENT;
state.complaints = lsGet("complaints", []);
state.testResults = lsGet("testResults", []);
enterApp();
return;
}
if (!res.ok) { errEl.textContent = res.error || "Кіру сәтсіз"; return; }
state.user = res.user;
state.token = res.token;
await loadServerData();
enterApp();
});
async function loadServerData() {
var c = await apiGet("/api/content");
if (c.ok) state.content = c.data;
var cp = await apiGet("/api/complaints");
if (cp.ok) state.complaints = cp.data;
var tr = await apiGet("/api/test-results");
if (tr.ok) state.testResults = tr.data;
}
function enterApp() {
document.getElementById("loginScreen").classList.add("hidden");
document.getElementById("app").classList.remove("hidden");
document.getElementById("userName").textContent = state.user.name;
document.getElementById("userAvatar").textContent = state.user.name.trim().charAt(0).toUpperCase();
var rb = document.getElementById("roleBadge");
rb.textContent = state.user.role === "admin" ? "Админ (комплаенс)" : "Қызметкер";
rb.className = "role-badge " + state.user.role;
buildNav();
go("home");
}
function buildNav() {
var nav = document.getElementById("nav");
nav.innerHTML = "";
NAV.forEach(function (item) {
if (item.admin && state.user.role !== "admin") return;
var b = document.createElement("button");
b.className = "nav-item" + (item.admin ? " admin-only" : "");
b.dataset.id = item.id;
b.innerHTML = '<span class="ico">' + item.ico + "</span><span>" + item.title + "</span>";
b.addEventListener("click", function () { go(item.id); });
nav.appendChild(b);
});
}
function go(page) {
state.curPage = page;
document.querySelectorAll(".nav-item").forEach(function (n) {
n.classList.toggle("active", n.dataset.id === page);
});
var t = NAV.find(function (n) { return n.id === page; });
document.getElementById("pageTitle").textContent = t ? t.title : "Басты бет";
var c = document.getElementById("content");
c.innerHTML = "";
if (page === "home") renderHome(c);
else if (page === "org") renderOrg(c);
else if (page === "contacts") renderContacts(c);
else if (page === "video") renderVideo(c);
else if (page === "laws") renderLaws(c);
else if (page === "complaints") renderComplaints(c);
else if (page === "tests") renderTests(c);
else if (page === "stats") renderStats(c);
}
function pageTitle(text) {
return '<div class="section-title">' + text + "</div>";
}
/* ---------------- БАСТЫ БЕТ ---------------- */
function renderHome(el) {
var myResults = state.testResults.filter(function (r) { return r.login === state.user.login; });
var myComplaints = state.complaints.filter(function (c) { return c.authorLogin === state.user.login; });
var html = '<div class="grid grid-2">';
html += '<div class="card"><h3>👋 Сәлем, ' + esc(state.user.name) + "!</h3><p>Комплаенс порталына қош келдіңіз. Мұнда заңдар, видео сабақтар, тесттер және шағым жәшігі бар.</p></div>";
html += '<div class="card stat-card"><div class="stat-num">' + myResults.length + "</div><div class=\"stat-label\">Сіз тапсырған тест</div></div>";
html += '<div class="card stat-card orange"><div class="stat-num">' + myComplaints.length + "</div><div class=\"stat-label\">Сіздің шағымдарыңыз</div></div>";
html += '<div class="card stat-card green"><div class="stat-num">' + (state.content ? state.content.videos.length : 0) + "</div><div class=\"stat-label\">Видео сабақ</div></div>";
html += "</div>";
html += '<div class="section-title" style="margin-top:28px">📢 Хабарландырулар</div>';
if (state.content && state.content.announcements.length) {
state.content.announcements.forEach(function (a) {
html += '<div class="announce"><h3>' + esc(a.title) + '</h3><div class="date">📅 ' + a.date + '</div><p>' + esc(a.text) + "</p></div>";
});
} else {
html += '<div class="empty">Хабарландыру жоқ</div>';
}
if (state.user.role === "admin") {
html += '<div class="card" style="margin-top:24px"><h3>➕ Жаңа хабарландыру</h3>';
html += '<div class="form"><div class="form-row">';
html += '<div><label>Тақырып</label><input id="annTitle" placeholder="Тақырыбы"></div>';
html += '<div><label>Күні</label><input id="annDate" type="date"></div>';
html += '</div><label>Мәтін</label><textarea id="annText" rows="3" placeholder="Хабарландыру мәтіні"></textarea>';
html += '<button class="btn btn-primary" style="margin-top:14px" onclick="addAnnouncement()">Жариялау</button></div></div>';
}
el.innerHTML = html;
}
async function addAnnouncement() {
var title = document.getElementById("annTitle").value.trim();
var text = document.getElementById("annText").value.trim();
var date = document.getElementById("annDate").value || new Date().toISOString().slice(0, 10);
if (!title || !text) { alert("Тақырып пен мәтінді толтырыңыз"); return; }
if (state.offline) {
var list = state.content.announcements;
list.unshift({ id: Date.now(), title: title, text: text, date: date });
renderHome(document.getElementById("content"));
return;
}
var res = await apiPost("/api/announcements", { title: title, text: text, date: date });
if (res.ok) {
await loadServerData();
renderHome(document.getElementById("content"));
} else alert(res.error || "Қате");
}
/* ---------------- БАСШЫЛЫҚ ҚҰРЫЛЫМЫ ---------------- */
function renderOrg(el) {
var org = state.content ? state.content.org : [];
var html = pageTitle("🏛️ Басшылық құрылымы");
var levels = {};
org.forEach(function (o) {
if (!levels[o.level]) levels[o.level] = [];
levels[o.level].push(o);
});
Object.keys(levels).sort(function (a, b) { return a - b; }).forEach(function (lv) {
html += '<div class="org-level">';
levels[lv].forEach(function (o) {
html += '<div class="org-card' + (o.level === 1 ? " top" : "") + '">';
html += '<div class="org-name">' + esc(o.name) + '</div><div class="org-pos">' + esc(o.person) + "</div>";
if (o.phone || o.email) {
html += '<div class="org-contacts">';
if (o.phone) html += '<div class="org-c">📞 <a href="tel:' + esc(o.phone.replace(/\s|\(|\)|-/g, "")) + '">' + esc(o.phone) + "</a></div>";
if (o.email) html += '<div class="org-c">✉️ <a href="mailto:' + esc(o.email) + '">' + esc(o.email) + "</a></div>";
html += "</div>";
}
html += "</div>";
});
html += "</div>";
html += '<div class="org-arrow">▼</div>';
});
html = html.replace(/<div class="org-arrow">▼<\/div>\s*$/, "");
el.innerHTML = html;
}
/* ---------------- МЕМЛЕКЕТТІК ОРГАНДАР ---------------- */
function renderContacts(el) {
var list = state.content && state.content.contacts ? state.content.contacts : [];
var html = pageTitle("📞 Мемлекеттік органдар — Алматы қаласы");
html += '<p class="muted" style="margin-bottom:16px">Комплаенс мәселелері бойынша хабарласуға болатын мемлекеттік органдардың ресми байланыс деректері.</p>';
html += '<div class="grid grid-2">';
list.forEach(function (c) {
html += '<div class="card"><div class="spread"><h3>' + esc(c.org) + '</h3><span class="cat-tag">' + esc(c.type) + "</span></div>";
html += '<div style="margin-top:12px">';
if (c.phone) html += '<div class="org-c">📞 <a href="tel:' + esc(c.phone.replace(/\s|\(|\)|-/g, "")) + '"><b>' + esc(c.phone) + "</b></a></div>";
if (c.email) html += '<div class="org-c">✉️ <a href="mailto:' + esc(c.email) + '">' + esc(c.email) + "</a></div>";
if (c.note) html += '<div class="org-c muted">📍 ' + esc(c.note) + "</div>";
html += "</div></div>";
});
html += "</div>";
el.innerHTML = html;
}
/* ---------------- ВИДЕО ---------------- */
function renderVideo(el) {
var vids = state.content ? state.content.videos : [];
var html = pageTitle("🎬 Видео сабақтар");
html += '<div class="grid grid-2">';
vids.forEach(function (v) {
html += '<div class="card video-card" onclick="openVideo(' + v.id + ')">';
html += '<div class="video-thumb" style="background-image:url(\'' + v.thumb + '\')">▶</div>';
html += "<h3>" + esc(v.title) + '</h3><p>' + esc(v.desc) + '</p><div class="muted" style="margin-top:8px">⏱ ' + v.dur + "</div>";
html += "</div>";
});
html += "</div>";
el.innerHTML = html;
}
function openVideo(id) {
var v = state.content.videos.find(function (x) { return x.id === id; });
if (!v) return;
var url = prompt("Видео сілтемесін қойыңыз (https://...)", v.link || "https://www.youtube.com/watch?v=dQw4w9WgXcQ");
if (url && state.offline) { v.link = url; }
if (url) window.open(url, "_blank");
}
/* ---------------- ЗАҢДАР ---------------- */
function renderLaws(el) {
var laws = state.content ? state.content.laws : [];
var cats = [];
laws.forEach(function (l) { if (cats.indexOf(l.cat) < 0) cats.push(l.cat); });
var html = pageTitle("⚖️ Заң нормалары мен комплаенс заңдар");
html += '<div class="tabs">';
html += '<button class="tab active" data-cat="all" onclick="filterLaws(\'all\')">Барлығы</button>';
cats.forEach(function (c) { html += '<button class="tab" data-cat="' + esc(c) + '" onclick="filterLaws(\'' + esc(c) + '\')">' + esc(c) + "</button>"; });
html += "</div>";
html += '<div id="lawList"></div>';
el.innerHTML = html;
filterLaws("all");
}
function filterLaws(cat) {
var laws = state.content ? state.content.laws : [];
document.querySelectorAll(".tab").forEach(function (t) { t.classList.toggle("active", t.dataset.cat === cat); });
var list = cat === "all" ? laws : laws.filter(function (l) { return l.cat === cat; });
var html = "";
list.forEach(function (l) {
html += '<div class="card" style="margin-bottom:12px"><div class="spread"><h3>' + esc(l.title) + '</h3><span class="cat-tag">' + esc(l.cat) + "</span></div><p>" + esc(l.text) + "</p></div>";
});
document.getElementById("lawList").innerHTML = html || '<div class="empty">Табылмады</div>';
}
/* ---------------- ШАҒЫМ ЖӘШІГІ ---------------- */
function renderComplaints(el) {
var html = pageTitle("✉️ Шағым жәшігі");
html += '<div class="card" style="margin-bottom:20px"><h3>Жаңа шағым / өтініш</h3>';
html += '<div class="form">';
html += '<label>Санаты</label><select id="compCat"><option>Жемқорлық фактісі</option><option>Мүдделер қақтығысы</option><option>Жұмыс ортасы</option><option>Жалақы/еңбек құқығы</option><option>Басқа</option></select>';
html += '<label>Мәтін</label><textarea id="compText" rows="4" placeholder="Шағымыңызды жазыңыз..."></textarea>';
html += '<div class="row" style="margin-top:6px"><label style="display:flex;align-items:center;gap:8px;font-weight:500"><input type="checkbox" id="compAnon"> Анонимді жіберу (аты-жөніңіз көрсетілмейді)</label></div>';
html += '<button class="btn btn-primary" style="margin-top:12px" onclick="sendComplaint()">Жіберу</button>';
html += '<div id="compMsg"></div></div></div>';
html += '<div class="section-title">Менің шағымдарым' + (state.user.role === "admin" ? " және барлық өтініштер" : "") + "</div>";
html += '<div id="compList"></div>';
el.innerHTML = html;
renderComplaintList();
}
function renderComplaintList() {
var list = state.complaints.slice().sort(function (a, b) { return b.id - a.id; });
if (state.user.role !== "admin") {
list = list.filter(function (c) { return c.authorLogin === state.user.login || c.anonymous; });
}
var html = "";
list.forEach(function (c) {
var st = { "new": "Жаңа", "work": "Қаралуда", "done": "Шешілді" }[c.status] || c.status;
html += '<div class="card complaint-item ' + c.status + '" style="margin-bottom:12px">';
html += '<div class="spread"><div class="row"><span class="cat-tag">' + esc(c.category) + "</span>";
html += '<span class="badge ' + c.status + '">' + st + "</span></div>";
html += '<span class="muted">' + (c.anonymous ? "Аноним" : esc(c.authorName)) + " · " + c.date + "</span></div>";
html += "<p style=\"margin-top:10px\">" + esc(c.text) + "</p>";
if (state.user.role === "admin" && c.status !== "done") {
html += '<div class="row" style="margin-top:12px">';
if (c.status !== "work") html += '<button class="btn btn-sm btn-ghost" onclick="setComplaintStatus(' + c.id + ',\'work\')">Қаралуда</button>';
html += '<button class="btn btn-sm btn-primary" onclick="setComplaintStatus(' + c.id + ',\'done\')">Шешілді</button>';
html += "</div>";
}
html += "</div>";
});
document.getElementById("compList").innerHTML = html || '<div class="empty">Әзірге шағым жоқ</div>';
}
async function sendComplaint() {
var category = document.getElementById("compCat").value;
var text = document.getElementById("compText").value.trim();
var anonymous = document.getElementById("compAnon").checked;
if (!text) { document.getElementById("compMsg").innerHTML = '<div class="msg err">Мәтінді толтырыңыз</div>'; return; }
if (state.offline) {
state.complaints.unshift({
id: Date.now(), category: category, text: text, anonymous: anonymous,
status: "new", date: new Date().toISOString().slice(0, 10),
authorLogin: anonymous ? null : state.user.login,
authorName: anonymous ? null : state.user.name,
});
lsSet("complaints", state.complaints);
document.getElementById("compMsg").innerHTML = '<div class="msg ok">✅ Шағымыңыз жіберілді (демо-режимде жергілікті сақталды)</div>';
document.getElementById("compText").value = "";
renderComplaintList();
return;
}
var res = await apiPost("/api/complaints", { category: category, text: text, anonymous: anonymous });
if (res.ok) {
document.getElementById("compMsg").innerHTML = '<div class="msg ok">✅ Шағымыңыз қабылданды. Рахмет!</div>';
document.getElementById("compText").value = "";
await loadServerData();
renderComplaintList();
} else document.getElementById("compMsg").innerHTML = '<div class="msg err">' + esc(res.error || "Қате") + "</div>";
}
async function setComplaintStatus(id, status) {
if (state.offline) {
var c = state.complaints.find(function (x) { return x.id === id; });
if (c) { c.status = status; lsSet("complaints", state.complaints); }
renderComplaintList();
return;
}
var res = await apiPost("/api/complaints/" + id + "/status", { status: status });
if (res.ok) { await loadServerData(); renderComplaintList(); }
else alert(res.error || "Қате");
}
/* ---------------- ТЕСТТЕР ---------------- */
function renderTests(el) {
if (state.curTest !== null) { renderTestRun(el); return; }
var tests = state.content ? state.content.tests : [];
var html = pageTitle("📝 Тесттер");
html += '<div class="grid grid-2">';
tests.forEach(function (t) {
var done = state.testResults.filter(function (r) { return r.testId === t.id; });
html += '<div class="card"><h3>' + esc(t.title) + "</h3>";
html += "<p>Сұрақтар саны: " + t.questions.length + " · өту үшін: " + t.pass + "%</p>";
html += '<div class="muted" style="margin:8px 0">Әрекеттер: ' + done.length;
if (done.length) {
var last = done[done.length - 1];
html += " · соңғысы: <b>" + last.score + "%</b>";
}
html += "</div>";
html += '<button class="btn btn-primary btn-sm" onclick="startTest(' + t.id + ')">Бастау</button></div>';
});
html += "</div>";
html += '<div class="section-title" style="margin-top:28px">Менің нәтижелерім</div>';
html += '<div id="myTestResults"></div>';
el.innerHTML = html;
var mine = state.testResults.filter(function (r) { return r.login === state.user.login; }).slice().reverse();
var th = "";
if (mine.length) {
th = "<table><thead><tr><th>Тест</th><th>Нәтиже</th><th>Күні</th></tr></thead><tbody>";
mine.forEach(function (r) {
var tn = tests.find(function (t) { return t.id === r.testId; });
th += "<tr><td>" + esc(tn ? tn.title : "Тест") + "</td><td><span class=\"badge " + (r.score >= (tn ? tn.pass : 70) ? "done" : "new") + "\">" + r.score + "%</span></td><td>" + r.date + "</td></tr>";
});
th += "</tbody></table>";
}
document.getElementById("myTestResults").innerHTML = th || '<div class="empty">Әзірге тест тапсырған жоқсыз</div>';
}
function startTest(id) {
state.curTest = id;
state.curTestAnswers = {};
renderTests(document.getElementById("content"));
}
function renderTestRun(el) {
var t = state.content.tests.find(function (x) { return x.id === state.curTest; });
if (!t) { state.curTest = null; renderTests(el); return; }
var html = '<button class="btn btn-ghost btn-sm" onclick="state.curTest=null;renderTests(document.getElementById(\'content\'))">← Артқа</button>';
html += '<div class="section-title" style="margin-top:14px">' + esc(t.title) + "</div>";
t.questions.forEach(function (q, qi) {
html += '<div class="card" style="margin-bottom:14px"><div class="test-question"><h4>' + (qi + 1) + ". " + esc(q.q) + "</h4>";
q.options.forEach(function (op, oi) {
html += '<label class="q-option' + (state.curTestAnswers[qi] === oi ? " selected" : "") + '" data-q="' + qi + '" data-o="' + oi + '" onclick="pickAnswer(this,' + qi + ',' + oi + ')">' + esc(op) + "</label>";
});
html += "</div></div>";
});
html += '<button class="btn btn-primary btn-block" onclick="submitTest()">Нәтижені жіберу</button>';
el.innerHTML = html;
}
function pickAnswer(elm, qi, oi) {
state.curTestAnswers[qi] = oi;
document.querySelectorAll('.q-option[data-q="' + qi + '"]').forEach(function (o) {
o.classList.toggle("selected", o.dataset.o === String(oi));
});
}
async function submitTest() {
var t = state.content.tests.find(function (x) { return x.id === state.curTest; });
var total = t.questions.length;
var answered = Object.keys(state.curTestAnswers).length;
if (answered < total) { alert("Барлық сұрақтарға жауап беріңіз (" + answered + "/" + total + ")"); return; }
var score = 0;
t.questions.forEach(function (q, qi) { if (state.curTestAnswers[qi] === q.correct) score++; });
var pct = Math.round(score / total * 100);
var result = { testId: t.id, login: state.user.login, name: state.user.name, score: pct, date: new Date().toISOString().slice(0, 10) };
if (state.offline) {
state.testResults.push(result);
lsSet("testResults", state.testResults);
showTestResult(pct, score, total);
return;
}
var res = await apiPost("/api/test-results", { testId: t.id, score: pct });
if (res.ok) {
await loadServerData();
showTestResult(pct, score, total);
} else alert(res.error || "Қате");
}
function showTestResult(pct, score, total) {
state.curTest = null;
var el = document.getElementById("content");
var html = '<div class="card" style="max-width:520px;margin:40px auto;text-align:center">';
html += '<div class="test-score">' + pct + "%</div>";
html += "<p>Дұрыс жауаптар: " + score + " / " + total + "</p>";
html += '<div class="' + (pct >= 70 ? "msg ok" : "msg err") + '" style="text-align:center">' + (pct >= 70 ? "✅ Тест сәтті өтілді!" : "Тест өтілмеді, қайта тапсыруға болады") + "</div>";
html += '<button class="btn btn-primary" onclick="renderTests(document.getElementById(\'content\'))">Тесттерге оралу</button>';
html += "</div>";
el.innerHTML = html;
}
/* ---------------- ДАШБОРД (АДМИН) ---------------- */
async function renderStats(el) {
var html = pageTitle("📊 Дашборд — статистика");
var data;
if (state.offline) {
data = computeStats();
} else {
var r = await apiGet("/api/stats");
if (r.ok) data = r.data; else data = computeStats();
}
if (!data) { el.innerHTML = '<div class="empty">Деректер жоқ</div>'; return; }
html += '<div class="grid grid-4">';
html += '<div class="card stat-card"><div class="stat-num">' + data.users + "</div><div class=\"stat-label\">Қызметкерлер</div></div>";
html += '<div class="card stat-card red"><div class="stat-num">' + data.complaintsTotal + "</div><div class=\"stat-label\">Шағымдар (барлығы)</div></div>";
html += '<div class="card stat-card orange"><div class="stat-num">' + data.complaintsOpen + "</div><div class=\"stat-label\">Ашық шағымдар</div></div>";
html += '<div class="card stat-card green"><div class="stat-num">' + data.testsTotal + "</div><div class=\"stat-label\">Тест тапсырулар</div></div>";
html += "</div>";
html += '<div class="grid grid-2" style="margin-top:20px">';
html += '<div class="card"><h3>Шағымдар санаты бойынша</h3><div id="catBars"></div></div>';
html += '<div class="card"><h3>Тест өту көрсеткіші</h3><div id="passBars"></div></div>';
html += "</div>";
html += '<div class="card" style="margin-top:20px"><h3>Тест нәтижелері бойынша орташа балл</h3><div id="avgBars"></div></div>';
html += '<div class="section-title" style="margin-top:28px">Соңғы шағымдар</div><div id="statsComplaints"></div>';
el.innerHTML = html;
var catData = data.byCategory;
var catMax = Math.max.apply(null, catData.map(function (c) { return c.count; }).concat([1]));
var catHtml = "";
catData.forEach(function (c) {
var w = Math.max(8, Math.round(c.count / catMax * 100));
catHtml += '<div class="bar-row"><div class="bar-label">' + esc(c.cat) + '</div><div class="bar-track"><div class="bar-fill" style="width:' + w + '%">' + c.count + "</div></div></div>";
});
document.getElementById("catBars").innerHTML = catHtml || '<div class="empty">Жоқ</div>';
var passHtml = "";
["done", "work", "new"].forEach(function (s) {
var c = data.byStatus[s] || 0;
var w = data.complaintsTotal ? Math.max(8, Math.round(c / data.complaintsTotal * 100)) : 8;
var color = s === "done" ? "green" : s === "work" ? "orange" : "red";
var name = s === "done" ? "Шешілді" : s === "work" ? "Қаралуда" : "Жаңа";
passHtml += '<div class="bar-row"><div class="bar-label">' + name + '</div><div class="bar-track"><div class="bar-fill ' + color + '" style="width:' + w + '%">' + c + "</div></div></div>";
});
document.getElementById("passBars").innerHTML = passHtml;
var avgHtml = "";
data.byTest.forEach(function (t) {
avgHtml += '<div class="bar-row"><div class="bar-label">' + esc(t.title) + '</div><div class="bar-track"><div class="bar-fill" style="width:' + t.avg + '%">' + t.avg + "%</div></div></div>";
});
document.getElementById("avgBars").innerHTML = avgHtml || '<div class="empty">Тест әлі жоқ</div>';
var cl = state.complaints.slice().sort(function (a, b) { return b.id - a.id; }).slice(0, 8);
var chtml = "";
cl.forEach(function (c) {
var st = { "new": "Жаңа", "work": "Қаралуда", "done": "Шешілді" }[c.status] || c.status;
chtml += '<div class="card complaint-item ' + c.status + '" style="margin-bottom:10px"><div class="spread"><span class="cat-tag">' + esc(c.category) + "</span><span>" + (c.anonymous ? "Аноним" : esc(c.authorName)) + ' · <span class="badge ' + c.status + '">' + st + "</span></span></div><p style=\"margin-top:8px\">" + esc(c.text) + "</p></div>";
});
document.getElementById("statsComplaints").innerHTML = chtml || '<div class="empty">Шағым жоқ</div>';
}
function computeStats() {
var complaints = state.complaints || [];
var results = state.testResults || [];
var tests = state.content ? state.content.tests : [];
var byStatus = { new: 0, work: 0, done: 0 };
var byCategory = {};
complaints.forEach(function (c) {
byStatus[c.status] = (byStatus[c.status] || 0) + 1;
byCategory[c.category] = (byCategory[c.category] || 0) + 1;
});
var catData = Object.keys(byCategory).map(function (k) { return { cat: k, count: byCategory[k] }; });
var byTest = tests.map(function (t) {
var rs = results.filter(function (r) { return r.testId === t.id; });
var avg = rs.length ? Math.round(rs.reduce(function (s, r) { return s + r.score; }, 0) / rs.length) : 0;
return { title: t.title, count: rs.length, avg: avg };
});
return {
users: state.offline ? MOCK_USERS.length : 0,
complaintsTotal: complaints.length,
complaintsOpen: byStatus.new + byStatus.work,
testsTotal: results.length,
byStatus: byStatus,
byCategory: catData,
byTest: byTest,
};
}
/* ---------------- ШЫҒУ ---------------- */
document.getElementById("logoutBtn").addEventListener("click", function () {
state.user = null;
state.token = null;
document.getElementById("app").classList.add("hidden");
document.getElementById("loginScreen").classList.remove("hidden");
document.getElementById("loginPass").value = "";
});
/* ---------------- УТИЛ ---------------- */
function esc(s) {
return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}