420 lines
23 KiB
JavaScript
420 lines
23 KiB
JavaScript
// ========== ДАННЫЕ ==========
|
||
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();
|
||
});
|