employee-rating/app.js

365 lines
17 KiB
JavaScript
Raw Permalink 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.

// 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 = '<option value="">Все подразделения</option>';
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 = '<span class="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">Норма</span>';
if (emp.isIncompletePeriod) {
statusBadge = '<span class="px-2 py-1 rounded-full text-xs font-medium warning-badge">⚠️ Неполный период</span>';
} else if (emp.availabilityCoeff < 0.7) {
statusBadge = '<span class="px-2 py-1 rounded-full text-xs font-medium low-availability">Низкая доступность</span>';
}
tr.innerHTML = `
<td class="px-4 py-4 text-sm font-semibold text-gray-900">${emp.currentPosition}</td>
<td class="px-4 py-4 text-sm text-gray-900 font-medium">${emp.fio}</td>
<td class="px-4 py-4 text-sm text-gray-600">${emp.dept}</td>
<td class="px-4 py-4 text-sm">
<span class="text-lg font-bold text-purple-600">${emp.rating.toFixed(2)}</span>
</td>
<td class="px-4 py-4 text-sm text-gray-600">${emp.prevPlace}</td>
<td class="px-4 py-4 text-sm font-semibold ${trendClass}">${trendIcon} ${Math.abs(emp.positionChange)}</td>
<td class="px-4 py-4 text-sm">
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 rounded-full h-2">
<div class="h-2 rounded-full ${emp.availabilityCoeff >= 0.9 ? 'bg-green-500' : emp.availabilityCoeff >= 0.7 ? 'bg-yellow-500' : 'bg-red-500'}"
style="width: ${emp.availabilityCoeff * 100}%"></div>
</div>
<span class="text-xs font-medium">${(emp.availabilityCoeff * 100).toFixed(0)}%</span>
</div>
</td>
<td class="px-4 py-4 text-sm">${statusBadge}</td>
`;
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) => `
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<span class="text-lg font-bold ${i === 0 ? 'text-yellow-500' : i === 1 ? 'text-gray-400' : i === 2 ? 'text-amber-600' : 'text-gray-600'}">#${i + 1}</span>
<span class="ml-2 font-medium text-gray-800">${emp.fio}</span>
</div>
<span class="text-purple-600 font-semibold">${emp.rating.toFixed(2)}</span>
</div>
`).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 => `
<div class="flex items-center justify-between p-3 bg-green-50 rounded-lg">
<div class="font-medium text-gray-800">${emp.fio}</div>
<span class="trend-up font-semibold">↑ ${emp.positionChange}</span>
</div>
`).join('') || '<p class="text-gray-500 text-sm">Нет данных</p>';
// 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 => `
<div class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div class="font-medium text-gray-800">${emp.fio}</div>
<span class="trend-down font-semibold">↓ ${Math.abs(emp.positionChange)}</span>
</div>
`).join('') || '<p class="text-gray-500 text-sm">Нет данных</p>';
// Low availability
const lowAvail = employeesData.filter(e => e.availabilityCoeff < 0.7).slice(0, 5);
document.getElementById('lowAvailability').innerHTML = lowAvail.map(emp => `
<div class="flex items-center justify-between p-3 bg-red-50 rounded-lg">
<div class="font-medium text-gray-800">${emp.fio}</div>
<span class="text-red-600 font-semibold">${(emp.availabilityCoeff * 100).toFixed(0)}%</span>
</div>
`).join('') || '<p class="text-gray-500 text-sm">Все сотрудники с нормальной доступностью</p>';
// Needs review
const review = employeesData.filter(e => e.needsReview).slice(0, 5);
document.getElementById('needsReview').innerHTML = review.map(emp => `
<div class="p-3 bg-yellow-50 rounded-lg">
<div class="font-medium text-gray-800">${emp.fio}</div>
<div class="text-xs text-gray-600 mt-1">
${emp.isIncompletePeriod ? '⚠️ Неполный период' : ''}
${emp.availabilityCoeff < 0.7 ? '⚠️ Низкая доступность' : ''}
${Math.abs(emp.positionChange) > 5 ? '⚠️ Резкое изменение позиции' : ''}
</div>
</div>
`).join('') || '<p class="text-gray-500 text-sm">Все сотрудники в норме</p>';
// 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 = `
<div class="flex justify-between py-2 border-b">
<span class="text-gray-600">Всего сотрудников:</span>
<span class="font-semibold">${employeesData.length}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-600">Средний рейтинг:</span>
<span class="font-semibold">${avgRating.toFixed(2)}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-600">Средняя доступность:</span>
<span class="font-semibold">${(avgAvailability * 100).toFixed(1)}%</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-gray-600">Рост позиций:</span>
<span class="font-semibold text-green-600">${growthCount}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-gray-600">Снижение позиций:</span>
<span class="font-semibold text-red-600">${declineCount}</span>
</div>
`;
}
// 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 = `
<div class="space-y-2 text-sm">
${emp.sickness > 0 ? `<div class="flex justify-between"><span>🤒 Больничный:</span><span>${emp.sickness} ч</span></div>` : ''}
${emp.vacation > 0 ? `<div class="flex justify-between"><span>🏖️ Отпуск:</span><span>${emp.vacation} ч</span></div>` : ''}
${emp.socialLeave > 0 ? `<div class="flex justify-between"><span>📋 Соц. отпуск:</span><span>${emp.socialLeave} ч</span></div>` : ''}
${emp.timeOff > 0 ? `<div class="flex justify-between"><span>⏰ Отгул:</span><span>${emp.timeOff} ч</span></div>` : ''}
${emp.absences ? `<div class="text-gray-600 mt-2">${emp.absences}</div>` : ''}
</div>
`;
} else {
absencesDiv.innerHTML = '<p class="text-gray-500 text-sm">Отсутствий не зафиксировано</p>';
}
// Warnings
const warningsDiv = document.getElementById('modalWarnings');
if (emp.isIncompletePeriod || emp.availabilityCoeff < 0.7) {
warningsDiv.innerHTML = `
<div class="bg-red-50 border border-red-200 rounded-xl p-4 mb-4">
<h3 class="text-lg font-semibold text-red-800 mb-2">⚠️ Внимание</h3>
${emp.isIncompletePeriod ? '<p class="text-sm text-red-700">🔴 Требуется учитывать неполный период при сравнении</p>' : ''}
${emp.availabilityCoeff < 0.7 ? '<p class="text-sm text-red-700 mt-1">🔴 Низкая доступность рабочего времени</p>' : ''}
</div>
`;
} 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();
}
});