diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..25006ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+.vibe42-run.log
+.vibe42-run.pid
diff --git a/data.json b/data.json
new file mode 100644
index 0000000..0a28bc7
--- /dev/null
+++ b/data.json
@@ -0,0 +1,59 @@
+{
+ "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" }
+ ],
+ "announcements": [
+ { "id": 1, "title": "Жылдық комплаенс оқуы басталды", "text": "Барлық қызметкерлер 1 қарашаға дейін «Сыбайлас жемқорлыққа қарсы» тестін тапсыруы қажет. Видео сабақтар мен заңдар бөлімін қарап шығыңыз.", "date": "2026-08-11" },
+ { "id": 2, "title": "Мүдделер қақтығысын декларациялау", "text": "Қызметкерлер жыл сайын мүдделер қақтығысы туралы декларация толтырады. Нысан шағым жәшігі бөлімінен алуға болады.", "date": "2026-08-05" }
+ ],
+ "org": [
+ { "level": 1, "name": "Бас директор", "person": "Ерлан Қасымов" },
+ { "level": 2, "name": "Басқарма төрағасы", "person": "Гүлнара Әбенова" },
+ { "level": 3, "name": "Қаржы дирекциясы", "person": "Серік Жұмабаев" },
+ { "level": 3, "name": "Эфир дирекциясы", "person": "Айжан Төлегенова" },
+ { "level": 3, "name": "Жаңалықтар дирекциясы", "person": "Марат Бекенов" },
+ { "level": 3, "name": "Техникалық дирекция", "person": "Нұрлан Сыдықов" },
+ { "level": 3, "name": "HR және әкімшілік", "person": "Ләззат Қалиева" },
+ { "level": 4, "name": "Комплаенс қызметі", "person": "Асет (сіз)" }
+ ],
+ "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": "Сыбайлас жемқорлыққа қарсы негіздер", "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": "Іскерлік этика кодексі", "pass": 70,
+ "questions": [
+ { "q": "Іскерлік этика кодексінің мақсаты?", "options": ["Қызметкерлерді жазалау", "Мінез-құлық стандарттарын белгілеу", "Жалақыны азайту", "Каникулды қысқарту"], "correct": 1 },
+ { "q": "Алқаптас әріптеске қалай қарау керек?", "options": ["Құрметпен және теңдікте", "Менсінбеу", "Елемеу", "Тек басшыларға ғана құрмет"], "correct": 0 },
+ { "q": "Конфиденциалды ақпаратты не істеуге болмайды?", "options": ["Қауіпсіз жерде сақтау", "Үшінші тұлғаларға тарату", "Пароль қою", "Құлыптап қою"], "correct": 1 },
+ { "q": "Жемқорлық әрекетке қатысуды сұраса, не істеу керек?", "options": ["Келісу", "Бас тартып, комплаенс қызметіне хабарлау", "Ойланып жауап беру", "Ешкімге айтпау"], "correct": 1 },
+ { "q": "Тест нәтижелері мен оқу материалдары кімге қажет?", "options": ["Тек HR-ға", "Барлық қызметкерлерге", "Тек админге", "Тек басшыларға"], "correct": 1 }
+ ]
+ }
+ ],
+ "complaints": [],
+ "testResults": []
+}
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..bf12e4c
--- /dev/null
+++ b/index.html
@@ -0,0 +1,64 @@
+
+
+
+▼<\/div>\s*$/, "");
+ el.innerHTML = html;
+}
+
+/* ---------------- ВИДЕО ---------------- */
+function renderVideo(el) {
+ var vids = state.content ? state.content.videos : [];
+ var html = pageTitle("🎬 Видео сабақтар");
+ html += '
';
+ vids.forEach(function (v) {
+ html += '
';
+ html += '
▶
';
+ html += "
" + esc(v.title) + ' ' + esc(v.desc) + '
⏱ ' + v.dur + "
";
+ html += "
";
+ });
+ html += "
";
+ 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 += '
';
+ html += 'Барлығы ';
+ cats.forEach(function (c) { html += '' + esc(c) + " "; });
+ html += "
";
+ html += '
';
+ 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 += '
' + esc(l.title) + ' ' + esc(l.cat) + " " + esc(l.text) + "
";
+ });
+ document.getElementById("lawList").innerHTML = html || '
Табылмады
';
+}
+
+/* ---------------- ШАҒЫМ ЖӘШІГІ ---------------- */
+function renderComplaints(el) {
+ var html = pageTitle("✉️ Шағым жәшігі");
+ html += '
Жаңа шағым / өтініш ';
+ html += '
';
+
+ html += '
Менің шағымдарым' + (state.user.role === "admin" ? " және барлық өтініштер" : "") + "
";
+ html += '
';
+ 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 += '
';
+ html += '
' + esc(c.category) + " ";
+ html += '' + st + "
";
+ html += '
' + (c.anonymous ? "Аноним" : esc(c.authorName)) + " · " + c.date + " ";
+ html += "
" + esc(c.text) + "
";
+ if (state.user.role === "admin" && c.status !== "done") {
+ html += '
';
+ if (c.status !== "work") html += 'Қаралуда ';
+ html += 'Шешілді ';
+ html += "
";
+ }
+ html += "
";
+ });
+ document.getElementById("compList").innerHTML = html || '
Әзірге шағым жоқ
';
+}
+
+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 = '
Мәтінді толтырыңыз
'; 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 = '
✅ Шағымыңыз жіберілді (демо-режимде жергілікті сақталды)
';
+ 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 = '
✅ Шағымыңыз қабылданды. Рахмет!
';
+ document.getElementById("compText").value = "";
+ await loadServerData();
+ renderComplaintList();
+ } else document.getElementById("compMsg").innerHTML = '
' + esc(res.error || "Қате") + "
";
+}
+
+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 += '
';
+ tests.forEach(function (t) {
+ var done = state.testResults.filter(function (r) { return r.testId === t.id; });
+ html += '
' + esc(t.title) + " ";
+ html += "
Сұрақтар саны: " + t.questions.length + " · өту үшін: " + t.pass + "%
";
+ html += '
Әрекеттер: ' + done.length;
+ if (done.length) {
+ var last = done[done.length - 1];
+ html += " · соңғысы: " + last.score + "% ";
+ }
+ html += "
";
+ html += '
Бастау ';
+ });
+ html += "
";
+ html += '
Менің нәтижелерім
';
+ html += '
';
+ el.innerHTML = html;
+
+ var mine = state.testResults.filter(function (r) { return r.login === state.user.login; }).slice().reverse();
+ var th = "";
+ if (mine.length) {
+ th = "
Тест Нәтиже Күні ";
+ mine.forEach(function (r) {
+ var tn = tests.find(function (t) { return t.id === r.testId; });
+ th += "" + esc(tn ? tn.title : "Тест") + " = (tn ? tn.pass : 70) ? "done" : "new") + "\">" + r.score + "% " + r.date + " ";
+ });
+ th += "
";
+ }
+ document.getElementById("myTestResults").innerHTML = th || '
Әзірге тест тапсырған жоқсыз
';
+}
+
+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 = '
← Артқа ';
+ html += '
' + esc(t.title) + "
";
+ t.questions.forEach(function (q, qi) {
+ html += '
' + (qi + 1) + ". " + esc(q.q) + " ";
+ q.options.forEach(function (op, oi) {
+ html += '' + esc(op) + " ";
+ });
+ html += "";
+ });
+ html += '
Нәтижені жіберу ';
+ 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 = '
';
+ html += '
' + pct + "%
";
+ html += "
Дұрыс жауаптар: " + score + " / " + total + "
";
+ html += '
' + (pct >= 70 ? "✅ Тест сәтті өтілді!" : "Тест өтілмеді, қайта тапсыруға болады") + "
";
+ html += '
Тесттерге оралу ';
+ html += "
";
+ 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 = '
Деректер жоқ
'; return; }
+
+ html += '
';
+ html += '
' + data.users + "
Қызметкерлер
";
+ html += '
' + data.complaintsTotal + "
Шағымдар (барлығы)
";
+ html += '
' + data.complaintsOpen + "
Ашық шағымдар
";
+ html += '
' + data.testsTotal + "
Тест тапсырулар
";
+ html += "
";
+
+ html += '
';
+ html += '
';
+ html += '
';
+ html += "
";
+
+ html += '
Тест нәтижелері бойынша орташа балл
';
+
+ html += '
Соңғы шағымдар
';
+ 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 += '
";
+ });
+ document.getElementById("catBars").innerHTML = catHtml || '
Жоқ
';
+
+ 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 += '
";
+ });
+ document.getElementById("passBars").innerHTML = passHtml;
+
+ var avgHtml = "";
+ data.byTest.forEach(function (t) {
+ avgHtml += '
";
+ });
+ document.getElementById("avgBars").innerHTML = avgHtml || '
Тест әлі жоқ
';
+
+ 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 += '
' + esc(c.category) + " " + (c.anonymous ? "Аноним" : esc(c.authorName)) + ' · ' + st + "
" + esc(c.text) + "
";
+ });
+ document.getElementById("statsComplaints").innerHTML = chtml || '
Шағым жоқ
';
+}
+
+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, "&").replace(//g, ">").replace(/"/g, """);
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..8335405
--- /dev/null
+++ b/server.js
@@ -0,0 +1,183 @@
+const http = require("http");
+const fs = require("fs");
+const path = require("path");
+const crypto = require("crypto");
+
+const PORT = process.env.PORT || 3000;
+const DATA_FILE = path.join(__dirname, "data.json");
+
+function load() {
+ try { return JSON.parse(fs.readFileSync(DATA_FILE, "utf8")); }
+ catch (e) { return { users: [], announcements: [], org: [], videos: [], laws: [], tests: [], complaints: [], testResults: [] }; }
+}
+function save(d) {
+ fs.writeFileSync(DATA_FILE, JSON.stringify(d, null, 2));
+}
+
+const sessions = new Map();
+
+function body(req) {
+ return new Promise((resolve) => {
+ let s = "";
+ req.on("data", (c) => (s += c));
+ req.on("end", () => {
+ try { resolve(JSON.parse(s || "{}")); } catch (e) { resolve({}); }
+ });
+ });
+}
+
+function send(res, code, obj) {
+ res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
+ res.end(JSON.stringify(obj));
+}
+
+function getUser(req) {
+ const h = req.headers["authorization"] || "";
+ const token = h.replace(/^Bearer\s+/i, "");
+ return sessions.get(token) || null;
+}
+
+function publicUser(u) {
+ return { login: u.login, name: u.name, role: u.role };
+}
+
+http.createServer(async (req, res) => {
+ const url = req.url.split("?")[0];
+ const db = load();
+
+ // ---------- ЛОГИН ----------
+ if (req.method === "POST" && url === "/api/login") {
+ const b = await body(req);
+ const u = db.users.find((x) => x.login === String(b.login) && x.password === String(b.password));
+ if (!u) return send(res, 401, { ok: false, error: "Логин немесе құпия сөз қате" });
+ const token = crypto.randomBytes(24).toString("hex");
+ sessions.set(token, u);
+ return send(res, 200, { ok: true, token, user: publicUser(u) });
+ }
+
+ // ---------- АУТЕНТИФИКАЦИЯ ----------
+ const user = getUser(req);
+ const isApi = url.startsWith("/api/");
+ if (isApi && !user) return send(res, 401, { ok: false, error: "Кіру қажет" });
+
+ // ---------- КОНТЕНТ ----------
+ if (req.method === "GET" && url === "/api/content") {
+ return send(res, 200, { ok: true, data: {
+ announcements: db.announcements,
+ org: db.org,
+ videos: db.videos,
+ laws: db.laws,
+ tests: db.tests.map((t) => ({
+ id: t.id, title: t.title, pass: t.pass,
+ questions: t.questions.map((q) => ({ q: q.q, options: q.options })),
+ })),
+ }});
+ }
+
+ // ---------- ХАБАРЛАНДЫРУ (админ) ----------
+ if (req.method === "POST" && url === "/api/announcements") {
+ if (user.role !== "admin") return send(res, 403, { ok: false, error: "Құқық жоқ" });
+ const b = await body(req);
+ if (!b.title || !b.text) return send(res, 400, { ok: false, error: "Тақырып пен мәтінді толтырыңыз" });
+ db.announcements.unshift({ id: Date.now(), title: b.title, text: b.text, date: b.date || new Date().toISOString().slice(0, 10) });
+ save(db);
+ return send(res, 200, { ok: true });
+ }
+
+ // ---------- ШАҒЫМДАР ----------
+ if (req.method === "GET" && url === "/api/complaints") {
+ let list = db.complaints;
+ if (user.role !== "admin") {
+ list = list.filter((c) => c.authorLogin === user.login || c.anonymous);
+ }
+ return send(res, 200, { ok: true, data: list });
+ }
+
+ if (req.method === "POST" && url === "/api/complaints") {
+ const b = await body(req);
+ if (!b.text) return send(res, 400, { ok: false, error: "Мәтінді толтырыңыз" });
+ const item = {
+ id: Date.now(),
+ category: b.category || "Басқа",
+ text: String(b.text).slice(0, 3000),
+ anonymous: !!b.anonymous,
+ status: "new",
+ date: new Date().toISOString().slice(0, 10),
+ authorLogin: b.anonymous ? null : user.login,
+ authorName: b.anonymous ? null : user.name,
+ };
+ db.complaints.unshift(item);
+ save(db);
+ return send(res, 200, { ok: true });
+ }
+
+ const stMatch = url.match(/^\/api\/complaints\/(\d+)\/status$/);
+ if (req.method === "POST" && stMatch) {
+ if (user.role !== "admin") return send(res, 403, { ok: false, error: "Құқық жоқ" });
+ const b = await body(req);
+ const c = db.complaints.find((x) => x.id === Number(stMatch[1]));
+ if (!c) return send(res, 404, { ok: false, error: "Табылмады" });
+ if (["new", "work", "done"].includes(b.status)) c.status = b.status;
+ save(db);
+ return send(res, 200, { ok: true });
+ }
+
+ // ---------- ТЕСТ НӘТИЖЕЛЕРІ ----------
+ if (req.method === "GET" && url === "/api/test-results") {
+ let list = db.testResults;
+ if (user.role !== "admin") list = list.filter((r) => r.login === user.login);
+ return send(res, 200, { ok: true, data: list });
+ }
+
+ if (req.method === "POST" && url === "/api/test-results") {
+ const b = await body(req);
+ const test = db.tests.find((t) => t.id === Number(b.testId));
+ if (!test) return send(res, 400, { ok: false, error: "Тест табылмады" });
+ db.testResults.push({
+ id: Date.now(),
+ testId: test.id,
+ login: user.login,
+ name: user.name,
+ score: Number(b.score) || 0,
+ date: new Date().toISOString().slice(0, 10),
+ });
+ save(db);
+ return send(res, 200, { ok: true });
+ }
+
+ // ---------- СТАТИСТИКА (админ) ----------
+ if (req.method === "GET" && url === "/api/stats") {
+ if (user.role !== "admin") return send(res, 403, { ok: false, error: "Құқық жоқ" });
+ const byStatus = { new: 0, work: 0, done: 0 };
+ const byCategory = {};
+ db.complaints.forEach((c) => {
+ byStatus[c.status] = (byStatus[c.status] || 0) + 1;
+ byCategory[c.category] = (byCategory[c.category] || 0) + 1;
+ });
+ const byTest = db.tests.map((t) => {
+ const rs = db.testResults.filter((r) => r.testId === t.id);
+ const avg = rs.length ? Math.round(rs.reduce((s, r) => s + r.score, 0) / rs.length) : 0;
+ return { title: t.title, count: rs.length, avg };
+ });
+ return send(res, 200, { ok: true, data: {
+ users: db.users.length,
+ complaintsTotal: db.complaints.length,
+ complaintsOpen: byStatus.new + byStatus.work,
+ testsTotal: db.testResults.length,
+ byStatus,
+ byCategory: Object.keys(byCategory).map((k) => ({ cat: k, count: byCategory[k] })),
+ byTest,
+ }});
+ }
+
+ // ---------- СТАТИКА ----------
+ let file = url === "/" ? "/index.html" : url;
+ const full = path.join(__dirname, file);
+ if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
+ const ext = path.extname(full);
+ const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : ext === ".json" ? "application/json" : "text/html";
+ res.writeHead(200, { "Content-Type": type + "; charset=utf-8" });
+ return res.end(fs.readFileSync(full));
+ }
+ send(res, 404, { ok: false, error: "Not found" });
+}).listen(PORT, () => console.log("Qonaev TV compliance порталы: http://localhost:" + PORT));
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..53daeda
--- /dev/null
+++ b/style.css
@@ -0,0 +1,242 @@
+:root{
+ --ink:#0F1218;
+ --cyan:#00E5FF;
+ --cyan-50:#E8FCFF;
+ --white:#fff;
+ --gray-500:#5B6573;
+ --gray-100:#F2F4F7;
+ --sidebar:#0F1218;
+ --accent:#2F80ED;
+ --green:#27AE60;
+ --red:#EB5757;
+ --orange:#F2994A;
+}
+*{box-sizing:border-box;margin:0;padding:0}
+html,body{height:100%}
+body{
+ font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
+ color:var(--ink);background:#F5F7FA;
+}
+.hidden{display:none !important}
+button{font-family:inherit}
+
+/* ===== КІРУ ЭКРАНЫ ===== */
+.login-wrap{
+ min-height:100vh;display:flex;align-items:center;justify-content:center;
+ background:radial-gradient(1200px 600px at 20% -10%,#1B2B4A 0%,var(--ink) 55%);
+ padding:24px;
+}
+.login-card{
+ width:100%;max-width:420px;background:var(--white);border-radius:20px;
+ padding:40px 36px;box-shadow:0 24px 60px rgba(0,0,0,.35);
+}
+.login-logo{
+ width:64px;height:64px;border-radius:16px;background:var(--ink);
+ display:flex;align-items:center;justify-content:center;font-size:34px;margin:0 auto 18px;
+}
+.login-card h1{font-size:26px;text-align:center;font-weight:800}
+.login-sub{text-align:center;color:var(--gray-500);margin-bottom:28px}
+.login-card label{display:block;font-size:13px;font-weight:600;color:var(--gray-500);margin:14px 0 6px}
+.login-card input{
+ width:100%;padding:12px 14px;border:1.5px solid #DFE4EA;border-radius:10px;
+ font-size:16px;outline:none;transition:border-color .2s;
+}
+.login-card input:focus{border-color:var(--accent)}
+.login-error{color:var(--red);font-size:13px;min-height:18px;margin-top:10px}
+.login-hint{margin-top:18px;font-size:12px;color:var(--gray-500);text-align:center;background:var(--gray-100);border-radius:8px;padding:8px}
+
+/* ===== ҚОЛДАНБА ===== */
+.app{display:flex;min-height:100vh}
+.sidebar{
+ width:260px;background:var(--sidebar);color:#fff;flex-shrink:0;
+ display:flex;flex-direction:column;padding:22px 16px;position:sticky;top:0;height:100vh;
+}
+.side-brand{display:flex;align-items:center;gap:12px;padding:0 8px 20px;border-bottom:1px solid rgba(255,255,255,.08)}
+.side-logo{font-size:30px}
+.side-name{font-weight:800;font-size:17px}
+.side-sub{color:#8A93A6;font-size:12px}
+.nav{flex:1;padding:16px 0;overflow-y:auto}
+.nav-item{
+ display:flex;align-items:center;gap:12px;padding:11px 12px;margin-bottom:4px;
+ border-radius:10px;color:#B7BECC;cursor:pointer;font-size:15px;font-weight:500;
+ transition:background .15s,color .15s;border:none;background:none;width:100%;text-align:left;
+}
+.nav-item:hover{background:rgba(255,255,255,.06);color:#fff}
+.nav-item.active{background:var(--cyan);color:var(--ink);font-weight:700}
+.nav-item .ico{font-size:18px;width:24px;text-align:center}
+.nav-item.admin-only{color:#9BE4FF}
+.side-user{
+ display:flex;align-items:center;gap:10px;border-top:1px solid rgba(255,255,255,.08);
+ padding:14px 8px 0;
+}
+.side-avatar{
+ width:40px;height:40px;border-radius:50%;background:var(--cyan);color:var(--ink);
+ display:flex;align-items:center;justify-content:center;font-weight:800;font-size:17px;flex-shrink:0;
+}
+.side-username{font-size:14px;font-weight:600;word-break:break-word}
+.side-logout{background:none;border:none;color:#8A93A6;font-size:12px;cursor:pointer;padding:0;margin-top:2px}
+.side-logout:hover{color:var(--cyan)}
+
+.main{flex:1;min-width:0}
+.topbar{
+ display:flex;align-items:center;justify-content:space-between;
+ padding:20px 32px;background:var(--white);border-bottom:1px solid #E7EBF0;
+ position:sticky;top:0;z-index:10;
+}
+.topbar h2{font-size:21px;font-weight:800}
+.role-badge{font-size:12px;font-weight:700;padding:6px 12px;border-radius:999px;background:var(--cyan-50);color:#0B6E7A}
+.role-badge.admin{background:#EAF7EF;color:#1B7A3D}
+.content{padding:28px 32px 60px;max-width:1100px}
+
+/* ===== КАРТОЧКАЛАР / ГРИД ===== */
+.grid{display:grid;gap:16px}
+.grid-2{grid-template-columns:repeat(2,1fr)}
+.grid-3{grid-template-columns:repeat(3,1fr)}
+.grid-4{grid-template-columns:repeat(4,1fr)}
+.card{background:var(--white);border:1px solid #E7EBF0;border-radius:14px;padding:22px}
+.card h3{font-size:17px;font-weight:700;margin-bottom:12px}
+.card p{color:var(--gray-500)}
+.stat-card{border-left:4px solid var(--accent)}
+.stat-card.orange{border-color:var(--orange)}
+.stat-card.green{border-color:var(--green)}
+.stat-card.red{border-color:var(--red)}
+.stat-num{font-size:34px;font-weight:800;line-height:1.1}
+.stat-label{font-size:13px;color:var(--gray-500);margin-top:4px}
+
+/* ===== КЕСТЕ ===== */
+table{width:100%;border-collapse:collapse;background:var(--white);border-radius:14px;overflow:hidden;border:1px solid #E7EBF0}
+th,td{text-align:left;padding:12px 16px;border-bottom:1px solid #EFF2F6;font-size:14px;vertical-align:top}
+th{background:var(--gray-100);font-weight:700;font-size:13px;color:var(--gray-500);white-space:nowrap}
+tr:last-child td{border-bottom:none}
+
+/* ===== ФОРМА ===== */
+.form label{display:block;font-size:13px;font-weight:600;color:var(--gray-500);margin:14px 0 6px}
+.form input,.form textarea,.form select{
+ width:100%;padding:11px 14px;border:1.5px solid #DFE4EA;border-radius:10px;
+ font-size:15px;outline:none;font-family:inherit;resize:vertical;
+}
+.form input:focus,.form textarea:focus,.form select:focus{border-color:var(--accent)}
+.form-row{display:flex;gap:14px}
+.form-row>div{flex:1}
+
+/* ===== КНОПКА ===== */
+.btn{
+ display:inline-block;border:none;border-radius:10px;padding:12px 22px;
+ font-size:15px;font-weight:700;cursor:pointer;transition:opacity .15s,transform .1s;
+}
+.btn:hover{opacity:.9}
+.btn:active{transform:scale(.98)}
+.btn-primary{background:var(--accent);color:#fff}
+.btn-cyan{background:var(--cyan);color:var(--ink)}
+.btn-ghost{background:var(--gray-100);color:var(--ink)}
+.btn-danger{background:#FDECEC;color:var(--red)}
+.btn-block{width:100%}
+.btn-sm{padding:8px 14px;font-size:13px}
+
+/* ===== БӘДЖ ===== */
+.badge{display:inline-block;font-size:12px;font-weight:700;padding:4px 10px;border-radius:999px;white-space:nowrap}
+.badge.new{background:#FDECEC;color:var(--red)}
+.badge.work{background:#FEF3E2;color:var(--orange)}
+.badge.done{background:#EAF7EF;color:#1B7A3D}
+.badge.admin{background:var(--cyan-50);color:#0B6E7A}
+.badge.user{background:#EEF1F6;color:var(--gray-500)}
+
+/* ===== ҚАБЫЛДАУ / ХАБАРЛАУ ===== */
+.msg{
+ padding:12px 16px;border-radius:10px;font-size:14px;font-weight:600;margin:14px 0;
+}
+.msg.ok{background:#EAF7EF;color:#1B7A3D}
+.msg.err{background:#FDECEC;color:var(--red)}
+
+/* ===== ҚҰРЫЛЫМ (орг) ===== */
+.org-level{display:flex;flex-wrap:wrap;gap:12px;justify-content:center}
+.org-card{
+ background:var(--white);border:1px solid #E7EBF0;border-radius:12px;
+ padding:16px 20px;min-width:200px;text-align:center;
+}
+.org-card.top{background:var(--ink);color:#fff;border-color:var(--ink)}
+.org-name{font-weight:700;font-size:15px}
+.org-pos{font-size:12px;color:var(--gray-500);margin-top:2px}
+.org-card.top .org-pos{color:#9AA6BB}
+.org-arrow{text-align:center;color:var(--gray-500);font-size:20px;padding:6px 0}
+
+/* ===== ВИДЕО ===== */
+.video-card{cursor:pointer;overflow:hidden;display:flex;flex-direction:column}
+.video-thumb{
+ height:150px;background:var(--ink);border-radius:10px;margin-bottom:12px;
+ display:flex;align-items:center;justify-content:center;color:#fff;font-size:40px;
+ background-size:cover;background-position:center;
+}
+.video-card h3{font-size:15px}
+.video-card p{font-size:13px}
+
+/* ===== ТЕСТ ===== */
+.test-question{margin-bottom:20px}
+.test-question h4{font-size:15px;font-weight:700;margin-bottom:10px}
+.q-option{
+ display:block;padding:11px 14px;border:1.5px solid #DFE4EA;border-radius:10px;
+ margin-bottom:8px;cursor:pointer;transition:all .15s;
+}
+.q-option:hover{border-color:var(--accent);background:var(--cyan-50)}
+.q-option.selected{border-color:var(--accent);background:#E3F0FF;font-weight:600}
+.q-option.correct{border-color:var(--green);background:#EAF7EF;font-weight:700}
+.q-option.wrong{border-color:var(--red);background:#FDECEC}
+.q-option.disabled{cursor:default;pointer-events:none}
+.test-score{font-size:26px;font-weight:800;color:var(--green);text-align:center;margin:10px 0}
+
+/* ===== ЖАЛОБА ===== */
+.complaint-item{border-left:4px solid #DFE4EA}
+.complaint-item.new{border-color:var(--red)}
+.complaint-item.work{border-color:var(--orange)}
+.complaint-item.done{border-color:var(--green)}
+.cat-tag{font-size:12px;font-weight:700;padding:3px 10px;border-radius:999px;background:var(--gray-100);color:var(--gray-500)}
+
+/* ===== ДИАГРАММА ===== */
+.bar-row{display:flex;align-items:center;gap:10px;margin-bottom:10px}
+.bar-label{width:180px;font-size:13px;font-weight:600;flex-shrink:0}
+.bar-track{flex:1;background:var(--gray-100);border-radius:8px;height:26px;overflow:hidden}
+.bar-fill{height:100%;background:var(--accent);border-radius:8px;display:flex;align-items:center;justify-content:flex-end;padding-right:8px;color:#fff;font-size:12px;font-weight:700;min-width:26px;transition:width .5s}
+.bar-fill.green{background:var(--green)}
+.bar-fill.orange{background:var(--orange)}
+.bar-fill.red{background:var(--red)}
+
+/* ===== УТИЛИТ ===== */
+.row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
+.spread{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap}
+.muted{color:var(--gray-500);font-size:13px}
+.empty{text-align:center;color:var(--gray-500);padding:30px 0}
+.section-title{font-size:19px;font-weight:800;margin-bottom:16px;display:flex;align-items:center;gap:10px}
+.chip-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
+.chip{
+ border:1.5px solid #DFE4EA;background:var(--white);border-radius:999px;
+ padding:7px 16px;font-size:13px;font-weight:600;cursor:pointer;
+}
+.chip.active{background:var(--accent);border-color:var(--accent);color:#fff}
+.announce{background:var(--white);border:1px solid #E7EBF0;border-radius:12px;padding:18px 20px;margin-bottom:12px;border-left:4px solid var(--cyan)}
+.announce h3{font-size:16px;margin-bottom:4px}
+.announce .date{font-size:12px;color:var(--gray-500);margin-bottom:8px}
+.tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:18px}
+.tab{
+ border:1.5px solid #DFE4EA;background:var(--white);border-radius:10px;
+ padding:9px 18px;font-size:14px;font-weight:600;cursor:pointer;
+}
+.tab.active{background:var(--ink);border-color:var(--ink);color:#fff}
+.search{width:100%;max-width:360px;padding:10px 14px;border:1.5px solid #DFE4EA;border-radius:10px;font-size:14px;outline:none}
+.search:focus{border-color:var(--accent)}
+
+@media (max-width:900px){
+ .grid-4{grid-template-columns:repeat(2,1fr)}
+ .grid-3{grid-template-columns:repeat(2,1fr)}
+ .grid-2{grid-template-columns:1fr}
+ .sidebar{width:76px;padding:22px 10px}
+ .side-brand>div,.side-sub,.side-name,.nav-item span:not(.ico),.side-username{display:none}
+ .nav-item{justify-content:center;padding:11px}
+ .side-user{justify-content:center}
+ .side-avatar{margin:0}
+ .content{padding:20px 16px}
+ .topbar{padding:16px}
+}
+@media (max-width:560px){
+ .grid-3,.grid-4{grid-template-columns:1fr}
+ .form-row{flex-direction:column}
+}