v1: комплаенс порталы — логин, басшылық, видео, заңдар, шағымдар, тесттер, дашборд
This commit is contained in:
parent
925f4f4d9f
commit
0af5fc4d2d
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.vibe42-run.log
|
||||
.vibe42-run.pid
|
||||
59
data.json
Normal file
59
data.json
Normal 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
64
index.html
Normal 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
10
package-lock.json
generated
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "qonaev-compliance",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "qonaev-compliance"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
package.json
Normal file
6
package.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "qonaev-compliance",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": { "start": "node server.js" }
|
||||
}
|
||||
623
script.js
Normal file
623
script.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
183
server.js
Normal file
183
server.js
Normal 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
242
style.css
Normal 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}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user