v1: комплаенс порталы — логин, басшылық, видео, заңдар, шағымдар, тесттер, дашборд

This commit is contained in:
Dauren777 2026-08-11 07:22:31 +00:00
parent 925f4f4d9f
commit 0af5fc4d2d
8 changed files with 1190 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.vibe42-run.log
.vibe42-run.pid

59
data.json Normal file
View File

@ -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": []
}

64
index.html Normal file
View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="kk">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Qonaev TV — Комплаенс порталы</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🛡️</text></svg>">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- ===== КІРУ ЭКРАНЫ ===== -->
<div id="loginScreen" class="login-wrap">
<div class="login-card">
<div class="login-logo">🛡️</div>
<h1>Qonaev TV</h1>
<p class="login-sub">Комплаенс порталы</p>
<form id="loginForm">
<label>Логин (жеке нөміріңіз)</label>
<input type="text" id="loginNum" inputmode="numeric" autocomplete="username" placeholder="Мысалы: 1001" required>
<label>Құпия сөз</label>
<input type="password" id="loginPass" autocomplete="current-password" placeholder="Ағылшын әріптерімен" required>
<div id="loginError" class="login-error"></div>
<button type="submit" class="btn btn-primary btn-block">Кіру</button>
</form>
<p class="login-hint">Демо: админ — 1001 / Admin123 · қызметкер — 2001 / Worker123</p>
</div>
</div>
<!-- ===== ҚОЛДАНБА ===== -->
<div id="app" class="app hidden">
<aside class="sidebar">
<div class="side-brand">
<span class="side-logo">🛡️</span>
<div>
<div class="side-name">Qonaev TV</div>
<div class="side-sub">Комплаенс</div>
</div>
</div>
<nav id="nav" class="nav"></nav>
<div class="side-user">
<div class="side-avatar" id="userAvatar">?</div>
<div class="side-userinfo">
<div class="side-username" id="userName">—</div>
<button class="side-logout" id="logoutBtn">Шығу</button>
</div>
</div>
</aside>
<main class="main">
<div class="topbar">
<h2 id="pageTitle">Басты бет</h2>
<div class="topbar-right">
<div class="role-badge" id="roleBadge"></div>
</div>
</div>
<div id="content" class="content"></div>
</main>
</div>
<script src="script.js"></script>
</body>
</html>

10
package-lock.json generated Normal file
View File

@ -0,0 +1,10 @@
{
"name": "qonaev-compliance",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "qonaev-compliance"
}
}
}

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"name": "qonaev-compliance",
"private": true,
"type": "commonjs",
"scripts": { "start": "node server.js" }
}

623
script.js Normal file
View File

@ -0,0 +1,623 @@
/* ============================================================
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: "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: "Ерлан Қасымов" },
{ 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: "Сыбайлас жемқорлыққа қарсы негіздер", 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 === "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" : "") + '"><div class="org-name">' + esc(o.name) + '</div><div class="org-pos">' + esc(o.person) + "</div></div>";
});
html += "</div>";
html += '<div class="org-arrow">▼</div>';
});
html = html.replace(/<div class="org-arrow">▼<\/div>\s*$/, "");
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;");
}

183
server.js Normal file
View File

@ -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));

242
style.css Normal file
View File

@ -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}
}