365 lines
17 KiB
JavaScript
365 lines
17 KiB
JavaScript
// 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();
|
||
}
|
||
});
|