790 lines
42 KiB
JavaScript
790 lines
42 KiB
JavaScript
// ─── Seed Data ──────────────────────────────────────────────────────────────
|
||
const ALL_EVENTS = [
|
||
{
|
||
id: "evt-1", title: "Проведение вводного инструктажа по пожарной безопасности", responsible: "Ахметов Т.К.",
|
||
section: "Обучение и культура безопасности", deadline: "2026-06-15", status: "completed",
|
||
branch: "Дирекция ПБ", region: "Астана", description: "Инструктаж проведён для 45 сотрудников",
|
||
quantity: 45, subItems: [
|
||
{ id: "1.1", title: "Подготовка материалов инструктажа", status: "completed" },
|
||
{ id: "1.2", title: "Проведение практического занятия", status: "completed" }
|
||
], history: [
|
||
{ timestamp: "2026-06-01T10:00:00", action: "created" },
|
||
{ timestamp: "2026-06-10T14:30:00", action: "updated", changes: { status: "completed" } }
|
||
], created_at: "2026-06-01"
|
||
},
|
||
{
|
||
id: "evt-2", title: "Проверка систем автоматического пожаротушения на объектах", responsible: "Сериков Д.А.",
|
||
section: "Техническая безопасность", deadline: "2026-09-20", status: "in_progress",
|
||
branch: "Дивизион «Сеть»", region: "Алматы", description: "Проверено 12 из 18 объектов",
|
||
quantity: 12, subItems: [
|
||
{ id: "2.1", title: "Инвентаризация систем пожаротушения", status: "completed" },
|
||
{ id: "2.2", title: "Техническое тестирование датчиков", status: "in_progress" },
|
||
{ id: "2.3", title: "Замена неисправных элементов", status: "pending" }
|
||
], history: [
|
||
{ timestamp: "2026-05-15T09:00:00", action: "created" },
|
||
{ timestamp: "2026-06-05T11:00:00", action: "updated", changes: { status: "in_progress", quantity: 12 } }
|
||
], created_at: "2026-05-15"
|
||
},
|
||
{
|
||
id: "evt-3", title: "Разработка плана эвакуации для нового офисного здания", responsible: "Куанышева А.М.",
|
||
section: "Готовность к ЧС", deadline: "2026-08-01", status: "pending",
|
||
branch: "Корпоративный бизнес", region: "Шымкент", description: "Требуется разработка плана с учётом новых нормативов",
|
||
quantity: 0, subItems: [], history: [
|
||
{ timestamp: "2026-06-01T08:00:00", action: "created" }
|
||
], created_at: "2026-06-01"
|
||
},
|
||
{
|
||
id: "evt-4", title: "Замена огнетушителей с истекшим сроком годности", responsible: "Нурланов Е.С.",
|
||
section: "Техническая безопасность", deadline: "2026-04-15", status: "overdue",
|
||
branch: "Розничный бизнес", region: "Караганда", description: "Срок истёк 15 апреля, срочно требуется замена 67 единиц",
|
||
quantity: 67, subItems: [
|
||
{ id: "4.1", title: "Инвентаризация огнетушителей", status: "completed" },
|
||
{ id: "4.2", title: "Закупка новых огнетушителей", status: "overdue" },
|
||
{ id: "4.3", title: "Установка и утилизация старых", status: "pending" }
|
||
], history: [
|
||
{ timestamp: "2026-03-01T10:00:00", action: "created" },
|
||
{ timestamp: "2026-05-20T09:00:00", action: "updated", changes: { status: "overdue" } }
|
||
], created_at: "2026-03-01"
|
||
},
|
||
{
|
||
id: "evt-5", title: "Обучение персонала оказанию первой помощи", responsible: "Мусаева Г.Р.",
|
||
section: "Обучение и культура безопасности", deadline: "2026-06-20", status: "in_progress",
|
||
branch: "Корп. университет", region: "Астана", description: "Проведено 3 из 8 запланированных тренингов",
|
||
quantity: 3, subItems: [
|
||
{ id: "5.1", title: "Программа обучения", status: "completed" },
|
||
{ id: "5.2", title: "Тренинг для руководителей", status: "completed" },
|
||
{ id: "5.3", title: "Тренинг для линейного персонала", status: "in_progress" },
|
||
{ id: "5.4", title: "Итоговая аттестация", status: "pending" }
|
||
], history: [
|
||
{ timestamp: "2026-04-01T08:00:00", action: "created" },
|
||
{ timestamp: "2026-05-15T14:00:00", action: "updated", changes: { quantity: 3 } }
|
||
], created_at: "2026-04-01"
|
||
},
|
||
{
|
||
id: "evt-6", title: "Внедрение цифровой системы учёта происшествий", responsible: "Тулегенов Б.К.",
|
||
section: "Цифровизация", deadline: "2026-05-30", status: "completed",
|
||
branch: "Цифровой бизнес", region: "Алматы", description: "Система запущена в промышленную эксплуатацию, обучено 120 пользователей",
|
||
quantity: 120, subItems: [
|
||
{ id: "6.1", title: "Разработка ТЗ", status: "completed" },
|
||
{ id: "6.2", title: "Пилотное внедрение", status: "completed" },
|
||
{ id: "6.3", title: "Обучение пользователей", status: "completed" }
|
||
], history: [
|
||
{ timestamp: "2026-02-01T09:00:00", action: "created" },
|
||
{ timestamp: "2026-05-30T16:00:00", action: "updated", changes: { status: "completed", quantity: 120 } }
|
||
], created_at: "2026-02-01"
|
||
},
|
||
{
|
||
id: "evt-7", title: "Проведение учений по ликвидации аварийного разлива нефтепродуктов", responsible: "Рахимов Ж.Н.",
|
||
section: "Готовность к ЧС", deadline: "2026-07-25", status: "pending",
|
||
branch: "Сервисная фабрика", region: "Атырау", description: "Согласование сценария учений с МЧС",
|
||
quantity: 0, subItems: [], history: [
|
||
{ timestamp: "2026-06-05T11:00:00", action: "created" }
|
||
], created_at: "2026-06-05"
|
||
},
|
||
{
|
||
id: "evt-8", title: "Аудит системы управления охраной труда в филиалах", responsible: "Садыкова Л.А.",
|
||
section: "Коммуникации", deadline: "2026-03-01", status: "overdue",
|
||
branch: "Управление проектами", region: "Актобе", description: "Аудит не проведён, требуется согласование графика",
|
||
quantity: 0, subItems: [
|
||
{ id: "8.1", title: "Формирование комиссии", status: "completed" },
|
||
{ id: "8.2", title: "Проверка документации", status: "overdue" },
|
||
{ id: "8.3", title: "Выезд на объекты", status: "pending" }
|
||
], history: [
|
||
{ timestamp: "2026-01-10T10:00:00", action: "created" },
|
||
{ timestamp: "2026-04-01T08:00:00", action: "updated", changes: { status: "overdue" } }
|
||
], created_at: "2026-01-10"
|
||
},
|
||
{
|
||
id: "evt-9", title: "Разработка корпоративного стандарта по работе на высоте", responsible: "Касымов А.Д.",
|
||
section: "Техническая безопасность", deadline: "2026-10-15", status: "pending",
|
||
branch: "Телеком Комплект", region: "Астана", description: "Стандарт находится на стадии согласования",
|
||
quantity: 0, subItems: [], history: [
|
||
{ timestamp: "2026-06-01T09:00:00", action: "created" }
|
||
], created_at: "2026-06-01"
|
||
},
|
||
{
|
||
id: "evt-10", title: "Обновление информационных стендов по технике безопасности", responsible: "Ибраева Д.С.",
|
||
section: "Коммуникации", deadline: "2026-07-01", status: "in_progress",
|
||
branch: "Розничный бизнес", region: "Павлодар", description: "Изготовлено 15 из 24 стендов",
|
||
quantity: 15, subItems: [], history: [
|
||
{ timestamp: "2026-05-01T10:00:00", action: "created" },
|
||
{ timestamp: "2026-06-08T15:00:00", action: "updated", changes: { status: "in_progress", quantity: 15 } }
|
||
], created_at: "2026-05-01"
|
||
}
|
||
];
|
||
|
||
const BRANCHES = [
|
||
"Дирекция ПБ", "Дивизион «Сеть»", "Корпоративный бизнес", "Розничный бизнес",
|
||
"Сервисная фабрика", "Телеком Комплект", "Корп. университет", "Управление проектами", "Цифровой бизнес"
|
||
];
|
||
|
||
// ─── Hardcoded Users ──────────────────────────────────────────────────────
|
||
const USERS = {
|
||
"curator@sk.kz": { name: "Куратор ПБ", password: "1234" },
|
||
"dpp@sk.kz": { name: "Директор ДПБ", password: "1234" }
|
||
};
|
||
|
||
// ─── State ────────────────────────────────────────────────────────────────
|
||
let user = null;
|
||
let eventsData = [];
|
||
let editingId = null;
|
||
|
||
// ─── Utils ────────────────────────────────────────────────────────────────
|
||
function daysUntil(d) {
|
||
if (!d) return 999;
|
||
const t = new Date(d + "T23:59:59");
|
||
return Math.ceil((t - new Date()) / 86400000);
|
||
}
|
||
|
||
function esc(s) {
|
||
const d = document.createElement("div");
|
||
d.textContent = String(s);
|
||
return d.innerHTML;
|
||
}
|
||
|
||
// ─── Auth ─────────────────────────────────────────────────────────────────
|
||
function login() {
|
||
const email = document.getElementById("loginEmail").value.trim();
|
||
const pass = document.getElementById("loginPass").value.trim();
|
||
const errEl = document.getElementById("loginError");
|
||
|
||
if (!pass) { errEl.textContent = "Введите пароль"; return; }
|
||
|
||
const u = USERS[email];
|
||
if (u && u.password === pass) {
|
||
user = { email: email, name: u.name };
|
||
localStorage.setItem("sh_token", "ok");
|
||
localStorage.setItem("sh_user", JSON.stringify(user));
|
||
document.getElementById("loginScreen").style.display = "none";
|
||
document.getElementById("app").style.display = "block";
|
||
document.getElementById("userName").textContent = user.name;
|
||
document.getElementById("userEmail").textContent = user.email;
|
||
init();
|
||
} else {
|
||
errEl.textContent = "Неверный email или пароль";
|
||
}
|
||
}
|
||
|
||
function logout() {
|
||
user = null;
|
||
localStorage.removeItem("sh_token");
|
||
localStorage.removeItem("sh_user");
|
||
document.getElementById("app").style.display = "none";
|
||
document.getElementById("loginScreen").style.display = "flex";
|
||
document.getElementById("loginEmail").value = "";
|
||
document.getElementById("loginPass").value = "";
|
||
document.getElementById("loginError").textContent = "";
|
||
}
|
||
|
||
// ─── Events persistence ───────────────────────────────────────────────────
|
||
function loadEvents() {
|
||
let saved = {};
|
||
try {
|
||
saved = JSON.parse(localStorage.getItem("sh_events") || "{}");
|
||
} catch (e) { /* ignore */ }
|
||
|
||
eventsData = ALL_EVENTS.map(function (e) {
|
||
if (saved[e.id]) {
|
||
// Merge: override saved fields onto the original, but preserve
|
||
// subItems and history from saved if they were explicitly set
|
||
const over = saved[e.id];
|
||
const merged = {};
|
||
for (var k in e) { merged[k] = e[k]; }
|
||
for (var k in over) { merged[k] = over[k]; }
|
||
return merged;
|
||
}
|
||
// shallow-clone to avoid mutating seed
|
||
const clone = {};
|
||
for (var k in e) { clone[k] = e[k]; }
|
||
clone.subItems = e.subItems ? e.subItems.map(function (s) { var c = {}; for (var ks in s) c[ks] = s[ks]; return c; }) : [];
|
||
clone.history = e.history ? e.history.map(function (h) { var c = {}; for (var kh in h) c[kh] = h[kh]; return c; }) : [];
|
||
return clone;
|
||
});
|
||
|
||
renderTable();
|
||
renderStats();
|
||
renderNotif();
|
||
}
|
||
|
||
function saveEvents() {
|
||
var changes = {};
|
||
eventsData.forEach(function (ev) {
|
||
var orig = null;
|
||
for (var i = 0; i < ALL_EVENTS.length; i++) {
|
||
if (ALL_EVENTS[i].id === ev.id) { orig = ALL_EVENTS[i]; break; }
|
||
}
|
||
if (!orig) return;
|
||
var diff = {};
|
||
var fields = ["status", "region", "description", "quantity", "subItems", "history"];
|
||
var hasDiff = false;
|
||
for (var fi = 0; fi < fields.length; fi++) {
|
||
var f = fields[fi];
|
||
if (JSON.stringify(ev[f]) !== JSON.stringify(orig[f])) {
|
||
diff[f] = JSON.parse(JSON.stringify(ev[f]));
|
||
hasDiff = true;
|
||
}
|
||
}
|
||
if (hasDiff) {
|
||
changes[ev.id] = diff;
|
||
}
|
||
});
|
||
localStorage.setItem("sh_events", JSON.stringify(changes));
|
||
}
|
||
|
||
// ─── Filtering ────────────────────────────────────────────────────────────
|
||
function getFilteredEvents() {
|
||
var list = eventsData.slice();
|
||
var q = document.getElementById("filterSearch").value.toLowerCase();
|
||
var st = document.getElementById("filterStatus").value;
|
||
var br = document.getElementById("filterBranch").value;
|
||
if (q) list = list.filter(function (e) { return e.title.toLowerCase().indexOf(q) !== -1; });
|
||
if (st) list = list.filter(function (e) { return e.status === st; });
|
||
if (br) list = list.filter(function (e) { return e.branch === br; });
|
||
|
||
var sort = document.getElementById("filterSort").value;
|
||
list.sort(function (a, b) {
|
||
if (sort === "deadline") return (a.deadline || "").localeCompare(b.deadline || "");
|
||
if (sort === "title") return a.title.localeCompare(b.title);
|
||
if (sort === "status") return (a.status || "").localeCompare(b.status || "");
|
||
return 0;
|
||
});
|
||
return list;
|
||
}
|
||
|
||
// ─── Render table ─────────────────────────────────────────────────────────
|
||
function renderTable() {
|
||
var list = getFilteredEvents();
|
||
|
||
// Update branch filter dropdown
|
||
var sel = document.getElementById("filterBranch");
|
||
var curVal = sel.value;
|
||
var branches = [];
|
||
var seen = {};
|
||
eventsData.forEach(function (e) {
|
||
if (e.branch && !seen[e.branch]) { seen[e.branch] = true; branches.push(e.branch); }
|
||
});
|
||
sel.innerHTML = '<option value="">Все филиалы</option>' +
|
||
branches.map(function (b) { return '<option value="' + esc(b) + '">' + esc(b) + '</option>'; }).join("");
|
||
sel.value = curVal;
|
||
|
||
var tbody = document.getElementById("eventsBody");
|
||
tbody.innerHTML = list.map(function (ev, i) {
|
||
var dd = daysUntil(ev.deadline);
|
||
var cls = "";
|
||
if (ev.status === "overdue" || (dd <= 0 && ev.status !== "completed")) cls = "overdue";
|
||
else if (dd <= 14 && ev.status !== "completed") cls = "warning";
|
||
else if (ev.status === "completed") cls = "completed";
|
||
var hasSub = ev.subItems && ev.subItems.length > 0;
|
||
var statusClass = "status-" + (ev.status || "pending");
|
||
var subRows = "";
|
||
if (hasSub) {
|
||
subRows = ev.subItems.map(function (s) {
|
||
return '<tr class="sub-row sub-body" data-parent="' + ev.id + '" style="display:none"><td></td><td colspan="5">' +
|
||
'<span style="color:var(--gray-light)">' + esc(s.id) + '.</span> ' + esc(s.title) +
|
||
' <span class="status-badge status-' + (s.status || "pending") + '">' + esc(s.status || "pending") + '</span></td></tr>';
|
||
}).join("");
|
||
}
|
||
var ddStr = "";
|
||
if (dd < 999 && dd > 0) {
|
||
ddStr = ' <span style="font-size:11px;color:var(--gray-text)">(' + dd + ' дн.)</span>';
|
||
} else if (dd <= 0 && ev.status !== "completed") {
|
||
ddStr = ' <span style="font-size:11px;color:var(--danger)">(просрочено)</span>';
|
||
}
|
||
return '<tr class="' + cls + '" data-id="' + ev.id + '">' +
|
||
'<td>' + (hasSub ? '<span class="sub-toggle" onclick="toggleSub(\'' + ev.id + '\')">▶</span>' : "") + (i + 1) + '</td>' +
|
||
'<td><strong>' + esc(ev.title) + '</strong></td>' +
|
||
'<td>' + esc(ev.responsible || "") + '</td>' +
|
||
'<td>' + esc(ev.section || "") + '</td>' +
|
||
'<td>' + esc(ev.deadline || "") + ddStr + '</td>' +
|
||
'<td><span class="status-badge ' + statusClass + '">' + esc(ev.status || "pending") + '</span></td>' +
|
||
'<td><button class="btn btn-outline btn-sm" onclick="openEdit(\'' + ev.id + '\')">✎</button></td>' +
|
||
'</tr>' + subRows;
|
||
}).join("");
|
||
if (list.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--gray-text);padding:40px">Нет данных</td></tr>';
|
||
}
|
||
}
|
||
|
||
function toggleSub(id) {
|
||
var rows = document.querySelectorAll('.sub-body[data-parent="' + id + '"]');
|
||
rows.forEach(function (r) {
|
||
r.style.display = r.style.display === "none" ? "" : "none";
|
||
});
|
||
// Toggle arrow direction
|
||
var toggle = document.querySelector('span.sub-toggle[data-for="' + id + '"]');
|
||
}
|
||
|
||
// ─── Stats ────────────────────────────────────────────────────────────────
|
||
function renderStats() {
|
||
var total = eventsData.length;
|
||
var completed = eventsData.filter(function (e) { return e.status === "completed"; }).length;
|
||
var in_progress = eventsData.filter(function (e) { return e.status === "in_progress"; }).length;
|
||
var overdue = eventsData.filter(function (e) {
|
||
return e.status === "overdue" || (e.status !== "completed" && daysUntil(e.deadline) <= 0);
|
||
}).length;
|
||
document.getElementById("statsRow").innerHTML =
|
||
'<div class="stat-card"><div class="num blue">' + total + '</div><div class="label">Всего мероприятий</div></div>' +
|
||
'<div class="stat-card"><div class="num green">' + completed + '</div><div class="label">Выполнено</div></div>' +
|
||
'<div class="stat-card"><div class="num yellow">' + in_progress + '</div><div class="label">В работе</div></div>' +
|
||
'<div class="stat-card"><div class="num red">' + overdue + '</div><div class="label">Просрочено</div></div>';
|
||
}
|
||
|
||
// ─── Edit modal ───────────────────────────────────────────────────────────
|
||
function openEdit(id) {
|
||
editingId = id;
|
||
var ev = null;
|
||
for (var i = 0; i < eventsData.length; i++) {
|
||
if (eventsData[i].id === id) { ev = eventsData[i]; break; }
|
||
}
|
||
if (!ev) return;
|
||
document.getElementById("editStatus").value = ev.status || "pending";
|
||
document.getElementById("editRegion").value = ev.region || "";
|
||
document.getElementById("editDesc").value = ev.description || "";
|
||
document.getElementById("editQty").value = ev.quantity || "";
|
||
document.getElementById("editFile").value = "";
|
||
renderEditFiles(id);
|
||
document.getElementById("editModal").classList.add("open");
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById("editModal").classList.remove("open");
|
||
editingId = null;
|
||
}
|
||
|
||
function renderEditFiles(eventId) {
|
||
var div = document.getElementById("editFileList");
|
||
var allFiles = {};
|
||
try {
|
||
allFiles = JSON.parse(localStorage.getItem("sh_files") || "{}");
|
||
} catch (e) { /* ignore */ }
|
||
var files = allFiles[eventId] || [];
|
||
var html = "";
|
||
if (files.length > 0) {
|
||
html += '<div style="font-size:11px;color:var(--gray-text);margin:6px 0">Файлы:</div>';
|
||
files.forEach(function (f, idx) {
|
||
html += '<div class="file-item">📄 ' + esc(f.name) +
|
||
' (' + Math.round(f.size / 1024) + ' KB)' +
|
||
' <a href="#" onclick="deleteFile(\'' + eventId + '\',' + idx + ');return false" style="color:var(--danger);font-size:11px;margin-left:8px">Удалить</a></div>';
|
||
});
|
||
}
|
||
div.innerHTML = html || '<div style="font-size:11px;color:var(--gray-text)">Нет загруженных файлов</div>';
|
||
}
|
||
|
||
function deleteFile(eventId, idx) {
|
||
var allFiles = {};
|
||
try {
|
||
allFiles = JSON.parse(localStorage.getItem("sh_files") || "{}");
|
||
} catch (e) { /* ignore */ }
|
||
var files = allFiles[eventId] || [];
|
||
files.splice(idx, 1);
|
||
allFiles[eventId] = files;
|
||
localStorage.setItem("sh_files", JSON.stringify(allFiles));
|
||
renderEditFiles(eventId);
|
||
}
|
||
|
||
function saveEdit() {
|
||
var ev = null;
|
||
for (var i = 0; i < eventsData.length; i++) {
|
||
if (eventsData[i].id === editingId) { ev = eventsData[i]; break; }
|
||
}
|
||
if (!ev) return;
|
||
|
||
// Update event fields
|
||
ev.status = document.getElementById("editStatus").value;
|
||
ev.region = document.getElementById("editRegion").value;
|
||
ev.description = document.getElementById("editDesc").value;
|
||
ev.quantity = parseInt(document.getElementById("editQty").value) || 0;
|
||
|
||
// Add history entry
|
||
if (!ev.history) ev.history = [];
|
||
ev.history.push({
|
||
timestamp: new Date().toISOString(),
|
||
action: "updated",
|
||
changes: { status: ev.status, region: ev.region, description: ev.description, quantity: ev.quantity }
|
||
});
|
||
|
||
// Handle file upload
|
||
var fileInput = document.getElementById("editFile");
|
||
if (fileInput.files.length > 0) {
|
||
var file = fileInput.files[0];
|
||
if (file.size > 3 * 1024 * 1024) {
|
||
alert("Файл превышает 3 МБ. Выберите файл меньшего размера.");
|
||
return;
|
||
}
|
||
var reader = new FileReader();
|
||
reader.onload = function () {
|
||
var allFiles = {};
|
||
try {
|
||
allFiles = JSON.parse(localStorage.getItem("sh_files") || "{}");
|
||
} catch (e) { /* ignore */ }
|
||
if (!allFiles[editingId]) allFiles[editingId] = [];
|
||
allFiles[editingId].push({
|
||
name: file.name,
|
||
size: file.size,
|
||
type: file.type,
|
||
data: reader.result,
|
||
uploadedAt: new Date().toISOString()
|
||
});
|
||
localStorage.setItem("sh_files", JSON.stringify(allFiles));
|
||
saveEvents();
|
||
renderTable();
|
||
renderStats();
|
||
renderEditFiles(editingId);
|
||
document.getElementById("editFile").value = "";
|
||
};
|
||
reader.readAsDataURL(file);
|
||
} else {
|
||
saveEvents();
|
||
renderTable();
|
||
renderStats();
|
||
closeModal();
|
||
}
|
||
}
|
||
|
||
// ─── Notifications ────────────────────────────────────────────────────────
|
||
function renderNotif() {
|
||
var list = [];
|
||
eventsData.forEach(function (ev) {
|
||
if (ev.status === "completed") return;
|
||
var dd = daysUntil(ev.deadline);
|
||
if (dd <= 0) list.push({ text: "Просрочено: " + ev.title, date: ev.deadline, type: "danger" });
|
||
else if (dd <= 1) list.push({ text: "Остался 1 день: " + ev.title, date: ev.deadline, type: "danger" });
|
||
else if (dd <= 7) list.push({ text: "Осталось " + dd + " дн.: " + ev.title, date: ev.deadline, type: "warning" });
|
||
else if (dd <= 14) list.push({ text: "Осталось " + dd + " дн.: " + ev.title, date: ev.deadline, type: "warning" });
|
||
else if (dd <= 30) list.push({ text: "Осталось " + dd + " дн.: " + ev.title, date: ev.deadline, type: "info" });
|
||
});
|
||
list.sort(function (a, b) { return a.date.localeCompare(b.date); });
|
||
var count = list.length;
|
||
document.getElementById("notifCount").textContent = count > 99 ? "99+" : count;
|
||
document.getElementById("notifList").innerHTML = list.slice(0, 50).map(function (n) {
|
||
var color = n.type === "danger" ? "var(--danger)" : n.type === "warning" ? "var(--warning)" : "var(--accent)";
|
||
return '<div class="notif-item" style="border-left:3px solid ' + color + ';padding-left:10px"><div>' + esc(n.text) + '</div><div class="date">' + esc(n.date) + '</div></div>';
|
||
}).join("") || '<div style="font-size:13px;color:var(--gray-text)">Нет уведомлений</div>';
|
||
}
|
||
|
||
function toggleNotif() {
|
||
document.getElementById("notifPanel").classList.toggle("open");
|
||
}
|
||
|
||
// ─── Analytics ────────────────────────────────────────────────────────────
|
||
function renderAnalytics() {
|
||
var total = eventsData.length;
|
||
var completed = eventsData.filter(function (e) { return e.status === "completed"; }).length;
|
||
var in_progress = eventsData.filter(function (e) { return e.status === "in_progress"; }).length;
|
||
var overdue = eventsData.filter(function (e) {
|
||
return e.status === "overdue" || (e.status !== "completed" && daysUntil(e.deadline) <= 0);
|
||
}).length;
|
||
var pct = total ? Math.round(completed / total * 100) : 0;
|
||
|
||
document.getElementById("analyticsStats").innerHTML =
|
||
'<div class="stat-card"><div class="num blue">' + pct + '%</div><div class="label">Выполнение</div></div>' +
|
||
'<div class="stat-card"><div class="num green">' + completed + '</div><div class="label">Выполнено</div></div>' +
|
||
'<div class="stat-card"><div class="num yellow">' + in_progress + '</div><div class="label">В работе</div></div>' +
|
||
'<div class="stat-card"><div class="num red">' + overdue + '</div><div class="label">Просрочено</div></div>';
|
||
|
||
// Branches chart
|
||
var branches = {};
|
||
eventsData.forEach(function (e) {
|
||
var b = e.branch || "Неизвестно";
|
||
branches[b] = (branches[b] || 0) + 1;
|
||
});
|
||
var maxB = Math.max.apply(null, Object.values(branches).concat([1]));
|
||
var branchKeys = Object.keys(branches).sort(function (a, b) { return branches[b] - branches[a]; });
|
||
document.getElementById("branchChart").innerHTML = branchKeys.map(function (k) {
|
||
return '<div class="chart-bar"><span class="bar-label">' + esc(k) + '</span><div class="bar-fill" style="width:' + (branches[k] / maxB * 200) + 'px"></div><span class="bar-val">' + branches[k] + '</span></div>';
|
||
}).join("");
|
||
|
||
// Status chart
|
||
var statuses = { completed: 0, in_progress: 0, pending: 0, overdue: 0 };
|
||
eventsData.forEach(function (e) {
|
||
var s = e.status || "pending";
|
||
statuses[s] = (statuses[s] || 0) + 1;
|
||
});
|
||
// Include overdue in the overdue count from the computed value
|
||
var od = eventsData.filter(function (e) {
|
||
return e.status === "overdue" || (e.status !== "completed" && daysUntil(e.deadline) <= 0);
|
||
}).length;
|
||
statuses.overdue = od;
|
||
|
||
var maxS = Math.max.apply(null, Object.values(statuses).concat([1]));
|
||
var labels = { completed: "Выполнено", in_progress: "В работе", pending: "Ожидает", overdue: "Просрочено" };
|
||
var colors = { completed: "var(--success)", in_progress: "var(--accent-bright)", pending: "var(--warning)", overdue: "var(--danger)" };
|
||
document.getElementById("statusChart").innerHTML = Object.keys(statuses).map(function (k) {
|
||
return '<div class="chart-bar"><span class="bar-label">' + labels[k] + '</span><div class="bar-fill" style="width:' + (statuses[k] / maxS * 200) + 'px;background:' + colors[k] + '"></div><span class="bar-val">' + statuses[k] + '</span></div>';
|
||
}).join("");
|
||
}
|
||
|
||
// ─── Reports ──────────────────────────────────────────────────────────────
|
||
function generateReportHTML() {
|
||
var now = new Date().toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
|
||
var total = eventsData.length;
|
||
var completed = eventsData.filter(function (e) { return e.status === "completed"; }).length;
|
||
var in_progress = eventsData.filter(function (e) { return e.status === "in_progress"; }).length;
|
||
var overdue = eventsData.filter(function (e) {
|
||
return e.status === "overdue" || (e.status !== "completed" && daysUntil(e.deadline) <= 0);
|
||
}).length;
|
||
var pending = eventsData.filter(function (e) { return e.status === "pending"; }).length;
|
||
var pct = total ? Math.round(completed / total * 100) : 0;
|
||
|
||
var rows = eventsData.map(function (e, i) {
|
||
var stLabel = { completed: "Выполнено", in_progress: "В работе", pending: "Ожидает", overdue: "Просрочено" };
|
||
return '<tr><td>' + (i + 1) + '</td><td>' + esc(e.title) + '</td><td>' + esc(e.responsible || "") + '</td><td>' + esc(e.section || "") + '</td><td>' + esc(e.branch || "") + '</td><td>' + esc(e.deadline || "") + '</td><td>' + (stLabel[e.status] || e.status) + '</td></tr>';
|
||
}).join("");
|
||
|
||
return '<html><head><meta charset="utf-8"><title>Отчёт по ПБ — АО «Самрук-Казына»</title><style>' +
|
||
'body{font-family:Arial,sans-serif;color:#222;padding:40px;max-width:900px;margin:0 auto}' +
|
||
'h1{font-size:22px;margin-bottom:4px}h2{font-size:16px;color:#555;margin-bottom:20px}' +
|
||
'.meta{font-size:12px;color:#888;margin-bottom:24px}' +
|
||
'.stats{display:flex;gap:16px;margin-bottom:24px;flex-wrap:wrap}' +
|
||
'.stat{padding:12px 16px;border:1px solid #ddd;border-radius:8px;min-width:130px}' +
|
||
'.stat .n{font-size:24px;font-weight:bold}.stat .l{font-size:12px;color:#666}' +
|
||
'table{width:100%;border-collapse:collapse;font-size:13px}' +
|
||
'th,td{padding:8px 10px;border:1px solid #ddd;text-align:left}' +
|
||
'th{background:#f5f5f5;font-weight:600}' +
|
||
'</style></head><body>' +
|
||
'<h1>Сводный отчёт по производственной безопасности</h1>' +
|
||
'<h2>АО «Самрук-Казына»</h2>' +
|
||
'<div class="meta">Сформирован: ' + now + '</div>' +
|
||
'<div class="stats">' +
|
||
'<div class="stat"><div class="n">' + total + '</div><div class="l">Всего</div></div>' +
|
||
'<div class="stat"><div class="n" style="color:green">' + completed + '</div><div class="l">Выполнено</div></div>' +
|
||
'<div class="stat"><div class="n" style="color:#00a3ff">' + in_progress + '</div><div class="l">В работе</div></div>' +
|
||
'<div class="stat"><div class="n" style="color:#f59e0b">' + pending + '</div><div class="l">Ожидает</div></div>' +
|
||
'<div class="stat"><div class="n" style="color:red">' + overdue + '</div><div class="l">Просрочено</div></div>' +
|
||
'<div class="stat"><div class="n" style="color:#00a3ff">' + pct + '%</div><div class="l">Выполнение</div></div>' +
|
||
'</div>' +
|
||
'<table><thead><tr><th>№</th><th>Мероприятие</th><th>Ответственный</th><th>Раздел</th><th>Филиал</th><th>Срок</th><th>Статус</th></tr></thead><tbody>' +
|
||
rows + '</tbody></table>' +
|
||
'</body></html>';
|
||
}
|
||
|
||
function downloadWord() {
|
||
var html = generateReportHTML();
|
||
var blob = new Blob(["\ufeff" + html], { type: "application/msword" });
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "Otchyot_PB.doc";
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function downloadPdf() {
|
||
var html = generateReportHTML();
|
||
var w = window.open("", "_blank");
|
||
w.document.write(html);
|
||
w.document.close();
|
||
w.focus();
|
||
w.print();
|
||
}
|
||
|
||
// ─── HSE Integration ──────────────────────────────────────────────────────
|
||
function sendHseReport() {
|
||
var result = document.getElementById("hseResult");
|
||
result.innerHTML = '<span style="color:var(--accent-bright)">ⓘ Это статическая демо-версия. Интеграция с HSE.sk.kz недоступна в офлайн-режиме. Отчёт за месяц можно скачать на вкладке «Отчёты».</span>';
|
||
}
|
||
|
||
// ─── AI Chat ──────────────────────────────────────────────────────────────
|
||
function sendChat() {
|
||
var input = document.getElementById("chatInput");
|
||
var q = input.value.trim();
|
||
if (!q) return;
|
||
input.value = "";
|
||
var msgs = document.getElementById("chatMsgs");
|
||
msgs.innerHTML += '<div class="chat-msg user">' + esc(q) + '</div>';
|
||
var answer = getAIAnswer(q);
|
||
msgs.innerHTML += '<div class="chat-msg ai"><div class="label">🤖 ИИ-агент</div>' + answer + '</div>';
|
||
msgs.scrollTop = msgs.scrollHeight;
|
||
}
|
||
|
||
function getAIAnswer(q) {
|
||
var ev = eventsData;
|
||
var total = ev.length;
|
||
var completed = ev.filter(function (e) { return e.status === "completed"; }).length;
|
||
var in_progress = ev.filter(function (e) { return e.status === "in_progress"; }).length;
|
||
var pending = ev.filter(function (e) { return e.status === "pending"; }).length;
|
||
var overdue = ev.filter(function (e) {
|
||
return e.status === "overdue" || (e.status !== "completed" && daysUntil(e.deadline) <= 0);
|
||
}).length;
|
||
var ql = q.toLowerCase();
|
||
|
||
if (ql.indexOf("просрочен") !== -1 || ql.indexOf("overdue") !== -1) {
|
||
var overdueList = ev.filter(function (e) {
|
||
return e.status === "overdue" || (e.status !== "completed" && daysUntil(e.deadline) <= 0);
|
||
});
|
||
if (overdueList.length === 0) return "Просроченных мероприятий нет.";
|
||
return "Просроченные мероприятия (" + overdueList.length + "):<br>" +
|
||
overdueList.map(function (e) {
|
||
return "- " + esc(e.title) + " (срок: " + esc(e.deadline) + ", филиал: " + esc(e.branch || "—") + ")";
|
||
}).join("<br>");
|
||
}
|
||
|
||
if (ql.indexOf("риск") !== -1) {
|
||
var risky = ev.filter(function (e) {
|
||
return e.status !== "completed" && daysUntil(e.deadline) <= 14 && daysUntil(e.deadline) > 0;
|
||
});
|
||
if (risky.length === 0) return "Мероприятий с высоким риском срыва нет.";
|
||
return "Высокий риск срыва у " + risky.length + " мероприятий:<br>" +
|
||
risky.map(function (e) {
|
||
return "- " + esc(e.title) + " (осталось " + daysUntil(e.deadline) + " дн., ответственный: " + esc(e.responsible || "—") + ")";
|
||
}).join("<br>");
|
||
}
|
||
|
||
if (ql.indexOf("сводк") !== -1 || ql.indexOf("итог") !== -1) {
|
||
return "Сводка по ПБ: всего " + total + " мероприятий. Выполнено: " + completed +
|
||
", в работе: " + in_progress + ", ожидает: " + pending + ", просрочено: " + overdue +
|
||
". Процент выполнения: " + (total ? Math.round(completed / total * 100) : 0) + "%.";
|
||
}
|
||
|
||
if (ql.indexOf("филиал") !== -1 || ql.indexOf("рейтинг") !== -1 || ql.indexOf("branch") !== -1) {
|
||
var br = {};
|
||
ev.forEach(function (e) {
|
||
var b = e.branch || "Неизвестно";
|
||
if (!br[b]) br[b] = { total: 0, done: 0 };
|
||
br[b].total++;
|
||
if (e.status === "completed") br[b].done++;
|
||
});
|
||
var sorted = Object.keys(br).sort(function (a, b) {
|
||
return (br[b].done / br[b].total) - (br[a].done / br[a].total);
|
||
});
|
||
return "Рейтинг филиалов по выполнению:<br>" +
|
||
sorted.map(function (k, i) {
|
||
return (i + 1) + ". " + esc(k) + ": " + br[k].done + "/" + br[k].total +
|
||
" (" + Math.round(br[k].done / br[k].total * 100) + "%)";
|
||
}).join("<br>");
|
||
}
|
||
|
||
if ((ql.indexOf("статус") !== -1 || ql.indexOf("номер") !== -1) && ql.match(/\d+/)) {
|
||
var num = parseInt(ql.match(/\d+/)[0], 10);
|
||
var found = ev[num - 1];
|
||
if (found) {
|
||
return "№" + num + ": <strong>" + esc(found.title) + "</strong><br>" +
|
||
"Статус: " + esc(found.status) + " | Срок: " + esc(found.deadline) +
|
||
" | Ответственный: " + esc(found.responsible) +
|
||
" | Филиал: " + esc(found.branch) +
|
||
" | Описание: " + esc(found.description || "—");
|
||
}
|
||
return "Мероприятие №" + num + " не найдено.";
|
||
}
|
||
|
||
if (ql.indexOf("советник") !== -1 || ql.indexOf("рекомендац") !== -1) {
|
||
var rlist = ev.filter(function (e) {
|
||
return e.status !== "completed" && daysUntil(e.deadline) <= 14 && daysUntil(e.deadline) > 0;
|
||
});
|
||
var olist = ev.filter(function (e) { return e.status === "overdue"; });
|
||
var adv = "Рекомендации:<br>";
|
||
if (olist.length) adv += "- Срочно принять меры по " + olist.length + " просроченным мероприятиям.<br>";
|
||
if (rlist.length) adv += "- Усилить контроль за " + rlist.length + " мероприятиями с приближающимся сроком.<br>";
|
||
if (pending > 5) adv += "- " + pending + " мероприятий ещё не начаты — требуется активизация.<br>";
|
||
if (adv === "Рекомендации:<br>") adv += "- Всё в порядке, продолжайте в том же духе.";
|
||
return adv;
|
||
}
|
||
|
||
if (ql.indexOf("аудит") !== -1 || ql.indexOf("360") !== -1) {
|
||
return "Аудит 360°:<br>- Всего: " + total +
|
||
"<br>- Выполнено: " + completed +
|
||
"<br>- В работе: " + in_progress +
|
||
"<br>- Просрочено: " + overdue +
|
||
"<br>- Ожидает: " + pending +
|
||
"<br>- Выполнение: " + (total ? Math.round(completed / total * 100) : 0) + "%";
|
||
}
|
||
|
||
if (ql.indexOf("прогноз") !== -1) {
|
||
var willComplete = ev.filter(function (e) {
|
||
return e.status === "in_progress" && daysUntil(e.deadline) <= 30 && daysUntil(e.deadline) > 0;
|
||
}).length;
|
||
var atRisk = ev.filter(function (e) {
|
||
return (e.status === "pending" && daysUntil(e.deadline) <= 30) ||
|
||
(e.status !== "completed" && daysUntil(e.deadline) <= 7 && daysUntil(e.deadline) >= 0);
|
||
}).length;
|
||
return "Прогноз на 30 дней:<br>" +
|
||
"- Ожидается завершение ~" + willComplete + " мероприятий в работе.<br>" +
|
||
"- " + atRisk + " мероприятий под угрозой срыва.<br>" +
|
||
"- Текущий процент выполнения: " + (total ? Math.round(completed / total * 100) : 0) + "%.<br>" +
|
||
"- При сохранении темпа к концу месяца выполнение достигнет ~" + Math.min(100, Math.round((completed + willComplete) / total * 100)) + "%.";
|
||
}
|
||
|
||
return "Я анализирую данные по мероприятиям ПБ. Можете спросить:<br>" +
|
||
"- «просроченные» — список просрочек<br>" +
|
||
"- «риски» — мероприятия с высоким риском<br>" +
|
||
"- «сводка» — общая статистика<br>" +
|
||
"- «рейтинг филиалов» — кто в лидерах<br>" +
|
||
"- «статус N» — информация по конкретному мероприятию<br>" +
|
||
"- «советник» — рекомендации<br>" +
|
||
"- «аудит 360» — полный срез<br>" +
|
||
"- «прогноз» — что будет через месяц";
|
||
}
|
||
|
||
// ─── Init ─────────────────────────────────────────────────────────────────
|
||
function init() {
|
||
loadEvents();
|
||
|
||
// Sidebar navigation
|
||
document.querySelectorAll(".sidebar a[data-page]").forEach(function (a) {
|
||
a.addEventListener("click", function (e) {
|
||
e.preventDefault();
|
||
document.querySelectorAll(".sidebar a").forEach(function (x) { x.classList.remove("active"); });
|
||
this.classList.add("active");
|
||
document.querySelectorAll(".page").forEach(function (p) { p.classList.remove("active"); });
|
||
document.getElementById("page-" + this.dataset.page).classList.add("active");
|
||
if (this.dataset.page === "analytics") renderAnalytics();
|
||
});
|
||
});
|
||
|
||
// Event listeners
|
||
document.getElementById("btnRefresh").addEventListener("click", loadEvents);
|
||
document.getElementById("filterSearch").addEventListener("input", renderTable);
|
||
document.getElementById("filterStatus").addEventListener("change", renderTable);
|
||
document.getElementById("filterBranch").addEventListener("change", renderTable);
|
||
document.getElementById("filterSort").addEventListener("change", renderTable);
|
||
document.getElementById("dlWord").addEventListener("click", downloadWord);
|
||
document.getElementById("dlPdf").addEventListener("click", downloadPdf);
|
||
document.getElementById("hseSendBtn").addEventListener("click", sendHseReport);
|
||
document.getElementById("chatSend").addEventListener("click", sendChat);
|
||
document.getElementById("chatInput").addEventListener("keydown", function (e) { if (e.key === "Enter") sendChat(); });
|
||
document.getElementById("modalCancel").addEventListener("click", closeModal);
|
||
document.getElementById("modalSave").addEventListener("click", saveEdit);
|
||
document.getElementById("logoutBtn").addEventListener("click", logout);
|
||
document.getElementById("notifBell").addEventListener("click", toggleNotif);
|
||
document.getElementById("notifClose").addEventListener("click", function () {
|
||
document.getElementById("notifPanel").classList.remove("open");
|
||
});
|
||
document.getElementById("editModal").addEventListener("click", function (e) {
|
||
if (e.target === this) closeModal();
|
||
});
|
||
document.addEventListener("keydown", function (e) {
|
||
if (e.key === "Escape") closeModal();
|
||
});
|
||
|
||
// Set current month for HSE
|
||
document.getElementById("hseMonth").value = new Date().toISOString().slice(0, 7);
|
||
}
|
||
|
||
// ─── Auto-login check ─────────────────────────────────────────────────────
|
||
(function () {
|
||
var isLoggedIn = localStorage.getItem("sh_token");
|
||
var savedUser = {};
|
||
try { savedUser = JSON.parse(localStorage.getItem("sh_user") || "{}"); } catch (e) { /* ignore */ }
|
||
|
||
// Login button always needs listener
|
||
var loginBtn = document.getElementById("loginBtn");
|
||
if (loginBtn) {
|
||
loginBtn.addEventListener("click", login);
|
||
}
|
||
var loginPass = document.getElementById("loginPass");
|
||
if (loginPass) {
|
||
loginPass.addEventListener("keydown", function (e) { if (e.key === "Enter") login(); });
|
||
}
|
||
|
||
if (isLoggedIn && savedUser.email) {
|
||
user = savedUser;
|
||
document.getElementById("loginScreen").style.display = "none";
|
||
document.getElementById("app").style.display = "block";
|
||
document.getElementById("userName").textContent = user.name || "";
|
||
document.getElementById("userEmail").textContent = user.email || "";
|
||
init();
|
||
}
|
||
})();
|