// ========== ДАННЫЕ ========== const COURSES = [ { id: 1, name: "Безопасная рабочая среда SWE 2", level: 2, periodicity: 12 }, { id: 2, name: "Безопасное вождение (Defensive Driving)", level: 2, periodicity: 24 }, { id: 3, name: "Безопасное выполнение земляных работ", level: 1, periodicity: 12 }, { id: 4, name: "Безопасное выполнение работ на лестницах", level: 1, periodicity: 12 }, { id: 5, name: "Безопасное выполнение работ на опорах", level: 1, periodicity: 12 }, { id: 6, name: "Безопасность рук", level: 2, periodicity: 12 }, { id: 7, name: "Изоляция источников энергии (LOTO)", level: 1, periodicity: 12 }, { id: 8, name: "Контроль усталости", level: 1, periodicity: 6 }, { id: 9, name: "Культура безопасности труда", level: 2, periodicity: 24 }, { id: 10, name: "Оказание первой медицинской помощи", level: 1, periodicity: 24 }, { id: 11, name: "Основы безопасности в замкнутом пространстве", level: 2, periodicity: 12 }, { id: 12, name: "Основы безопасности на высоте", level: 2, periodicity: 12 }, { id: 13, name: "Поведенческие аудиты безопасности", level: 2, periodicity: 12 }, { id: 14, name: "Предотвращение травматизма IPR", level: 1, periodicity: 12 }, { id: 15, name: "Предотвращение травматизма IPR (углублённый)", level: 2, periodicity: 12 }, { id: 16, name: "Расследование инцидентов", level: 1, periodicity: 12 }, { id: 17, name: "Управление опасностями и рисками", level: 1, periodicity: 12 }, { id: 18, name: "Устойчивое развитие и экология", level: 1, periodicity: 24 }, { id: 19, name: "Фасилитатор в HSE", level: 2, periodicity: 24 }, { id: 20, name: "Четырёхступенчатый контроль (1 ступень)", level: 1, periodicity: 12 }, { id: 21, name: "Четырёхступенчатый контроль (2 ступень)", level: 1, periodicity: 12 }, { id: 22, name: "Четырёхступенчатый контроль (3 ступень)", level: 1, periodicity: 12 }, { id: 23, name: "Четырёхступенчатый контроль (4 ступень)", level: 1, periodicity: 12 } ]; const CITIES = ["Алматы", "Астана", "Атырау", "Актау", "Караганда", "Шымкент"]; const DEPARTMENTS = ["HSE", "Производство", "Логистика", "Администрация", "Техническая служба", "Электроснабжение"]; const POSITIONS = ["Инженер HSE", "Мастер", "Электромонтёр", "Слесарь", "Водитель", "Начальник смены", "Техник"]; function generateEmployees() { const employees = []; const lastNames = ["Иванов", "Петров", "Сидоров", "Козлов", "Новиков", "Морозов", "Волков", "Соколов", "Лебедев", "Кузнецов", "Попов", "Васильев", "Зайцев", "Михайлов", "Фёдоров", "Абдуллин", "Нурланов", "Сагиев", "Турсунов", "Касымов", "Жумабаев", "Ермеков", "Баймуханов", "Алиев", "Хасенов"]; const firstNames = ["Александр", "Дмитрий", "Сергей", "Андрей", "Михаил", "Ильяс", "Данияр", "Артём", "Руслан", "Нурсултан", "Айдар", "Ерлан", "Булат", "Тимур", "Арман", "Мадияр", "Самат", "Кайрат", "Ержан", "Аслан"]; const patronymics = ["Александрович", "Дмитриевич", "Сергеевич", "Андреевич", "Михайлович", "Ильясович", "Даниярович", "Артёмович", "Русланович", "Нурсултанович"]; let tab = 1001; for (let city of CITIES) { const count = 8 + Math.floor(Math.random() * 7); for (let i = 0; i < count; i++) { const emp = { tab: String(tab++), lastName: lastNames[Math.floor(Math.random() * lastNames.length)], firstName: firstNames[Math.floor(Math.random() * firstNames.length)], patronymic: patronymics[Math.floor(Math.random() * patronymics.length)], city: city, department: DEPARTMENTS[Math.floor(Math.random() * DEPARTMENTS.length)], position: POSITIONS[Math.floor(Math.random() * POSITIONS.length)], courses: [] }; emp.fullName = emp.lastName + ' ' + emp.firstName + ' ' + emp.patronymic; const courseCount = 5 + Math.floor(Math.random() * 10); const shuffled = [...COURSES].sort(() => 0.5 - Math.random()); for (let j = 0; j < Math.min(courseCount, COURSES.length); j++) { const course = shuffled[j]; const completed = Math.random() > 0.3; if (completed) { const monthsAgo = Math.floor(Math.random() * 36); const completedDate = new Date(); completedDate.setMonth(completedDate.getMonth() - monthsAgo); const nextDate = new Date(completedDate); nextDate.setMonth(nextDate.getMonth() + course.periodicity); const isOverdue = nextDate < new Date(); emp.courses.push({ courseId: course.id, courseName: course.name, level: course.level, completed: true, completedDate: completedDate.toISOString().split('T')[0], nextDate: nextDate.toISOString().split('T')[0], overdue: isOverdue, daysOverdue: isOverdue ? Math.floor((new Date() - nextDate) / 86400000) : 0 }); } else { emp.courses.push({ courseId: course.id, courseName: course.name, level: course.level, completed: false, completedDate: null, nextDate: null, overdue: false, daysOverdue: 0 }); } } employees.push(emp); } } return employees; } let EMPLOYEES = generateEmployees(); // ========== ТАБЫ ========== function showTab(name) { ['dashboard', 'search', 'region', 'export'].forEach(t => { document.getElementById('tab-' + t).classList.toggle('hidden', t !== name); }); document.querySelectorAll('.tab-btn').forEach((btn, i) => { const tabs = ['dashboard', 'search', 'region', 'export']; btn.classList.toggle('active', tabs[i] === name); }); if (name === 'dashboard') { renderDashboard(); renderCityStats(); } if (name === 'region') renderCityStats(); } // ========== ПОИСК ========== function searchEmployee() { const query = document.getElementById('search-input').value.trim().toLowerCase(); const cityFilter = document.getElementById('filter-city').value; let results = EMPLOYEES; if (query) results = results.filter(e => e.tab === query || e.fullName.toLowerCase().includes(query)); if (cityFilter) results = results.filter(e => e.city === cityFilter); renderSearchResults(results); } function renderSearchResults(employees) { const c = document.getElementById('search-results'); if (!employees.length) { c.innerHTML = '
Сотрудники не найдены
'; return; } let h = '
'; for (const e of employees) { const done = e.courses.filter(c => c.completed).length; const ov = e.courses.filter(c => c.overdue).length; const pct = Math.round((done / e.courses.length) * 100); h += ''; } h += '
Таб.№ФИОГородДолжностьПройденоПросроченоПрогресс
' + e.tab + '' + e.fullName + '' + e.city + '' + e.position + '' + done + '/' + e.courses.length + '' + (ov ? '❌ ' + ov : '✅') + '
' + pct + '%
'; c.innerHTML = h; } function showDetail(tab) { const emp = EMPLOYEES.find(e => e.tab === tab); if (!emp) return; const c = document.getElementById('search-results'); const done = emp.courses.filter(c => c.completed); const notDone = emp.courses.filter(c => !c.completed); const overdue = done.filter(c => c.overdue); const valid = done.filter(c => !c.overdue); const pct = Math.round((done.length / emp.courses.length) * 100); let h = '
'; h += '
Таб.№: ' + emp.tab + '
Город: ' + emp.city + '
Должность: ' + emp.position + '
Подразделение: ' + emp.department + '
'; h += '
Общий прогресс' + done.length + ' из ' + emp.courses.length + ' (' + pct + '%)
'; if (overdue.length) { h += '

