diff --git a/index.html b/index.html index 80c70c5..98d6989 100644 --- a/index.html +++ b/index.html @@ -3,62 +3,103 @@ -Помощник по обучению ТБ - +HSE Assistant — Обучение ТБ -
- -
-
-
-🛡️ HSE Assistant -
-
-Открытый доступ -
-
+ +
+ +
Система учёта обучения ТБ
-
- -'; + c.innerHTML = h; } -function formatDate(dateStr) { - if (!dateStr) return '—'; - const [y, m, d] = dateStr.split('-'); - return `${d}.${m}.${y}`; -} +function fmt(d) { if (!d) return '—'; const [y, m, dd] = d.split('-'); return dd + '.' + m + '.' + y; } // ========== РЕГИОНЫ ========== function renderCityStats() { - const container = document.getElementById('city-stats'); + const c = document.getElementById('city-stats'); const stats = CITIES.map(city => { const emps = EMPLOYEES.filter(e => e.city === city); - const totalCourses = emps.reduce((sum, e) => sum + e.courses.length, 0); - const completed = emps.reduce((sum, e) => sum + e.courses.filter(c => c.completed).length, 0); - const overdue = emps.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0); - const progress = totalCourses > 0 ? Math.round((completed / totalCourses) * 100) : 0; - return { city, count: emps.length, progress, overdue }; + 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 }; }); - - container.innerHTML = stats.map(s => ` -
-
${s.count}
-
${s.city}
-
-
${s.progress}% выполнено ${s.overdue > 0 ? `| ${s.overdue} просрочено` : ''}
-
- `).join(''); + c.innerHTML = stats.map(s => + '
' + + '
' + s.count + '
' + + '
' + s.city + '
' + + '
' + + '
' + s.pct + '% выполнено' + (s.overdue ? ' | ' + s.overdue + ' просрочено' : '') + '
' + + '
' + ).join(''); } -function showCityEmployees(city) { - const emps = EMPLOYEES.filter(e => e.city === city); - renderSearchResults(emps); +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 extension = file.name.split('.').pop().toLowerCase(); + const ext = file.name.split('.').pop().toLowerCase(); reader.onload = function(e) { - let csvData = e.target.result; - let parsedData = null; - let fileType = ''; + 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 (extension === 'csv') { - parsedData = parseCSV(csvData); - fileType = 'CSV'; - } else if (extension === 'xlsx') { - try { - parsedData = parseExcel(e.target.result); - fileType = 'Excel'; - } catch (err) { - showUploadMessage(`Ошибка при чтении Excel файла: ${err.message}`, 'error'); - return; - } - } else { - showUploadMessage('Поддерживаются только файлы .csv и .xlsx', 'error'); - return; - } - - if (parsedData && parsedData.length > 0) { - showUploadMessage(`Файл успешно загружен! Найдено ${parsedData.length} строк данных.`, 'success'); - processUploadedData(parsedData, fileType); - } else { - showUploadMessage('Не удалось распознать данные в файле. Проверьте формат CSV/Excel.', 'error'); - } + if (data && data.length) { + processData(data); + msg('Загружено ' + data.length + ' строк', 'ok'); + } else { msg('Не удалось распознать данные', 'err'); } }; + reader.onerror = function() { msg('Ошибка чтения файла', 'err'); }; - reader.onerror = function() { - showUploadMessage('Ошибка при чтении файла', 'error'); - }; - - if (extension === 'csv') { - reader.readAsText(file); - } else if (extension === 'xlsx') { - reader.readAsArrayBuffer(file); - } + if (ext === 'csv') reader.readAsText(file); + else reader.readAsArrayBuffer(file); } -function parseCSV(csvData) { - const lines = csvData.split(/\r?\n/).filter(line => line.trim()); +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 values = lines[i].split(';'); - if (values.length < 3) continue; - + const vals = lines[i].split(';'); + if (vals.length < 3) continue; const row = {}; - for (let j = 0; j < headers.length; j++) { - if (j < values.length) { - row[headers[j]] = values[j]; - } - } + headers.forEach((h, j) => { if (j < vals.length) row[h] = vals[j]; }); data.push(row); } - return data; } -function parseExcel(arrayBuffer) { - try { - const data = new Uint8Array(arrayBuffer); - const workbook = XLSX.read(data, { type: 'array' }); - const sheetName = workbook.SheetNames[0]; - const worksheet = workbook.Sheets[sheetName]; - const json = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); - - const headers = json[0] ? json[0].map(h => { - if (typeof h === 'string') return h.trim(); - return String(h).trim(); - }) : []; - - const data = []; - for (let i = 1; i < json.length; i++) { - if (!json[i] || json[i].length === 0) continue; - - const row = {}; - for (let j = 0; j < headers.length; j++) { - if (j < json[i].length) { - row[headers[j]] = json[i][j]; - } - } - data.push(row); - } - - return data; - } catch (err) { - console.error('Ошибка парсинга Excel:', err); - return null; +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 processUploadedData(uploadedData, fileType) { - try { - const processedEmployees = []; - - for (const row of uploadedData) { - const emp = { - tab: row['Таб.№'] || row['tab'] || row['Табельный'] || '', - lastName: row['ФИО'] ? row['ФИО'].split(' ')[0] : '', - firstName: row['ФИО'] && row['ФИО'].includes(' ') ? row['ФИО'].split(' ')[1].split(' ')[0] : '', - patronymic: row['ФИО'] && row['ФИО'].split(' ').length > 2 ? row['ФИО'].split(' ')[2] : '', - fullName: row['ФИО'] || row['ФИО'] || '', - city: row['Город'] || '', - department: row['Подразделение'] || '', - position: row['Должность'] || row['Штатная должность'] || '', - courses: [] - }; - - if (!emp.tab || !emp.fullName) continue; - - const courseHeaders = ['Курс1', 'Курс2', 'Курс3', 'Курс4', 'Курс5']; - const statusHeaders = ['Статус1', 'Статус2', 'Статус3', 'Статус4', 'Статус5']; - const dateHeaders = ['Дата1', 'Дата2', 'Дата3', 'Дата4', 'Дата5']; - - let courseIndex = 1; - for (const header of courseHeaders) { - const courseName = row[header]; - if (!courseName) break; - - const status = row[statusHeaders[courseIndex - 1]] || row[`Статус ${courseIndex}`] || ''; - const dateStr = row[dateHeaders[courseIndex - 1]] || row[`Дата ${courseIndex}`] || ''; - - const course = { - courseId: courseIndex, - courseName: courseName, - level: Math.floor(Math.random() * 2) + 1, - completed: status.toLowerCase() === 'пройдено' || status.toLowerCase() === 'yes' || status.toLowerCase() === 'true', - completedDate: dateStr && dateStr.match(/\d{2}\.\d{2}\.\d{4}/) ? dateStr.match(/\d{2}\.\d{2}\.\d{4}/)[0] : '', - nextDate: '', - overdue: false, - daysOverdue: 0 - }; - - if (course.completed && course.completedDate) { - const [day, month, year] = course.completedDate.split('.'); - const completedDate = new Date(`${year}-${month}-${day}`); - course.nextDate = new Date(completedDate); - course.nextDate.setMonth(course.nextDate.getMonth() + 12); - course.nextDate = course.nextDate.toISOString().split('T')[0]; - - const today = new Date(); - course.overdue = new Date(course.nextDate) < today; - course.daysOverdue = course.overdue ? Math.floor((today - new Date(course.nextDate)) / 86400000) : 0; - } - - emp.courses.push(course); - courseIndex++; - } - - processedEmployees.push(emp); - } - - EMPLOYEES.length = 0; - EMPLOYEES.push(...processedEmployees); - - saveUploadedDataToLocalStorage(processedEmployees, fileType); - updateDashboardAfterUpload(); - - } catch (error) { - showUploadMessage('Ошибка обработки данных: ' + error.message, 'error'); - } -} - -function saveUploadedDataToLocalStorage(data, fileType) { - try { - const savedData = { - employees: data, - fileType: fileType, - timestamp: new Date().toISOString(), - version: '1.0' +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; - localStorage.setItem('tb_assistant_uploaded_data', JSON.stringify(savedData)); - localStorage.setItem('tb_assistant_last_upload', new Date().toISOString()); + 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 }; - showUploadMessage('Данные успешно загружены и сохранены!', 'success'); - } catch (error) { - showUploadMessage('Данные загружены, но не сохранены в локальное хранилище: ' + error.message, 'warning'); + 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 updateDashboardAfterUpload() { - if (currentUser && isAdmin) { - if (document.getElementById('tab-dashboard').classList.contains('hidden')) { - showTab('dashboard'); - } - renderDashboard(); - renderCityStats(); - if (document.getElementById('admin-panel')) { - document.getElementById('admin-panel').classList.remove('hidden'); - } - } -} - -function showUploadMessage(message, type) { - const container = document.getElementById('upload-results'); - if (!container) return; - - const msgDiv = document.createElement('div'); - msgDiv.className = `p-3 mb-2 rounded text-sm ${type === 'error' ? 'bg-red-50 text-red-700 border border-red-200' : type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-yellow-50 text-yellow-700 border border-yellow-200'}`; - msgDiv.textContent = message; - - container.insertBefore(msgDiv, container.firstChild); - - if (type === 'success') { - setTimeout(() => { - if (msgDiv.parentNode) msgDiv.parentNode.removeChild(msgDiv); - }, 5000); - } +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 totalEmp = EMPLOYEES.length; - const totalCourses = EMPLOYEES.reduce((sum, e) => sum + e.courses.length, 0); - const completed = EMPLOYEES.reduce((sum, e) => sum + e.courses.filter(c => c.completed).length, 0); - const overdue = EMPLOYEES.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0); - const notStarted = EMPLOYEES.reduce((sum, e) => sum + e.courses.filter(c => !c.completed).length, 0); - const progress = totalCourses > 0 ? Math.round((completed / totalCourses) * 100) : 0; + 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 = ` -
${totalEmp}
Сотрудников
-
${progress}%
Выполнено
-
${overdue}
Просрочено
-
${notStarted}
Не начато
- `; + document.getElementById('dash-stats').innerHTML = + '
' + total + '
Сотрудников
' + + '
' + pct + '%
Выполнено
' + + '
' + ov + '
Просрочено
' + + '
' + notStarted + '
Не начато
'; - const cityStats = CITIES.map(city => { + const cs = CITIES.map(city => { const emps = EMPLOYEES.filter(e => e.city === city); - const ov = emps.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0); - const total = emps.reduce((sum, e) => sum + e.courses.length, 0); - return { city, overdue: ov, pct: total > 0 ? Math.round((ov / total) * 100) : 0 }; + 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 = cityStats.map((s, i) => - `
- ${i + 1}. ${s.city} - ${s.pct}% просрочено (${s.overdue}) -
` + document.getElementById('dash-problems').innerHTML = cs.map((s, i) => + '
' + + '' + (i + 1) + '. ' + s.city + '' + s.pct + '% просрочено (' + s.overdue + ')
' ).join(''); - const courseStats = COURSES.map(c => { - const total = EMPLOYEES.length; + 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, notDone, pct: Math.round((notDone / total) * 100) }; + 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 = courseStats.map((s, i) => - `
- ${i + 1}. ${s.name} - ${s.pct}% -
` + document.getElementById('dash-courses').innerHTML = crs.map((s, i) => + '
' + + '' + (i + 1) + '. ' + s.name + '' + s.pct + '%
' ).join(''); } // ========== ВЫГРУЗКА ========== function exportData(type) { - const container = document.getElementById('export-results'); - let html = '
'; + const c = document.getElementById('export-results'); + let h = '
'; let data = []; if (type === 'overdue') { - html += '

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

'; - for (const emp of EMPLOYEES) { - for (const c of emp.courses.filter(c => c.overdue)) { - data.push({ tab: emp.tab, fio: emp.fullName, city: emp.city, course: c.courseName, completed: c.completedDate, overdue: c.nextDate, days: c.daysOverdue }); - } - } + 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') { - html += '

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

'; - for (const emp of EMPLOYEES) { - for (const c of emp.courses) { - data.push({ tab: emp.tab, fio: emp.fullName, city: emp.city, course: c.courseName, status: c.completed ? (c.overdue ? 'Просрочено' : 'Пройдено') : 'Не пройдено' }); - } - } + 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') { - html += '

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

'; - for (const emp of EMPLOYEES) { - const completed = emp.courses.filter(c => c.completed).length; - const overdue = emp.courses.filter(c => c.overdue).length; - data.push({ tab: emp.tab, fio: emp.fullName, city: emp.city, department: emp.department, total: emp.courses.length, completed, overdue, progress: Math.round((completed / emp.courses.length) * 100) + '%' }); - } + 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') { - html += '

📋 Сводный отчёт по филиалам

'; - for (const city of CITIES) { + h += '

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

'; + CITIES.forEach(city => { const emps = EMPLOYEES.filter(e => e.city === city); - const total = emps.reduce((sum, e) => sum + e.courses.length, 0); - const completed = emps.reduce((sum, e) => sum + e.courses.filter(c => c.completed).length, 0); - const overdue = emps.reduce((sum, e) => sum + e.courses.filter(c => c.overdue).length, 0); - data.push({ city, employees: emps.length, totalCourses: total, completed, overdue, progress: total > 0 ? Math.round((completed / total) * 100) + '%' : '0%' }); - } + 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 === 0) { - html += '

Нет данных для выгрузки

'; - } else { - const headers = Object.keys(data[0]); - html += '' + headers.map(h => ``).join('') + ''; - for (const row of data.slice(0, 100)) { - html += '' + headers.map(h => ``).join('') + ''; - } - html += '
${h}
${row[h]}
'; - if (data.length > 100) html += `

Показано 100 из ${data.length} записей

`; + 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 + '

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