tb-assistant/script.js

420 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ========== ДАННЫЕ ==========
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 = '<div class="card">Сотрудники не найдены</div>'; return; }
let h = '<div class="card" style="overflow-x:auto"><table><thead><tr><th>Таб.№</th><th>ФИО</th><th>Город</th><th>Должность</th><th>Пройдено</th><th>Просрочено</th><th>Прогресс</th><th></th></tr></thead><tbody>';
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 += '<tr><td style="font-family:monospace">' + e.tab + '</td><td><strong>' + e.fullName + '</strong></td><td>' + e.city + '</td><td>' + e.position + '</td><td class="ok">' + done + '/' + e.courses.length + '</td><td class="' + (ov ? 'danger' : '') + '">' + (ov ? '❌ ' + ov : '✅') + '</td><td><div style="display:flex;align-items:center;gap:8px"><div class="progress" style="flex:1"><div class="progress-fill" style="width:' + pct + '%"></div></div><span style="font-size:12px">' + pct + '%</span></div></td><td><button onclick="showDetail(\'' + e.tab + '\')" class="btn" style="padding:6px 12px;font-size:12px">Подробнее</button></td></tr>';
}
h += '</tbody></table></div>';
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 = '<div class="card"><button onclick="searchEmployee()" class="btn" style="padding:6px 12px;font-size:12px;margin-bottom:16px">← Назад</button>';
h += '<div class="flex" style="margin-bottom:16px;gap:24px"><div><span style="color:#64748b">Таб.№:</span> <strong>' + emp.tab + '</strong></div><div><span style="color:#64748b">Город:</span> <strong>' + emp.city + '</strong></div><div><span style="color:#64748b">Должность:</span> <strong>' + emp.position + '</strong></div><div><span style="color:#64748b">Подразделение:</span> <strong>' + emp.department + '</strong></div></div>';
h += '<div style="margin-bottom:16px"><div style="display:flex;justify-content:space-between;margin-bottom:6px"><strong>Общий прогресс</strong><span>' + done.length + ' из ' + emp.courses.length + ' (' + pct + '%)</span></div><div class="progress" style="height:12px"><div class="progress-fill" style="width:' + pct + '%"></div></div></div>';
if (overdue.length) {
h += '<div style="margin-bottom:16px"><h3 class="danger" style="margin-bottom:8px">⏰ Просрочено (' + overdue.length + ')</h3><table><thead><tr><th>Курс</th><th>Уровень</th><th>Завершён</th><th>Должен пройти</th><th>Просрочка</th></tr></thead><tbody>';
for (const c of overdue) h += '<tr><td>' + c.courseName + '</td><td>Ур.' + c.level + '</td><td>' + fmt(c.completedDate) + '</td><td>' + fmt(c.nextDate) + '</td><td class="danger">⚠️ ' + c.daysOverdue + ' дн.</td></tr>';
h += '</tbody></table></div>';
}
if (valid.length) {
h += '<div style="margin-bottom:16px"><h3 class="ok" style="margin-bottom:8px">✅ Действует (' + valid.length + ')</h3><table><thead><tr><th>Курс</th><th>Уровень</th><th>Завершён</th><th>Следующее</th></tr></thead><tbody>';
for (const c of valid) {
const left = Math.ceil((new Date(c.nextDate) - new Date()) / 86400000);
const urg = left <= 30 ? ' class="warn"' : '';
h += '<tr><td>' + c.courseName + '</td><td>Ур.' + c.level + '</td><td>' + fmt(c.completedDate) + '</td><td' + urg + '>' + fmt(c.nextDate) + (left <= 30 ? ' ⚠️ ' + left + ' дн.' : '') + '</td></tr>';
}
h += '</tbody></table></div>';
}
if (notDone.length) {
h += '<div><h3 style="color:#64748b;margin-bottom:8px">❗ Не пройдено (' + notDone.length + ')</h3><ul style="padding-left:20px;color:#64748b">';
for (const c of notDone) h += '<li>' + c.courseName + ' (Ур.' + c.level + ')</li>';
h += '</ul></div>';
}
h += '</div>';
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 =>
'<div class="card city-card" onclick="showCity(\'' + s.city + '\')">' +
'<div style="font-size:24px;font-weight:700">' + s.count + '</div>' +
'<div style="font-size:13px;color:#64748b">' + s.city + '</div>' +
'<div class="progress" style="margin-top:8px"><div class="progress-fill" style="width:' + s.pct + '%"></div></div>' +
'<div style="font-size:11px;margin-top:4px">' + s.pct + '% выполнено' + (s.overdue ? ' <span class="danger">| ' + s.overdue + ' просрочено</span>' : '') + '</div>' +
'</div>'
).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 =
'<div class="card stat-card"><div class="stat-num">' + total + '</div><div class="stat-label">Сотрудников</div></div>' +
'<div class="card stat-card"><div class="stat-num">' + pct + '%</div><div class="stat-label">Выполнено</div></div>' +
'<div class="card stat-card"><div class="stat-num" style="color:#ef4444">' + ov + '</div><div class="stat-label">Просрочено</div></div>' +
'<div class="card stat-card"><div class="stat-num" style="color:#f59e0b">' + notStarted + '</div><div class="stat-label">Не начато</div></div>';
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) =>
'<div style="display:flex;justify-content:space-between;padding:8px 0' + (i < cs.length - 1 ? ';border-bottom:1px solid #f1f5f9' : '') + '">' +
'<span>' + (i + 1) + '. ' + s.city + '</span><span class="danger">' + s.pct + '% просрочено (' + s.overdue + ')</span></div>'
).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) =>
'<div style="display:flex;justify-content:space-between;padding:8px 0;font-size:13px' + (i < crs.length - 1 ? ';border-bottom:1px solid #f1f5f9' : '') + '">' +
'<span>' + (i + 1) + '. ' + s.name + '</span><span class="danger">' + s.pct + '%</span></div>'
).join('');
}
// ========== ВЫГРУЗКА ==========
function exportData(type) {
const c = document.getElementById('export-results');
let h = '<div class="card">';
let data = [];
if (type === 'overdue') {
h += '<h3 style="margin-bottom:12px">⏰ Просроченные обучения</h3>';
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 += '<h3 style="margin-bottom:12px">🏙️ По филиалам</h3>';
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 += '<h3 style="margin-bottom:12px">👤 По сотрудникам</h3>';
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 += '<h3 style="margin-bottom:12px">📋 Сводный отчёт</h3>';
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 += '<p style="color:#64748b">Нет данных</p>'; }
else {
const hdrs = Object.keys(data[0]);
h += '<table><thead><tr>' + hdrs.map(x => '<th>' + x + '</th>').join('') + '</tr></thead><tbody>';
data.slice(0, 100).forEach(row => { h += '<tr>' + hdrs.map(x => '<td>' + (row[x] || '') + '</td>').join('') + '</tr>'; });
h += '</tbody></table>';
if (data.length > 100) h += '<p style="font-size:12px;color:#64748b;margin-top:8px">Показано 100 из ' + data.length + '</p>';
}
h += '</div>';
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();
});