⏰ Просрочено (' + overdue.length + ')

'; for (const c of overdue) h += ''; h += '
КурсУровеньЗавершёнДолжен пройтиПросрочка
' + c.courseName + 'Ур.' + c.level + '' + fmt(c.completedDate) + '' + fmt(c.nextDate) + '⚠️ ' + c.daysOverdue + ' дн.
'; } if (valid.length) { h += '

✅ Действует (' + valid.length + ')

'; for (const c of valid) { const left = Math.ceil((new Date(c.nextDate) - new Date()) / 86400000); const urg = left <= 30 ? ' class="warn"' : ''; h += '' + fmt(c.nextDate) + (left <= 30 ? ' ⚠️ ' + left + ' дн.' : '') + ''; } h += '
КурсУровеньЗавершёнСледующее
' + c.courseName + 'Ур.' + c.level + '' + fmt(c.completedDate) + '
'; } if (notDone.length) { h += '

❗ Не пройдено (' + notDone.length + ')

'; } h += '
'; c.innerHTML = h; } function fmt(d) { if (!d) return '—'; const [y, m, dd] = d.split('-'); return dd + '.' + m + '.' + y; } // ========== РЕГИОНЫ ========== function renderCityStats() { const c = document.getElementById('city-stats'); const stats = CITIES.map(city => { const emps = EMPLOYEES.filter(e => e.city === city); const total = emps.reduce((s, e) => s + e.courses.length, 0); const done = emps.reduce((s, e) => s + e.courses.filter(c => c.completed).length, 0); const ov = emps.reduce((s, e) => s + e.courses.filter(c => c.overdue).length, 0); return { city, count: emps.length, pct: total ? Math.round((done / total) * 100) : 0, overdue: ov }; }); c.innerHTML = stats.map(s => '
' + '
' + s.count + '
' + '
' + s.city + '
' + '
' + '
' + s.pct + '% выполнено' + (s.overdue ? ' | ' + s.overdue + ' просрочено' : '') + '
' + '
' ).join(''); } function showCity(city) { showTab('search'); renderSearchResults(EMPLOYEES.filter(e => e.city === city)); } // ========== ЗАГРУЗКА ========== function handleFileUpload(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); const ext = file.name.split('.').pop().toLowerCase(); reader.onload = function(e) { let data = null; if (ext === 'csv') { data = parseCSV(e.target.result); } else if (ext === 'xlsx') { try { data = parseExcel(e.target.result); } catch (err) { msg('Ошибка Excel: ' + err.message, 'err'); return; } } else { msg('Только .csv и .xlsx', 'err'); return; } if (data && data.length) { processData(data); msg('Загружено ' + data.length + ' строк', 'ok'); } else { msg('Не удалось распознать данные', 'err'); } }; reader.onerror = function() { msg('Ошибка чтения файла', 'err'); }; if (ext === 'csv') reader.readAsText(file); else reader.readAsArrayBuffer(file); } function parseCSV(text) { const lines = text.split(/\r?\n/).filter(l => l.trim()); if (lines.length < 2) return null; const headers = lines[0].split(';').map(h => h.trim()); const data = []; for (let i = 1; i < lines.length; i++) { const vals = lines[i].split(';'); if (vals.length < 3) continue; const row = {}; headers.forEach((h, j) => { if (j < vals.length) row[h] = vals[j]; }); data.push(row); } return data; } function parseExcel(buf) { const wb = XLSX.read(new Uint8Array(buf), { type: 'array' }); const ws = wb.Sheets[wb.SheetNames[0]]; const json = XLSX.utils.sheet_to_json(ws, { header: 1 }); const headers = (json[0] || []).map(h => String(h).trim()); const data = []; for (let i = 1; i < json.length; i++) { if (!json[i] || !json[i].length) continue; const row = {}; headers.forEach((h, j) => { if (j < json[i].length) row[h] = json[i][j]; }); data.push(row); } return data; } function processData(rows) { const emps = []; for (const row of rows) { const emp = { tab: row['Таб.№'] || row['tab'] || row['Табельный'] || '', fullName: row['ФИО'] || '', city: row['Город'] || '', department: row['Подразделение'] || '', position: row['Должность'] || row['Штатная должность'] || '', courses: [] }; if (!emp.tab || !emp.fullName) continue; for (let i = 1; i <= 20; i++) { const name = row['Курс' + i] || row['Курс ' + i]; if (!name) break; const status = (row['Статус' + i] || row['Статус ' + i] || '').toLowerCase(); const dateStr = row['Дата' + i] || row['Дата ' + i] || ''; const completed = status === 'пройдено' || status === 'yes' || status === 'true' || status === '1'; const course = { courseId: i, courseName: name, level: 1, completed, completedDate: null, nextDate: null, overdue: false, daysOverdue: 0 }; if (completed && dateStr) { let d, m, y; if (typeof dateStr === 'number') { const excelDate = new Date((dateStr - 25569) * 86400000); d = String(excelDate.getDate()).padStart(2, '0'); m = String(excelDate.getMonth() + 1).padStart(2, '0'); y = excelDate.getFullYear(); } else { const match = String(dateStr).match(/(\d{1,2})[\.\/\-](\d{1,2})[\.\/\-](\d{2,4})/); if (match) { d = match[1].padStart(2, '0'); m = match[2].padStart(2, '0'); y = match[3].length === 2 ? '20' + match[3] : match[3]; } } if (d && m && y) { course.completedDate = y + '-' + m + '-' + d; const next = new Date(+y, +m - 1, +d); next.setMonth(next.getMonth() + 12); course.nextDate = next.toISOString().split('T')[0]; course.overdue = next < new Date(); course.daysOverdue = course.overdue ? Math.floor((new Date() - next) / 86400000) : 0; } } emp.courses.push(course); } emps.push(emp); } EMPLOYEES = emps; localStorage.setItem('tb_data', JSON.stringify(emps)); renderDashboard(); renderCityStats(); } function msg(text, type) { const c = document.getElementById('upload-results'); const d = document.createElement('div'); d.className = 'msg ' + (type === 'ok' ? 'msg-ok' : 'msg-err'); d.textContent = text; c.prepend(d); if (type === 'ok') setTimeout(() => d.remove(), 5000); } // ========== ДАШБОРД ========== function renderDashboard() { const total = EMPLOYEES.length; const allCourses = EMPLOYEES.reduce((s, e) => s + e.courses.length, 0); const done = EMPLOYEES.reduce((s, e) => s + e.courses.filter(c => c.completed).length, 0); const ov = EMPLOYEES.reduce((s, e) => s + e.courses.filter(c => c.overdue).length, 0); const notStarted = EMPLOYEES.reduce((s, e) => s + e.courses.filter(c => !c.completed).length, 0); const pct = allCourses ? Math.round((done / allCourses) * 100) : 0; document.getElementById('dash-stats').innerHTML = '
' + total + '
Сотрудников
' + '
' + pct + '%
Выполнено
' + '
' + ov + '
Просрочено
' + '
' + notStarted + '
Не начато
'; const cs = CITIES.map(city => { const emps = EMPLOYEES.filter(e => e.city === city); const o = emps.reduce((s, e) => s + e.courses.filter(c => c.overdue).length, 0); const t = emps.reduce((s, e) => s + e.courses.length, 0); return { city, overdue: o, pct: t ? Math.round((o / t) * 100) : 0 }; }).sort((a, b) => b.pct - a.pct); document.getElementById('dash-problems').innerHTML = cs.map((s, i) => '
' + '' + (i + 1) + '. ' + s.city + '' + s.pct + '% просрочено (' + s.overdue + ')
' ).join(''); const crs = COURSES.map(c => { const notDone = EMPLOYEES.filter(e => !e.courses.find(cc => cc.courseId === c.id && cc.completed)).length; return { name: c.name, pct: Math.round((notDone / total) * 100) }; }).sort((a, b) => b.pct - a.pct).slice(0, 5); document.getElementById('dash-courses').innerHTML = crs.map((s, i) => '
' + '' + (i + 1) + '. ' + s.name + '' + s.pct + '%
' ).join(''); } // ========== ВЫГРУЗКА ========== function exportData(type) { const c = document.getElementById('export-results'); let h = '
'; let data = []; if (type === 'overdue') { h += '

