// Global state let employeesData = []; let filteredData = []; let currentPeriod = ''; // Process uploaded Excel file function processFile() { const fileInput = document.getElementById('fileInput'); const file = fileInput.files[0]; if (!file) { alert('Пожалуйста, выберите файл'); return; } const reader = new FileReader(); reader.onload = function(e) { try { const data = new Uint8Array(e.target.result); const workbook = XLSX.read(data, { type: 'array' }); // Read first sheet const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; const jsonData = XLSX.utils.sheet_to_json(firstSheet); parseEmployeeData(jsonData); } catch (error) { console.error('Error parsing file:', error); alert('Ошибка при чтении файла. Убедитесь, что это корректный Excel-файл.'); } }; reader.readAsArrayBuffer(file); } // Parse employee data from Excel function parseEmployeeData(data) { employeesData = data.map((row, index) => { // Try to map common column names const fio = row['ФИО'] || row['FIO'] || row['Сотрудник'] || row['Employee'] || `Сотрудник ${index + 1}`; const dept = row['Подразделение'] || row['Department'] || row['Отдел'] || 'Не указано'; const rating = parseFloat(row['Рейтинг'] || row['Rating'] || row['Баллы'] || 0); const prevPlace = parseInt(row['Предыдущее место'] || row['Место пред.'] || row['PrevPlace'] || index + 1); const plannedHours = parseFloat(row['План часы'] || row['Плановые часы'] || row['PlannedHours'] || 160); const absenceHours = parseFloat(row['Часы отсутствия'] || row['Отсутствие часы'] || row['AbsenceHours'] || 0); const schedule = row['График'] || row['Schedule'] || 'Стандартный'; const absences = row['Отсутствия'] || row['Absences'] || ''; const sickness = parseFloat(row['Больничный'] || row['SickLeave'] || 0); const vacation = parseFloat(row['Отпуск'] || row['Vacation'] || 0); const socialLeave = parseFloat(row['Соц. отпуск'] || row['SocialLeave'] || 0); const timeOff = parseFloat(row['Отгул'] || row['TimeOff'] || 0); // Calculate availability const availableHours = Math.max(0, plannedHours - absenceHours); const availabilityCoeff = plannedHours > 0 ? (availableHours / plannedHours) : 1; // Calculate position change const currentPosition = index + 1; const positionChange = prevPlace - currentPosition; // Positive = moved up // Determine if needs review const needsReview = availabilityCoeff < 0.7 || positionChange < -5 || positionChange > 5; const isIncompletePeriod = availabilityCoeff < 0.5; return { id: index, fio, dept, rating, prevPlace, currentPosition, positionChange, plannedHours, absenceHours, availableHours, availabilityCoeff, schedule, absences, sickness, vacation, socialLeave, timeOff, needsReview, isIncompletePeriod }; }); // Sort by rating (descending) employeesData.sort((a, b) => b.rating - a.rating); // Update current positions after sorting employeesData.forEach((emp, idx) => { emp.currentPosition = idx + 1; }); filteredData = [...employeesData]; // Show controls and table document.getElementById('controlsSection').classList.remove('hidden'); document.getElementById('tableView').classList.remove('hidden'); // Populate filters populateDepartmentFilter(); applyFilters(); } // Populate department filter function populateDepartmentFilter() { const depts = [...new Set(employeesData.map(emp => emp.dept))].sort(); const select = document.getElementById('deptFilter'); select.innerHTML = ''; depts.forEach(dept => { const option = document.createElement('option'); option.value = dept; option.textContent = dept; select.appendChild(option); }); } // Apply filters function applyFilters() { const deptFilter = document.getElementById('deptFilter').value; const nameFilter = document.getElementById('nameFilter').value.toLowerCase(); filteredData = employeesData.filter(emp => { const matchesDept = !deptFilter || emp.dept === deptFilter; const matchesName = !nameFilter || emp.fio.toLowerCase().includes(nameFilter); return matchesDept && matchesName; }); renderTable(); } // Render table function renderTable() { const tbody = document.getElementById('ratingTableBody'); tbody.innerHTML = ''; filteredData.forEach(emp => { const tr = document.createElement('tr'); tr.className = 'hover:bg-gray-50 cursor-pointer transition'; tr.onclick = () => showEmployeeModal(emp.id); const trendClass = emp.positionChange > 0 ? 'trend-up' : emp.positionChange < 0 ? 'trend-down' : 'trend-neutral'; const trendIcon = emp.positionChange > 0 ? '↑' : emp.positionChange < 0 ? '↓' : '−'; let statusBadge = 'Норма'; if (emp.isIncompletePeriod) { statusBadge = '⚠️ Неполный период'; } else if (emp.availabilityCoeff < 0.7) { statusBadge = 'Низкая доступность'; } tr.innerHTML = ` ${emp.currentPosition} ${emp.fio} ${emp.dept} ${emp.rating.toFixed(2)} ${emp.prevPlace} ${trendIcon} ${Math.abs(emp.positionChange)}
${(emp.availabilityCoeff * 100).toFixed(0)}%
${statusBadge} `; tbody.appendChild(tr); }); } // Switch view function switchView(view) { const tableView = document.getElementById('tableView'); const analysisView = document.getElementById('analysisView'); const tableViewBtn = document.getElementById('tableViewBtn'); const analysisViewBtn = document.getElementById('analysisViewBtn'); if (view === 'table') { tableView.classList.remove('hidden'); analysisView.classList.add('hidden'); tableViewBtn.classList.add('tab-active'); tableViewBtn.classList.remove('text-gray-600'); analysisViewBtn.classList.remove('tab-active'); analysisViewBtn.classList.add('text-gray-600'); } else { tableView.classList.add('hidden'); analysisView.classList.remove('hidden'); analysisViewBtn.classList.add('tab-active'); analysisViewBtn.classList.remove('text-gray-600'); tableViewBtn.classList.remove('tab-active'); tableViewBtn.classList.add('text-gray-600'); renderAnalysis(); } } // Render analysis view function renderAnalysis() { // TOP employees const topEmployees = employeesData.slice(0, 5); document.getElementById('topEmployees').innerHTML = topEmployees.map((emp, i) => `
#${i + 1} ${emp.fio}
${emp.rating.toFixed(2)}
`).join(''); // Biggest growth const growth = [...employeesData].filter(e => e.positionChange > 0).sort((a, b) => b.positionChange - a.positionChange).slice(0, 5); document.getElementById('biggestGrowth').innerHTML = growth.map(emp => `
${emp.fio}
↑ ${emp.positionChange}
`).join('') || '

Нет данных

'; // Biggest decline const decline = [...employeesData].filter(e => e.positionChange < 0).sort((a, b) => a.positionChange - b.positionChange).slice(0, 5); document.getElementById('biggestDecline').innerHTML = decline.map(emp => `
${emp.fio}
↓ ${Math.abs(emp.positionChange)}
`).join('') || '

Нет данных

'; // Low availability const lowAvail = employeesData.filter(e => e.availabilityCoeff < 0.7).slice(0, 5); document.getElementById('lowAvailability').innerHTML = lowAvail.map(emp => `
${emp.fio}
${(emp.availabilityCoeff * 100).toFixed(0)}%
`).join('') || '

Все сотрудники с нормальной доступностью

'; // Needs review const review = employeesData.filter(e => e.needsReview).slice(0, 5); document.getElementById('needsReview').innerHTML = review.map(emp => `
${emp.fio}
${emp.isIncompletePeriod ? '⚠️ Неполный период' : ''} ${emp.availabilityCoeff < 0.7 ? '⚠️ Низкая доступность' : ''} ${Math.abs(emp.positionChange) > 5 ? '⚠️ Резкое изменение позиции' : ''}
`).join('') || '

Все сотрудники в норме

'; // Summary stats const avgRating = employeesData.reduce((sum, e) => sum + e.rating, 0) / employeesData.length; const avgAvailability = employeesData.reduce((sum, e) => sum + e.availabilityCoeff, 0) / employeesData.length; const growthCount = employeesData.filter(e => e.positionChange > 0).length; const declineCount = employeesData.filter(e => e.positionChange < 0).length; document.getElementById('summaryStats').innerHTML = `
Всего сотрудников: ${employeesData.length}
Средний рейтинг: ${avgRating.toFixed(2)}
Средняя доступность: ${(avgAvailability * 100).toFixed(1)}%
Рост позиций: ${growthCount}
Снижение позиций: ${declineCount}
`; } // Show employee modal function showEmployeeModal(id) { const emp = employeesData.find(e => e.id === id); if (!emp) return; document.getElementById('modalName').textContent = emp.fio; document.getElementById('modalDept').textContent = emp.dept; document.getElementById('modalRating').textContent = emp.rating.toFixed(2); document.getElementById('modalPrevPlace').textContent = `#${emp.prevPlace}`; document.getElementById('modalAvailability').textContent = `${(emp.availabilityCoeff * 100).toFixed(1)}%`; document.getElementById('modalPlannedHours').textContent = `${emp.plannedHours} ч`; document.getElementById('modalAbsenceHours').textContent = `${emp.absenceHours} ч`; document.getElementById('modalAvailableHours').textContent = `${emp.availableHours} ч`; document.getElementById('modalSchedule').textContent = emp.schedule; // Absences const absencesDiv = document.getElementById('modalAbsences'); if (emp.absences || emp.sickness || emp.vacation || emp.socialLeave || emp.timeOff) { absencesDiv.innerHTML = `
${emp.sickness > 0 ? `
🤒 Больничный:${emp.sickness} ч
` : ''} ${emp.vacation > 0 ? `
🏖️ Отпуск:${emp.vacation} ч
` : ''} ${emp.socialLeave > 0 ? `
📋 Соц. отпуск:${emp.socialLeave} ч
` : ''} ${emp.timeOff > 0 ? `
⏰ Отгул:${emp.timeOff} ч
` : ''} ${emp.absences ? `
${emp.absences}
` : ''}
`; } else { absencesDiv.innerHTML = '

Отсутствий не зафиксировано

'; } // Warnings const warningsDiv = document.getElementById('modalWarnings'); if (emp.isIncompletePeriod || emp.availabilityCoeff < 0.7) { warningsDiv.innerHTML = `

