corp-university-dashboard/script.js

359 lines
14 KiB
JavaScript
Raw 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.

let chartInstances = {};
document.getElementById('excelFile').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(event) {
const data = new Uint8Array(event.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(firstSheet, { defval: '' });
if (jsonData.length === 0) {
alert('Файл пуст или имеет неверный формат');
return;
}
processAndRender(jsonData);
};
reader.readAsArrayBuffer(file);
});
function normalizeHeaders(rows) {
const headerMap = {
'фио': 'name', 'ф.и.о': 'name', 'сотрудник': 'name', 'employee': 'name', 'имя': 'name',
'дивизион': 'division', 'подразделение': 'division', 'отдел': 'division', 'division': 'division', 'department': 'division',
'курс': 'course', 'обучение': 'course', 'training': 'course', 'программа': 'course',
'статус': 'status', 'status': 'status', 'состояние': 'status',
'оценка': 'score', 'ball': 'score', 'score': 'score', 'рейтинг': 'score', 'балл': 'score',
'тренер': 'trainer', 'trainer': 'coach', 'преподаватель': 'trainer', 'coach': 'trainer',
'дата': 'date', 'date': 'date', 'дата прохождения': 'date'
};
return rows.map(row => {
const normalized = {};
for (const key in row) {
const cleanKey = key.toLowerCase().trim();
const mapped = headerMap[cleanKey] || cleanKey;
normalized[mapped] = row[key];
}
return normalized;
});
}
function processAndRender(rawData) {
const data = normalizeHeaders(rawData);
document.querySelector('.upload-section').classList.add('hidden');
document.getElementById('dashboard').classList.remove('hidden');
const totalEmployees = new Set(data.map(r => r.name)).size;
const completed = data.filter(r => {
const s = (r.status || '').toLowerCase();
return s.includes('завершен') || s.includes('прошёл') || s.includes('прошел') || s === 'completed' || s.includes('ок');
});
const completedEmployees = new Set(completed.map(r => r.name)).size;
const rate = totalEmployees ? Math.round(completedEmployees / totalEmployees * 100) : 0;
const scores = data.map(r => parseFloat(r.score)).filter(s => !isNaN(s) && s > 0);
const avgScore = scores.length ? (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1) : '—';
document.getElementById('totalEmployees').textContent = totalEmployees;
document.getElementById('completedCount').textContent = completedEmployees;
document.getElementById('completionRate').textContent = rate + '%';
document.getElementById('avgScore').textContent = avgScore;
const trainingStats = {};
data.forEach(r => {
const course = r.course || 'Без названия';
if (!trainingStats[course]) trainingStats[course] = { completed: new Set(), all: new Set(), scores: [], trainers: new Set(), divisions: {} };
trainingStats[course].all.add(r.name);
const s = (r.status || '').toLowerCase();
const isCompleted = s.includes('завершен') || s.includes('прошёл') || s.includes('прошел') || s === 'completed' || s.includes('ок');
if (isCompleted) {
trainingStats[course].completed.add(r.name);
}
const score = parseFloat(r.score);
if (!isNaN(score) && score > 0) trainingStats[course].scores.push(score);
if (r.trainer) trainingStats[course].trainers.add(r.trainer);
const div = r.division || 'Без дивизиона';
if (!trainingStats[course].divisions[div]) trainingStats[course].divisions[div] = { total: new Set(), completed: new Set() };
trainingStats[course].divisions[div].total.add(r.name);
if (isCompleted) trainingStats[course].divisions[div].completed.add(r.name);
});
const trainingLabels = Object.keys(trainingStats);
const trainingCompletedCounts = trainingLabels.map(t => trainingStats[t].completed.size);
const trainingTotalCounts = trainingLabels.map(t => trainingStats[t].all.size);
renderChart('trainingChart', 'bar', {
labels: trainingLabels,
datasets: [
{
label: 'Всего записей',
data: trainingTotalCounts,
backgroundColor: 'rgba(33, 150, 243, 0.4)',
borderColor: '#2196F3',
borderWidth: 1
},
{
label: 'Прошли',
data: trainingCompletedCounts,
backgroundColor: 'rgba(76, 175, 80, 0.7)',
borderColor: '#4caf50',
borderWidth: 1
}
]
}, {
indexAxis: 'y',
scales: {
x: { beginAtZero: true, ticks: { color: '#90a4ae' }, grid: { color: '#1e2d3d' } },
y: { ticks: { color: '#e0e0e0', font: { size: 11 } }, grid: { color: '#1e2d3d' } }
}
});
const allDivisions = [...new Set(data.map(r => r.division || 'Без дивизиона'))];
const trainingByDivDatasets = allDivisions.map((div, i) => {
const colors = ['#2196F3', '#4caf50', '#ff9800', '#9c27b0', '#00bcd4', '#ff5722', '#8bc34a', '#e91e63'];
return {
label: div,
data: trainingLabels.map(t => trainingStats[t].divisions[div] ? trainingStats[t].divisions[div].completed.size : 0),
backgroundColor: colors[i % colors.length] + 'aa',
borderColor: colors[i % colors.length],
borderWidth: 1
};
});
renderChart('trainingByDivisionChart', 'bar', {
labels: trainingLabels,
datasets: trainingByDivDatasets
}, {
scales: {
x: { stacked: true, ticks: { color: '#90a4ae', maxRotation: 45 }, grid: { color: '#1e2d3d' } },
y: { stacked: true, beginAtZero: true, ticks: { color: '#90a4ae' }, grid: { color: '#1e2d3d' } }
}
});
const divisionStats = {};
data.forEach(r => {
const div = r.division || 'Без дивизиона';
if (!divisionStats[div]) divisionStats[div] = { total: new Set(), completed: new Set() };
divisionStats[div].total.add(r.name);
const s = (r.status || '').toLowerCase();
if (s.includes('завершен') || s.includes('прошёл') || s.includes('прошел') || s === 'completed' || s.includes('ок')) {
divisionStats[div].completed.add(r.name);
}
});
renderChart('divisionChart', 'bar', {
labels: Object.keys(divisionStats),
datasets: [
{
label: 'Всего',
data: Object.values(divisionStats).map(d => d.total.size),
backgroundColor: 'rgba(33, 150, 243, 0.6)',
borderColor: '#2196F3',
borderWidth: 1
},
{
label: 'Прошли',
data: Object.values(divisionStats).map(d => d.completed.size),
backgroundColor: 'rgba(76, 175, 80, 0.6)',
borderColor: '#4caf50',
borderWidth: 1
}
]
});
const trainerLoad = {};
data.forEach(r => {
const t = r.trainer || 'Не указан';
if (!trainerLoad[t]) trainerLoad[t] = { count: 0, scores: [] };
trainerLoad[t].count++;
const s = parseFloat(r.score);
if (!isNaN(s) && s > 0) trainerLoad[t].scores.push(s);
});
const trainerLabels = Object.keys(trainerLoad);
const trainerCounts = trainerLabels.map(t => trainerLoad[t].count);
const trainerAvg = trainerLabels.map(t => {
const sc = trainerLoad[t].scores;
return sc.length ? (sc.reduce((a, b) => a + b, 0) / sc.length).toFixed(1) : 0;
});
renderChart('trainerChart', 'bar', {
labels: trainerLabels,
datasets: [
{
label: 'Занятий',
data: trainerCounts,
backgroundColor: 'rgba(255, 152, 0, 0.6)',
borderColor: '#ff9800',
borderWidth: 1
},
{
label: 'Средняя оценка',
data: trainerAvg,
backgroundColor: 'rgba(156, 39, 176, 0.6)',
borderColor: '#9c27b0',
borderWidth: 1,
yAxisID: 'y1'
}
]
}, {
scales: {
y: { beginAtZero: true, ticks: { color: '#90a4ae' }, grid: { color: '#1e2d3d' } },
y1: { position: 'right', beginAtZero: true, max: 5, ticks: { color: '#90a4ae' }, grid: { display: false } },
x: { ticks: { color: '#90a4ae' }, grid: { color: '#1e2d3d' } }
}
});
const scoreDist = { '5': 0, '4': 0, '3': 0, '2': 0, '1': 0 };
scores.forEach(s => {
const rounded = Math.round(s);
if (scoreDist[String(rounded)] !== undefined) scoreDist[String(rounded)]++;
});
renderChart('scoreChart', 'doughnut', {
labels: Object.keys(scoreDist).map(k => 'Оценка ' + k),
datasets: [{
data: Object.values(scoreDist),
backgroundColor: ['#4caf50', '#8bc34a', '#ff9800', '#ff5722', '#f44336'],
borderWidth: 0
}]
});
const statusCounts = {};
data.forEach(r => {
const s = (r.status || 'Неизвестно').trim();
statusCounts[s] = (statusCounts[s] || 0) + 1;
});
renderChart('statusChart', 'pie', {
labels: Object.keys(statusCounts),
datasets: [{
data: Object.values(statusCounts),
backgroundColor: ['#4caf50', '#ff9800', '#f44336', '#2196F3', '#9c27b0', '#00bcd4'],
borderWidth: 0
}]
});
const trainingAvgScores = trainingLabels.map(t => {
const sc = trainingStats[t].scores;
return sc.length ? (sc.reduce((a, b) => a + b, 0) / sc.length).toFixed(1) : 0;
});
renderChart('trainingScoreChart', 'bar', {
labels: trainingLabels,
datasets: [{
label: 'Средняя оценка',
data: trainingAvgScores,
backgroundColor: trainingAvgScores.map(v => v >= 4 ? 'rgba(76,175,80,0.7)' : v >= 3 ? 'rgba(255,152,0,0.7)' : 'rgba(244,67,54,0.7)'),
borderColor: trainingAvgScores.map(v => v >= 4 ? '#4caf50' : v >= 3 ? '#ff9800' : '#f44336'),
borderWidth: 1
}]
}, {
scales: {
y: { beginAtZero: true, max: 5, ticks: { color: '#90a4ae' }, grid: { color: '#1e2d3d' } },
x: { ticks: { color: '#e0e0e0', maxRotation: 45 }, grid: { color: '#1e2d3d' } }
}
});
const trainingDetailBody = document.querySelector('#trainingDetailTable tbody');
trainingDetailBody.innerHTML = trainingLabels.map(t => {
const st = trainingStats[t];
const total = st.all.size;
const done = st.completed.size;
const pct = total ? Math.round(done / total * 100) : 0;
const avg = st.scores.length ? (st.scores.reduce((a, b) => a + b, 0) / st.scores.length).toFixed(1) : '—';
const trainers = [...st.trainers].join(', ') || '—';
return `<tr>
<td><strong>${t}</strong></td>
<td>${done}</td>
<td>${total - done}</td>
<td>${pct}%</td>
<td>${avg}</td>
<td>${trainers}</td>
</tr>`;
}).join('');
const coursesPerPerson = {};
data.forEach(r => {
if (!coursesPerPerson[r.name]) coursesPerPerson[r.name] = { division: r.division || '', courses: new Set() };
if (r.course) coursesPerPerson[r.name].courses.add(r.course);
});
const champions = Object.entries(coursesPerPerson)
.map(([name, d]) => ({ name, division: d.division, count: d.courses.size }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
const championsBody = document.querySelector('#championsTable tbody');
championsBody.innerHTML = champions.map(c =>
`<tr><td>${c.name}</td><td>${c.division}</td><td>${c.count}</td></tr>`
).join('');
const completedNames = new Set(completed.map(r => r.name));
const allNames = new Set(data.map(r => r.name));
const noTraining = [...allNames].filter(n => !completedNames.has(n));
const noTrainingDivs = {};
data.forEach(r => {
if (noTraining.includes(r.name)) noTrainingDivs[r.name] = r.division || '';
});
const noTrainingBody = document.querySelector('#noTrainingTable tbody');
noTrainingBody.innerHTML = noTraining.slice(0, 20).map(n =>
`<tr><td>${n}</td><td>${noTrainingDivs[n]}</td></tr>`
).join('');
const detailBody = document.querySelector('#detailTable tbody');
detailBody.innerHTML = data.map(r => {
const s = (r.status || '').toLowerCase();
let cls = '';
if (s.includes('завершен') || s.includes('прошёл') || s.includes('прошел') || s === 'completed' || s.includes('ок')) cls = 'status-completed';
else if (s.includes('ходе') || s.includes('progress')) cls = 'status-in-progress';
else cls = 'status-not-started';
return `<tr>
<td>${r.name || ''}</td>
<td>${r.division || ''}</td>
<td>${r.course || ''}</td>
<td class="${cls}">${r.status || ''}</td>
<td>${r.score || ''}</td>
<td>${r.trainer || ''}</td>
</tr>`;
}).join('');
}
function renderChart(canvasId, type, chartData, extraOptions) {
if (chartInstances[canvasId]) chartInstances[canvasId].destroy();
const defaults = {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { labels: { color: '#e0e0e0', font: { size: 12 } } }
}
};
if (type === 'bar' || type === 'line') {
defaults.scales = {
y: { beginAtZero: true, ticks: { color: '#90a4ae' }, grid: { color: '#1e2d3d' } },
x: { ticks: { color: '#90a4ae' }, grid: { color: '#1e2d3d' } }
};
}
if (extraOptions) {
Object.assign(defaults, extraOptions);
if (extraOptions.scales) defaults.scales = extraOptions.scales;
}
const ctx = document.getElementById(canvasId).getContext('2d');
chartInstances[canvasId] = new Chart(ctx, {
type: type,
data: chartData,
options: defaults
});
}