⏰ Просроченные обучения

'; EMPLOYEES.forEach(e => e.courses.filter(c => c.overdue).forEach(c => data.push({ tab: e.tab, fio: e.fullName, city: e.city, course: c.courseName, completed: c.completedDate, overdue: c.nextDate, days: c.daysOverdue }) )); data.sort((a, b) => b.days - a.days); } else if (type === 'city') { h += '

🏙️ По филиалам

'; EMPLOYEES.forEach(e => e.courses.forEach(c => data.push({ tab: e.tab, fio: e.fullName, city: e.city, course: c.courseName, status: c.completed ? (c.overdue ? 'Просрочено' : 'Пройдено') : 'Не пройдено' }) )); } else if (type === 'employee') { h += '

👤 По сотрудникам

'; EMPLOYEES.forEach(e => { const done = e.courses.filter(c => c.completed).length; const ov = e.courses.filter(c => c.overdue).length; data.push({ tab: e.tab, fio: e.fullName, city: e.city, dept: e.department, total: e.courses.length, done, ov, pct: Math.round((done / e.courses.length) * 100) + '%' }); }); } else if (type === 'summary') { h += '

📋 Сводный отчёт

'; CITIES.forEach(city => { const emps = EMPLOYEES.filter(e => e.city === city); const t = emps.reduce((s, e) => s + e.courses.length, 0); const d = emps.reduce((s, e) => s + e.courses.filter(c => c.completed).length, 0); const o = emps.reduce((s, e) => s + e.courses.filter(c => c.overdue).length, 0); data.push({ city, employees: emps.length, total: t, done, overdue: o, pct: t ? Math.round((d / t) * 100) + '%' : '0%' }); }); } if (!data.length) { h += '