⚠️ Внимание

${emp.isIncompletePeriod ? '

🔴 Требуется учитывать неполный период при сравнении

' : ''} ${emp.availabilityCoeff < 0.7 ? '

🔴 Низкая доступность рабочего времени

' : ''}
`; } else { warningsDiv.innerHTML = ''; } // Explanation document.getElementById('modalExplanation').textContent = `Рейтинг ${emp.rating.toFixed(2)} рассчитан на основе показателей за период. ` + `Коэффициент доступности ${(emp.availabilityCoeff * 100).toFixed(1)}% показывает, ` + `какую часть планового времени сотрудник был доступен. ` + `${emp.positionChange > 0 ? `Позиция улучшилась на ${emp.positionChange} места.` : emp.positionChange < 0 ? `Позиция снизилась на ${Math.abs(emp.positionChange)} места.` : 'Позиция не изменилась.'} ` + `${emp.isIncompletePeriod ? '⚠️ Рекомендация: не использовать для кадровых решений из-за неполного периода.' : ''}`; document.getElementById('employeeModal').classList.remove('hidden'); document.getElementById('employeeModal').classList.add('flex'); } // Close modal function closeModal() { document.getElementById('employeeModal').classList.add('hidden'); document.getElementById('employeeModal').classList.remove('flex'); } // Close modal on overlay click document.getElementById('employeeModal').addEventListener('click', function(e) { if (e.target === this) { closeModal(); } }); // Keyboard shortcut document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { closeModal(); } });