qonaev-compliance/script.js

1054 lines
63 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: "insider", ico: "🔐", title: "Инсайдерлік ақпарат" },
{ id: "video", ico: "🎬", title: "Видео сабақтар" },
{ id: "laws", ico: "⚖️", title: "Заң нормалары" },
{ id: "complaints", ico: "✉️", title: "Шағым жәшігі" },
{ id: "tests", ico: "📝", title: "Тесттер" },
{ id: "users", ico: "👥", title: "Қызметкерлер", admin: true },
{ 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 },
],
},
{
id: 3, title: "Инсайдерлік ақпарат және құпиялылық", dur: "10 сұрақ", pass: 70,
questions: [
{ q: "Инсайдерлік ақпарат дегеніміз не?", options: ["Әріптестер туралы қауесет", "Компания туралы әлі жарияланбаған қызметтік деректер", "Әлеуметтік желідегі ақпарат", "Жалпыға белгілі жаңалықтар"], correct: 1 },
{ q: "Жаңа сюжет эфирге шыққанға дейін оны танысқа айтуға бола ма?", options: ["Иә, егер ол жақын таныс болса", "Жоқ, инсайдерлік ақпаратты таратуға тыйым салынады", "Иә, бірақ тек SMS арқылы", "Болады, себебі бұл жай ғана жаңалық"], correct: 1 },
{ q: "Дереккөздің атын басшылыққа ашуға бола ма?", options: ["Иә, міндетті түрде", "Жоқ, журналист дереккөзді құпия ұстайды", "Тек түскі үзілісте айтуға болады", "Тек жазбаша түрде"], correct: 1 },
{ q: "Инсайдерлік ақпаратты таратқаны үшін не болады?", options: ["Ештеңе болмайды", "Тәртіптік жауапкершілік, ауыр жағдайда заң бойынша жауапкершілік", "Тек сөгіс беріледі", "Бонус өседі"], correct: 1 },
{ q: "Қызметтік ақпаратты қай жерде талқылауға БОЛМАЙДЫ?", options: ["Кеңседе әріптестермен", "Көпшілік орындарда және әлеуметтік желілерде", "Ресми жиналыста", "Комплаенс офицермен кеңесте"], correct: 1 },
],
},
],
insider: [
{ title: "Инсайдерлік ақпарат дегеніміз не?", text: "Бұл компания туралы әлі жарияланбаған, қызметкерге өз жұмысы барысында белгілі болған деректер: қаржылық көрсеткіштер, келісімшарттар, жаңалық сюжеттері, кадрлық шешімдер." },
{ title: "Таратуға тыйым салынады", text: "Инсайдерлік ақпаратты эфирге шыққанға дейін немесе ресми жарияланғанға дейін үшінші тұлғаларға, әлеуметтік желілерде немесе таныстарға таратуға тыйым салынады." },
{ title: "Дереккөздерді құпия ұстау", text: "Журналистер өз дереккөздерін құпия ұстауға міндетті. Дереккөздің атын ашу — еңбек тәртібін өрескел бұзу." },
{ title: "Не істеу керек?", text: "Инсайдерлік ақпаратқа қол жеткізгенде және оны пайдалану қажет болғанда — комплаенс офицермен кеңесіңіз. Ақпарат тек қызметтік қажеттілік үшін пайдаланылады." },
{ title: "Жауапкершілік", text: "Инсайдерлік ақпаратты тарату — тәртіптік жауапкершілікке әкеледі, ал ауыр жағдайларда ҚР заңнамасы бойынша қылмыстық жауапкершілікке де тартылуы мүмкін." },
],
giftRules: {
allowed: [
"Құны 3 АЕК-тен аспайтын еске салу сыйлығы (күнтізбе, қалам, кесе)",
"Ресми іс-шарада берілген естелік сыйлықтар",
"Мерекелік корпоративтік сыйлықтар (компания атынан)",
"Іскерлік келіссөздердегі қонақжайлылық (кешкі ас, кофе)",
],
forbidden: [
"Ақша және оған балама (карта, купон, сертификат)",
"Құны 3 АЕК-тен асатын сыйлықтар",
"Қызметтік шешімге әсер ететін сыйлықтар (мүдделер қақтығысы)",
"Кез келген уақытта қолма-қол ақша немесе қарыз беру",
],
},
};
/* ---------------- 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("forgotBtn").addEventListener("click", function () {
document.getElementById("recoverBox").classList.toggle("hidden");
});
document.getElementById("recoverGo").addEventListener("click", async function () {
var login = document.getElementById("recoverLogin").value.trim();
var msg = document.getElementById("recoverMsg");
if (!login) { msg.textContent = "Логиніңізді жазыңыз"; return; }
var res = await apiPost("/api/recover-password", { login: login });
if (res.ok) {
msg.innerHTML = "<b>" + esc(res.name) + "</b>, жаңа құпия сөзіңіз: <b>" + esc(res.password) + "</b>";
} else if (res.offline) {
var mu = MOCK_USERS.find(function (u) { return u.login === login; });
if (!mu) { msg.textContent = "Мұндай логин табылмады"; return; }
var np = "Qonaev" + Math.floor(1000 + Math.random() * 9000);
mu.password = np;
msg.innerHTML = "<b>" + esc(mu.name) + "</b>, жаңа құпия сөзіңіз: <b>" + np + "</b> (демо-режим)";
} else {
msg.textContent = res.error || "Қате";
}
});
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", []);
state.directMessages = lsGet("directMessages", []);
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;
var dm = await apiGet("/api/direct-messages");
if (dm.ok) state.directMessages = dm.data;
if (state.user && state.user.role === "admin") {
var us = await apiGet("/api/users");
if (us.ok) state.users = us.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 = "";
var newCount = newItemsCount();
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>";
if (state.user.role === "admin" && (item.id === "complaints" || item.id === "stats") && newCount > 0) {
b.innerHTML += '<span class="nav-badge">' + newCount + "</span>";
}
b.addEventListener("click", function () { go(item.id); });
nav.appendChild(b);
});
}
function newItemsCount() {
var n = 0;
(state.complaints || []).forEach(function (c) { if (c.status === "new") n++; });
(state.directMessages || []).forEach(function (m) { if (m.status === "new") n++; });
return n;
}
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 === "insider") renderInsider(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 === "users") renderUsers(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>";
if (state.user.role === "admin") {
var newCount = newItemsCount();
if (newCount > 0) {
html += '<div class="card" style="margin-top:20px;border:2px solid var(--red);background:var(--red-50)">';
html += '<h3>🔔 Жаңа хабарламалар: <b>' + newCount + "</b></h3>";
html += '<p style="margin:8px 0">Шағым жәшігіне және жеке хаттарға жаңа өтініштер келді. Жауап беру үшін «Шағым жәшігі» немесе «Дашборд» бөліміне өтіңіз.</p>';
html += '<button class="btn btn-primary btn-sm" onclick="go(\'complaints\')">Ашып қарау</button>';
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("⚖️ Заң нормалары мен комплаенс заңдар");
var gr = state.content && state.content.giftRules ? state.content.giftRules : null;
if (gr) {
html += '<div class="card" style="margin-bottom:24px;border:1.5px solid #DFE4EA">';
html += '<h3 style="font-size:19px">🎁 Сыйлықтар ережесі</h3>';
html += '<div class="gift-grid">';
html += '<div class="gift-col gift-ok"><div class="gift-head">✅ Рұқсат етілген</div>';
gr.allowed.forEach(function (t) { html += '<div class="gift-item">' + esc(t) + "</div>"; });
html += "</div>";
html += '<div class="gift-col gift-no"><div class="gift-head">🚫 Тыйым салынған</div>';
gr.forbidden.forEach(function (t) { html += '<div class="gift-item">' + esc(t) + "</div>"; });
html += "</div>";
html += "</div></div>";
}
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 renderInsider(el) {
var list = state.content && state.content.insider ? state.content.insider : [];
var html = pageTitle("🔐 Инсайдерлік ақпарат және құпиялылық");
html += '<p class="muted" style="margin-bottom:16px">Медиа саласында инсайдерлік ақпаратпен жұмыс — ең маңызды талаптардың бірі. Төмендегі ережелерді оқып, тест тапсыру ұсынылады.</p>';
list.forEach(function (item, i) {
html += '<div class="card" style="margin-bottom:12px;border-left:4px solid var(--accent)">';
html += '<h3>' + (i + 1) + ". " + esc(item.title) + "</h3>";
html += "<p>" + esc(item.text) + "</p>";
html += "</div>";
});
html += '<div class="card" style="margin-top:20px;background:var(--cyan-50);border:1.5px solid var(--cyan)">';
html += '<h3>📝 Материалды оқыдыңыз ба?</h3>';
html += '<p style="margin:8px 0">«Инсайдерлік ақпарат және құпиялылық» тестін тапсырып, біліміңізді тексеріңіз.</p>';
html += '<button class="btn btn-cyan" onclick="startTest(3)">Тестке өту</button>';
html += "</div>";
el.innerHTML = html;
}
/* ---------------- ШАҒЫМ ЖӘШІГІ ---------------- */
function renderComplaints(el) {
var html = pageTitle("✉️ Шағым жәшігі");
html += '<div class="grid grid-2" style="margin-bottom:20px">';
html += '<div class="card"><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="card"><h3>🔒 Комплаенс офицерге жеке хат</h3>';
html += '<div class="form">';
html += '<label>Тақырыбы</label><select id="dmCat"><option>Консультация</option><option>Мүдделер қақтығысы</option><option>Инсайдерлік ақпарат мәселесі</option><option>Сыйлық/қонақжайлылық</option><option>Басқа</option></select>';
html += '<label>Хат мәтіні</label><textarea id="dmText" 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="dmAnon"> Анонимді түрде</label></div>';
html += '<button class="btn btn-cyan" style="margin-top:12px" onclick="sendDirectMsg()">Жіберу</button>';
html += '<div id="dmMsg"></div></div></div>';
html += "</div>";
html += '<div class="section-title">Менің шағымдарым' + (state.user.role === "admin" ? " және барлық өтініштер" : "") + "</div>";
html += '<div id="compList"></div>';
html += '<div class="section-title" style="margin-top:24px">Менің жеке хаттарым (комплаенс офицерге)</div>';
html += '<div id="dmList"></div>';
el.innerHTML = html;
renderComplaintList();
renderMyDmList();
}
function renderMyDmList() {
var list = (state.directMessages || []).slice().sort(function (a, b) { return b.id - a.id; });
if (state.user.role !== "admin") {
list = list.filter(function (m) { return m.authorLogin === state.user.login; });
}
var html = "";
list.forEach(function (m) {
var st = { "new": "Жаңа", "work": "Қаралуда", "done": "Шешілді" }[m.status] || m.status;
html += '<div class="card complaint-item ' + m.status + '" style="margin-bottom:12px">';
html += '<div class="spread"><span class="cat-tag">' + esc(m.category) + "</span>";
html += '<span>' + (m.anonymous ? "Аноним" : esc(m.authorName)) + ' · <span class="badge ' + m.status + '">' + st + "</span></span></div>";
html += "<p style=\"margin-top:8px\">" + esc(m.text) + "</p>";
if (m.reply) {
html += '<div class="reply-box"><b>📨 Комплаенс офицердің жауабы (' + (m.replyDate || "") + '):</b><p>' + esc(m.reply) + "</p></div>";
}
if (state.user.role === "admin") {
html += '<div class="row" style="margin-top:10px;gap:8px;align-items:flex-start;flex-wrap:wrap">';
html += '<input id="replyDm_' + m.id + '" class="reply-input" placeholder="Жауап жазыңыз және «Жіберу» басыңыз...">';
html += '<button class="btn btn-sm btn-cyan" onclick="replyDm(' + m.id + ')">Жіберу</button>';
if (m.status !== "done") {
if (m.status !== "work") html += '<button class="btn btn-sm btn-ghost" onclick="setDmStatus(' + m.id + ',\'work\')">Қаралуда</button>';
html += '<button class="btn btn-sm btn-primary" onclick="setDmStatus(' + m.id + ',\'done\')">Шешілді</button>';
}
html += "</div>";
}
html += "</div>";
});
document.getElementById("dmList").innerHTML = html || '<div class="empty">Хаттар жоқ — комплаенс офицерге жаза аласыз</div>';
}
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 (c.reply) {
html += '<div class="reply-box"><b>📨 Жауап (' + c.replyDate + '):</b><p>' + esc(c.reply) + "</p></div>";
}
if (state.user.role === "admin") {
html += '<div class="row" style="margin-top:12px;gap:8px;align-items:flex-start;flex-wrap:wrap">';
html += '<input id="replyComp_' + c.id + '" class="reply-input" placeholder="Жауап жазыңыз және «Жіберу» басыңыз...">';
html += '<button class="btn btn-sm btn-cyan" onclick="replyComplaint(' + c.id + ')">Жіберу</button>';
if (c.status !== "done") {
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 replyComplaint(id) {
var reply = document.getElementById("replyComp_" + id).value.trim();
if (!reply) { alert("Жауап мәтінін жазыңыз"); return; }
if (state.offline) {
var c = state.complaints.find(function (x) { return x.id === id; });
if (c) {
c.reply = reply;
c.replyDate = new Date().toISOString().slice(0, 10);
if (c.status === "new") c.status = "work";
lsSet("complaints", state.complaints);
}
renderComplaintList();
return;
}
var res = await apiPost("/api/complaints/" + id + "/reply", { reply: reply });
if (res.ok) { await loadServerData(); renderComplaintList(); }
else alert(res.error || "Қате");
}
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 || "Қате");
}
async function sendDirectMsg() {
var category = document.getElementById("dmCat").value;
var text = document.getElementById("dmText").value.trim();
var anonymous = document.getElementById("dmAnon").checked;
if (!text) { document.getElementById("dmMsg").innerHTML = '<div class="msg err">Хат мәтінін жазыңыз</div>'; return; }
if (state.offline) {
var list = lsGet("directMessages", []);
list.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,
});
state.directMessages = list;
lsSet("directMessages", list);
document.getElementById("dmMsg").innerHTML = '<div class="msg ok">✅ Хат жіберілді (демо-режимде жергілікті сақталды)</div>';
document.getElementById("dmText").value = "";
renderMyDmList();
return;
}
var res = await apiPost("/api/direct-messages", { category: category, text: text, anonymous: anonymous });
if (res.ok) {
document.getElementById("dmMsg").innerHTML = '<div class="msg ok">✅ Хатыңыз комплаенс офицерге жіберілді</div>';
document.getElementById("dmText").value = "";
await loadServerData();
renderMyDmList();
} else document.getElementById("dmMsg").innerHTML = '<div class="msg err">' + esc(res.error || "Қате") + "</div>";
}
/* ---------------- ҚЫЗМЕТКЕРЛЕР (АККАУНТТАР, админ) ---------------- */
async function renderUsers(el) {
var html = pageTitle("👥 Қызметкерлер және аккаунттар");
html += '<p class="muted" style="margin-bottom:16px">Логиндер мен құпия сөздер <code>users.json</code> файлында сақталады. Мұнда жаңа қызметкер қосуға, құпия сөз ауыстыруға және аккаунтты өшіруге болады.</p>';
html += '<div class="card" style="margin-bottom:20px"><h3>➕ Жаңа қызметкер қосу</h3><div class="form"><div class="grid grid-2">';
html += '<div><label>Логин (жеке нөмір)</label><input id="uLogin" placeholder="Мысалы: 2004"></div>';
html += '<div><label>Аты-жөні</label><input id="uName" placeholder="Аты-жөні"></div>';
html += '</div><div class="grid grid-2">';
html += '<div><label>Құпия сөз</label><input id="uPass" placeholder="Ағылшын әріптерімен"></div>';
html += '<div><label>Рөлі</label><select id="uRole"><option value="user">Қызметкер</option><option value="admin">Админ (комплаенс)</option></select></div>';
html += '</div>';
html += '<button class="btn btn-primary" style="margin-top:14px" onclick="addUser()">Қосу</button>';
html += '<div id="uMsg"></div></div></div>';
html += '<div id="usersList"></div>';
el.innerHTML = html;
loadUsersList();
}
async function loadUsersList() {
var users;
if (state.offline) {
users = MOCK_USERS;
} else {
var r = await apiGet("/api/users");
if (!r.ok) return;
users = r.data;
}
var html = '<div class="card"><table><thead><tr><th>Логин</th><th>Аты-жөні</th><th>Рөл</th><th>Құпия сөз</th><th></th></tr></thead><tbody>';
users.forEach(function (u) {
html += "<tr><td><b>" + esc(u.login) + "</b></td><td>" + esc(u.name) + "</td>";
html += '<td>' + (u.role === "admin" ? "Админ" : "Қызметкер") + "</td>";
html += '<td><span class="pass-row"><span class="pass-text">' + esc(u.password) + '</span>';
html += '<button class="btn btn-sm btn-ghost" onclick="resetUserPass(\'' + esc(u.login) + '\')">Ауыстыру</button></span></td>';
html += '<td>' + (u.role !== "admin" ? '<button class="btn btn-sm btn-danger" onclick="delUser(\'' + esc(u.login) + '\')">Өшіру</button>' : "") + "</td></tr>";
});
html += "</tbody></table></div>";
document.getElementById("usersList").innerHTML = html;
}
async function addUser() {
var login = document.getElementById("uLogin").value.trim();
var name = document.getElementById("uName").value.trim();
var pass = document.getElementById("uPass").value.trim();
var role = document.getElementById("uRole").value;
var msg = document.getElementById("uMsg");
if (!login || !name || !pass) { msg.innerHTML = '<div class="msg err">Барлық өрістерді толтырыңыз</div>'; return; }
if (state.offline) {
MOCK_USERS.push({ login: login, name: name, password: pass, role: role });
msg.innerHTML = '<div class="msg ok">✅ Қосылды (демо)</div>';
loadUsersList();
return;
}
var res = await apiPost("/api/users", { login: login, name: name, password: pass, role: role });
if (res.ok) {
msg.innerHTML = '<div class="msg ok">✅ Қызметкер қосылды</div>';
document.getElementById("uLogin").value = "";
document.getElementById("uName").value = "";
document.getElementById("uPass").value = "";
loadUsersList();
} else msg.innerHTML = '<div class="msg err">' + esc(res.error || "Қате") + "</div>";
}
async function delUser(login) {
if (!confirm("«" + login + "» аккаунтын өшіру керек пе?")) return;
if (state.offline) {
var i = MOCK_USERS.findIndex(function (u) { return u.login === login; });
if (i >= 0) MOCK_USERS.splice(i, 1);
loadUsersList();
return;
}
var res = await fetch(apiUrl() + "/api/users/" + encodeURIComponent(login), {
method: "DELETE", headers: { "Authorization": state.token ? "Bearer " + state.token : "" },
});
if (res.ok) loadUsersList(); else alert("Қате");
}
async function resetUserPass(login) {
var np = prompt("«" + login + "» үшін жаңа құпия сөз:");
if (!np) return;
if (state.offline) {
var u = MOCK_USERS.find(function (x) { return x.login === login; });
if (u) u.password = np;
loadUsersList();
return;
}
var res = await apiPost("/api/users/" + encodeURIComponent(login) + "/password", { password: np });
if (res.ok) loadUsersList(); 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 answers = t.questions.map(function (q, qi) { return state.curTestAnswers[qi]; });
if (state.offline) {
var correct = 0;
t.questions.forEach(function (q, qi) { if (state.curTestAnswers[qi] === q.correct) correct++; });
var pct = Math.round(correct / total * 100);
state.testResults.push({ testId: t.id, login: state.user.login, name: state.user.name, score: pct, date: new Date().toISOString().slice(0, 10) });
lsSet("testResults", state.testResults);
showTestResult(pct, correct, total);
return;
}
var res = await apiPost("/api/test-results", { testId: t.id, answers: answers });
if (res.ok) {
await loadServerData();
showTestResult(res.score, res.correct, res.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>";
if (state.user.role === "admin") {
html += '<div class="grid grid-3" style="margin-top:16px">';
html += '<div class="card stat-card"><div class="stat-num">' + (data.dmTotal || 0) + "</div><div class=\"stat-label\">Жеке хаттар (барлығы)</div></div>";
html += '<div class="card stat-card orange"><div class="stat-num">' + (data.dmOpen || 0) + "</div><div class=\"stat-label\">Жауап күтіп тұрған хаттар</div></div>";
html += '<div class="card stat-card green"><div class="stat-num">' + (data.dmDone || 0) + "</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>';
html += '<div class="section-title" style="margin-top:28px">🔒 Комплаенс офицерге жеке хаттар</div><div id="statsDm"></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>";
if (c.reply) {
chtml += '<div class="reply-box"><b>📨 Жауап:</b><p>' + esc(c.reply) + "</p></div>";
}
chtml += "</div>";
});
document.getElementById("statsComplaints").innerHTML = chtml || '<div class="empty">Шағым жоқ</div>';
if (state.user.role === "admin") {
var dms = state.directMessages || [];
dms.sort(function (a, b) { return b.id - a.id; });
var dhtml = "";
dms.forEach(function (m) {
var st = { "new": "Жаңа", "work": "Қаралуда", "done": "Шешілді" }[m.status] || m.status;
dhtml += '<div class="card complaint-item ' + m.status + '" style="margin-bottom:10px">';
dhtml += '<div class="spread"><span class="cat-tag">' + esc(m.category) + "</span><span>" + (m.anonymous ? "Аноним" : esc(m.authorName)) + ' · <span class="badge ' + m.status + '">' + st + "</span></span></div>";
dhtml += '<p style="margin-top:8px">' + esc(m.text) + "</p>";
if (m.reply) {
dhtml += '<div class="reply-box"><b>📨 Жауап:</b><p>' + esc(m.reply) + "</p></div>";
}
dhtml += '<div class="row" style="margin-top:10px;gap:8px;align-items:flex-start;flex-wrap:wrap">';
dhtml += '<input id="replyDm_' + m.id + '" class="reply-input" placeholder="Жауап жазыңыз және «Жіберу» басыңыз...">';
dhtml += '<button class="btn btn-sm btn-cyan" onclick="replyDm(' + m.id + ')">Жіберу</button>';
if (m.status !== "done") {
if (m.status !== "work") dhtml += '<button class="btn btn-sm btn-ghost" onclick="setDmStatus(' + m.id + ',\'work\')">Қаралуда</button>';
dhtml += '<button class="btn btn-sm btn-primary" onclick="setDmStatus(' + m.id + ',\'done\')">Шешілді</button>';
}
dhtml += "</div></div>";
});
document.getElementById("statsDm").innerHTML = dhtml || '<div class="empty">Жеке хаттар жоқ</div>';
}
}
async function replyDm(id) {
var reply = document.getElementById("replyDm_" + id).value.trim();
if (!reply) { alert("Жауап мәтінін жазыңыз"); return; }
if (state.offline) {
var list = lsGet("directMessages", []);
var m = list.find(function (x) { return x.id === id; });
if (m) {
m.reply = reply;
m.replyDate = new Date().toISOString().slice(0, 10);
if (m.status === "new") m.status = "work";
lsSet("directMessages", list);
}
renderStats(document.getElementById("content"));
return;
}
var res = await apiPost("/api/direct-messages/" + id + "/reply", { reply: reply });
if (res.ok) {
await loadServerData();
renderStats(document.getElementById("content"));
} else alert(res.error || "Қате");
}
async function setDmStatus(id, status) {
if (state.offline) {
var list = lsGet("directMessages", []);
var m = list.find(function (x) { return x.id === id; });
if (m) { m.status = status; lsSet("directMessages", list); }
renderStats(document.getElementById("content"));
return;
}
var res = await apiPost("/api/direct-messages/" + id + "/status", { status: status });
if (res.ok) {
await loadServerData();
renderStats(document.getElementById("content"));
} else alert(res.error || "Қате");
}
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 };
});
var dms = state.directMessages || [];
var dmOpen = dms.filter(function (m) { return m.status !== "done"; }).length;
return {
users: state.offline ? MOCK_USERS.length : 0,
complaintsTotal: complaints.length,
complaintsOpen: byStatus.new + byStatus.work,
testsTotal: results.length,
dmTotal: dms.length,
dmOpen: dmOpen,
dmDone: dms.length - dmOpen,
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;");
}