Нет данных

'; } else { const hdrs = Object.keys(data[0]); h += '' + hdrs.map(x => '').join('') + ''; data.slice(0, 100).forEach(row => { h += '' + hdrs.map(x => '').join('') + ''; }); h += '
' + x + '
' + (row[x] || '') + '
'; if (data.length > 100) h += '

Показано 100 из ' + data.length + '

'; } h += '
'; c.innerHTML = h; } function exportCSV() { let csv = 'Таб.№;ФИО;Филиал;Должность;Подразделение;Курс;Уровень;Статус;Дата обучения;Следующее;Просрочка дн.\n'; EMPLOYEES.forEach(e => e.courses.forEach(c => { const st = c.completed ? (c.overdue ? 'Просрочено' : 'Пройдено') : 'Не пройдено'; csv += e.tab + ';' + e.fullName + ';' + e.city + ';' + e.position + ';' + e.department + ';' + c.courseName + ';' + c.level + ';' + st + ';' + (c.completedDate || '') + ';' + (c.nextDate || '') + ';' + (c.daysOverdue || 0) + '\n'; })); const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'training_' + new Date().toISOString().split('T')[0] + '.csv'; a.click(); } // ========== ИНИЦИАЛИЗАЦИЯ ========== document.addEventListener('DOMContentLoaded', function() { const saved = localStorage.getItem('tb_data'); if (saved) { try { EMPLOYEES = JSON.parse(saved); } catch(e) {} } renderDashboard(); renderCityStats(); });