corp-university-dashboard/script.js

375 lines
15 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 = {};
const headerMap = {
'фио': 'name', 'ф.и.о': 'name', 'сотрудник': 'name', 'employee': 'name', 'имя': 'name',
'дивизион': 'division', 'подразделение': 'division', 'отдел': 'division', 'division': 'division', 'department': 'division',
'курс': 'course', 'обучение': 'course', 'training': 'course', 'программа': 'course',
'тренинг': 'course', 'тренинг/курс': 'course', 'название тренинга': 'course', 'название курса': 'course',
'what': 'course', 'название': 'course', 'тема': 'course', 'дисциплина': 'course', 'module': 'course', 'модуль': 'course',
'статус': 'status', 'status': 'status', 'состояние': 'status',
'оценка': 'score', 'ball': 'score', 'score': 'score', 'рейтинг': 'score', 'балл': 'score',
'тренер': 'trainer', 'trainer': 'coach', 'преподаватель': 'trainer', 'coach': 'trainer',
'дата': 'date', 'date': 'date', 'дата прохождения': 'date'
};
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;
}
const sampleRow = jsonData[0] || {};
const rawCols = Object.keys(sampleRow);
const normalized = normalizeHeaders([sampleRow])[0];
const mapping = rawCols.map(k => {
const nk = k.toLowerCase().trim();
const mapped = headerMap[nk] || nk;
const val = normalized[mapped] || '(пусто)';
return `<b>${k}</b> → ${mapped} = "${val}"`;
}).join('<br>');
document.getElementById('debug').classList.remove('hidden');
document.getElementById('debugColumns').innerHTML =
`<strong>Оригинальные столбцы:</strong> ${rawCols.join(', ')}<br><br>${mapping}`;
processAndRender(jsonData);
};
reader.readAsArrayBuffer(file);
});
function normalizeHeaders(rows) {
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
});
}