// ========== ДАННЫЕ ==========
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 += '' + e.tab + ' ' + e.fullName + ' ' + e.city + ' ' + e.position + ' ' + done + '/' + e.courses.length + ' ' + (ov ? '❌ ' + ov : '✅') + ' Подробнее ';
}
h += '
';
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 += '' + c.courseName + ' Ур.' + c.level + ' ' + fmt(c.completedDate) + ' ' + fmt(c.nextDate) + ' ⚠️ ' + c.daysOverdue + ' дн. ';
h += '
';
}
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 += '' + c.courseName + ' Ур.' + c.level + ' ' + fmt(c.completedDate) + ' ' + fmt(c.nextDate) + (left <= 30 ? ' ⚠️ ' + left + ' дн.' : '') + ' ';
}
h += '
';
}
if (notDone.length) {
h += '
❗ Не пройдено (' + notDone.length + ') ';
for (const c of notDone) h += '' + c.courseName + ' (Ур.' + c.level + ') ';
h += ' ';
}
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 =
'' +
'' +
'' +
'' + 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 => '' + x + ' ').join('') + ' ';
data.slice(0, 100).forEach(row => { h += '' + hdrs.map(x => '' + (row[x] || '') + ' ').join('') + ' '; });
h += '
';
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();
});