diff --git a/app.js b/app.js
new file mode 100644
index 0000000..adf9c20
--- /dev/null
+++ b/app.js
@@ -0,0 +1,182 @@
+var charts = {};
+
+document.getElementById('uploadBtn').addEventListener('click', function() {
+ document.getElementById('excelFile').click();
+});
+
+document.getElementById('excelFile').addEventListener('change', function(e) {
+ var file = e.target.files[0];
+ if (!file) return;
+ document.getElementById('statusMsg').textContent = 'Загрузка: ' + file.name + '...';
+
+ var reader = new FileReader();
+ reader.onload = function(ev) {
+ try {
+ var arr = new Uint8Array(ev.target.result);
+ var wb = XLSX.read(arr, {type: 'array'});
+ var ws = wb.Sheets[wb.SheetNames[0]];
+ var rows = XLSX.utils.sheet_to_json(ws, {defval: ''});
+ if (!rows.length) { document.getElementById('statusMsg').textContent = 'Файл пуст'; return; }
+ build(rows);
+ } catch(err) {
+ document.getElementById('statusMsg').textContent = 'Ошибка: ' + err.message;
+ }
+ };
+ reader.readAsArrayBuffer(file);
+});
+
+function norm(rows) {
+ var map = {
+ 'фио':'name','ф.и.о':'name','сотрудник':'name','employee':'name','имя':'name',
+ 'дивизион':'div','подразделение':'div','отдел':'div','division':'div','department':'div',
+ 'курс':'course','обучение':'course','training':'course','программа':'course',
+ 'тренинг':'course','название тренинга':'course','название курса':'course','название обучения':'course',
+ 'тема':'course','тема курса':'course','дисциплина':'course','модуль':'course','module':'course',
+ 'статус':'status','status':'status','состояние':'status',
+ 'оценка':'score','балл':'score','баллы':'score','рейтинг':'score','ball':'score','score':'score',
+ 'тренер':'trainer','преподаватель':'trainer','trainer':'trainer','coach':'trainer',
+ 'дата':'date','date':'date'
+ };
+ return rows.map(function(r) {
+ var out = {};
+ for (var k in r) {
+ var key = k.toLowerCase().replace(/\s+/g,' ').trim();
+ out[map[key] || key] = r[k];
+ }
+ return out;
+ });
+}
+
+function done(s) {
+ s = (s||'').toLowerCase();
+ return s.indexOf('завершен')>=0 || s.indexOf('прошёл')>=0 || s.indexOf('прошел')>=0 ||
+ s.indexOf('пройден')>=0 || s==='completed' || s==='ок' || s==='да';
+}
+
+function build(raw) {
+ var d = norm(raw);
+
+ document.getElementById('uploadSection').classList.add('hidden');
+ document.getElementById('dashboard').classList.remove('hidden');
+
+ var allN = {}, doneN = {}, scores = [], train = {}, divs = {}, trainers = {};
+
+ d.forEach(function(r) {
+ allN[r.name] = true;
+ if (done(r.status)) doneN[r.name] = true;
+
+ var sc = parseFloat(r.score);
+ if (!isNaN(sc) && sc > 0) scores.push(sc);
+
+ var c = r.course || 'Без темы';
+ if (!train[c]) train[c] = {all:{}, ok:{}, sc:[], tr:{}, div:{}};
+ train[c].all[r.name] = true;
+ if (done(r.status)) train[c].ok[r.name] = true;
+ if (!isNaN(sc) && sc > 0) train[c].sc.push(sc);
+ if (r.trainer) train[c].tr[r.trainer] = true;
+
+ var dv = r.division || '—';
+ if (!train[c].div[dv]) train[c].div[dv] = {all:{}, ok:{}};
+ train[c].div[dv].all[r.name] = true;
+ if (done(r.status)) train[c].div[dv].ok[r.name] = true;
+
+ if (!divs[dv]) divs[dv] = {all:{}, ok:{}};
+ divs[dv].all[r.name] = true;
+ if (done(r.status)) divs[dv].ok[r.name] = true;
+
+ var tr = r.trainer || '—';
+ if (!trainers[tr]) trainers[tr] = {n:0, sc:[]};
+ trainers[tr].n++;
+ if (!isNaN(sc) && sc > 0) trainers[tr].sc.push(sc);
+ });
+
+ var totalN = Object.keys(allN).length;
+ var doneNn = Object.keys(doneN).length;
+ document.getElementById('statTotal').textContent = totalN;
+ document.getElementById('statDone').textContent = doneNn;
+ document.getElementById('statRate').textContent = totalN ? Math.round(doneNn/totalN*100)+'%' : '0%';
+ document.getElementById('statAvg').textContent = scores.length ? (scores.reduce(function(a,b){return a+b},0)/scores.length).toFixed(1) : '—';
+
+ var tKeys = Object.keys(train);
+ var tDone = tKeys.map(function(k){return Object.keys(train[k].ok).length});
+ var tAll = tKeys.map(function(k){return Object.keys(train[k].all).length});
+
+ barH('chartTrainings', tKeys, [
+ {label:'Всего', data:tAll, bg:'rgba(33,150,243,0.4)', bc:'#2196F3'},
+ {label:'Прошли', data:tDone, bg:'rgba(76,175,80,0.7)', bc:'#4caf50'}
+ ]);
+
+ var dvNames = Object.keys(divs);
+ var dvColors = ['#2196F3','#4caf50','#ff9800','#9c27b0','#00bcd4','#ff5722','#8bc34a','#e91e63'];
+ var divDatasets = dvNames.map(function(dv,i){
+ return {label:dv, data:tKeys.map(function(tk){return train[tk].div[dv]?Object.keys(train[tk].div[dv].ok).length:0}),
+ bg:dvColors[i%dvColors.length]+'cc', bc:dvColors[i%dvColors.length]};
+ });
+ stacked('chartTrainDiv', tKeys, divDatasets);
+
+ bar('chartDiv', dvNames, [
+ {label:'Всего', data:dvNames.map(function(d){return Object.keys(divs[d].all).length}), bg:'rgba(33,150,243,0.6)', bc:'#2196F3'},
+ {label:'Прошли', data:dvNames.map(function(d){return Object.keys(divs[d].ok).length}), bg:'rgba(76,175,80,0.6)', bc:'#4caf50'}
+ ]);
+
+ var trNames = Object.keys(trainers);
+ bar('chartTrainers', trNames, [
+ {label:'Занятий', data:trNames.map(function(t){return trainers[t].n}), bg:'rgba(255,152,0,0.6)', bc:'#ff9800'}
+ ]);
+
+ var sd = {5:0,4:0,3:0,2:0,1:0};
+ scores.forEach(function(s){var r=Math.round(s);if(sd[r]!==undefined)sd[r]++});
+ doughnut('chartScores', Object.keys(sd).map(function(k){return 'Оценка '+k}), Object.values(sd),
+ ['#4caf50','#8bc34a','#ff9800','#ff5722','#f44336']);
+
+ var tAvg = tKeys.map(function(k){var sc=train[k].sc;return sc.length?(sc.reduce(function(a,b){return a+b},0)/sc.length).toFixed(1):0});
+ bar('chartTrainScores', tKeys, [{label:'Средняя оценка', data:tAvg,
+ bg:tAvg.map(function(v){return v>=4?'rgba(76,175,80,0.7)':v>=3?'rgba(255,152,0,0.7)':'rgba(244,67,54,0.7)'}),
+ bc:tAvg.map(function(v){return v>=4?'#4caf50':v>=3?'#ff9800':'#f44336'})}]);
+
+ document.querySelector('#tblTrainings tbody').innerHTML = tKeys.map(function(k){
+ var t=train[k], tot=Object.keys(t.all).length, ok=Object.keys(t.ok).length;
+ var avg=t.sc.length?(t.sc.reduce(function(a,b){return a+b},0)/t.sc.length).toFixed(1):'—';
+ var trn=Object.keys(t.tr).join(', ')||'—';
+ return '
| '+k+' | '+ok+' | '+(tot-ok)+' | '+(tot?Math.round(ok/tot*100):0)+'% | '+avg+' | '+trn+' |
';
+ }).join('');
+
+ var cph = {};
+ d.forEach(function(r){if(!cph[r.name])cph[r.name]={d:r.division||'',c:{} };if(r.course)cph[r.name].c[r.course]=true});
+ var champs=Object.keys(cph).map(function(n){return{name:n,div:cph[n].d,cnt:Object.keys(cph[n].c).length}})
+ .sort(function(a,b){return b.cnt-a.cnt}).slice(0,10);
+ document.querySelector('#tblChamp tbody').innerHTML = champs.map(function(c){
+ return '| '+c.name+' | '+c.div+' | '+c.cnt+' |
';
+ }).join('');
+
+ var noT=Object.keys(allN).filter(function(n){return !doneN[n]});
+ document.querySelector('#tblNoTrain tbody').innerHTML = noT.slice(0,20).map(function(n){
+ return '| '+n+' | '+(cph[n]?cph[n].d:'')+' |
';
+ }).join('');
+
+ document.querySelector('#tblAll tbody').innerHTML = d.map(function(r){
+ var cls=done(r.status)?'status-completed':'status-not-started';
+ return '| '+(r.name||'')+' | '+(r.division||'')+' | '+(r.course||'')+' | '+(r.status||'')+' | '+(r.score||'')+' | '+(r.trainer||'')+' |
';
+ }).join('');
+}
+
+function barH(id, labels, ds) {
+ if(charts[id])charts[id].destroy();
+ charts[id]=new Chart(document.getElementById(id),{type:'bar',data:{labels:labels,datasets:ds.map(function(s){return{label:s.label,data:s.data,backgroundColor:s.bg,borderColor:s.bc,borderWidth:1}})},
+ options:{indexAxis:'y',responsive:true,plugins:{legend:{labels:{color:'#e0e0e0'}}},scales:{x:{beginAtZero:true,ticks:{color:'#90a4ae'},grid:{color:'#1e2d3d'}},y:{ticks:{color:'#e0e0e0',font:{size:11}},grid:{color:'#1e2d3d'}}}}});
+}
+function bar(id, labels, ds) {
+ if(charts[id])charts[id].destroy();
+ charts[id]=new Chart(document.getElementById(id),{type:'bar',data:{labels:labels,datasets:ds.map(function(s){return{label:s.label,data:s.data,backgroundColor:s.bg,borderColor:s.bc,borderWidth:1}})},
+ options:{responsive:true,plugins:{legend:{labels:{color:'#e0e0e0'}}},scales:{y:{beginAtZero:true,ticks:{color:'#90a4ae'},grid:{color:'#1e2d3d'}},x:{ticks:{color:'#90a4ae'},grid:{color:'#1e2d3d'}}}}});
+}
+function stacked(id, labels, ds) {
+ if(charts[id])charts[id].destroy();
+ charts[id]=new Chart(document.getElementById(id),{type:'bar',data:{labels:labels,datasets:ds.map(function(s){return{label:s.label,data:s.data,backgroundColor:s.bg,borderColor:s.bc,borderWidth:1}})},
+ options:{responsive:true,plugins:{legend:{labels:{color:'#e0e0e0'}}},scales:{x:{stacked:true,ticks:{color:'#90a4ae',maxRotation:45},grid:{color:'#1e2d3d'}},y:{stacked:true,beginAtZero:true,ticks:{color:'#90a4ae'},grid:{color:'#1e2d3d'}}}}});
+}
+function doughnut(id, labels, data, colors) {
+ if(charts[id])charts[id].destroy();
+ charts[id]=new Chart(document.getElementById(id),{type:'doughnut',data:{labels:labels,datasets:[{data:data,backgroundColor:colors,borderWidth:0}]},
+ options:{responsive:true,plugins:{legend:{labels:{color:'#e0e0e0'}}}}});
+}
diff --git a/index.html b/index.html
index d73cdda..0f70040 100644
--- a/index.html
+++ b/index.html
@@ -5,169 +5,76 @@
Корпоративный университет — Дашборд
-
-
-
+
📊
-
Загрузите данные
-
Перетащите Excel-файл или нажмите для выбора
+
Загрузите Excel-файл
+
Столбцы файла: ФИО, Дивизион, Тема курса, Статус, Оценка, Тренер
-
-
Формат: столбцы — ФИО, Дивизион, Курс, Статус, Оценка, Тренер
-
-
-
-
-
-
Распознанные столбцы:
-
+
+
-
-
-
-
0%
-
Процент прохождения
-
-
+
+
+
+
+
+
+
+
Тренинги — сколько человек прошли
+
-
-
Тренинги — количество прошедших
-
-
+
Тренинги по дивизионам
+
Дивизионы — кто прошёл
-
-
Тренинги по дивизионам
-
-
-
-
Прохождение по дивизионам
-
-
+
Нагрузка тренеров
+
Оценки
+
+
+
+
Средняя оценка по тренингам
+
+
+
+
+
📋 Детализация по тренингам
+
+ | Тренинг | Прошли | Не прошли | % | Оценка | Тренер |
+
-
-
Нагрузка тренеров
-
-
-
-
Распределение оценок
-
-
+
+
-
-
-
Средняя оценка по тренингам
-
-
-
-
Статусы обучения
-
-
-
-
-
-
📋 Кто какие тренинги прошёл
-
-
-
-
- | Тренинг |
- Прошли |
- Не прошли |
- % прохождения |
- Средняя оценка |
- Тренер |
-
-
-
-
-
-
-
-
-
-
🏆 Чемпионы по обучению
-
-
-
- | ФИО |
- Дивизион |
- Курсов пройдено |
-
-
-
-
-
-
-
⚠️ Без обучения
-
-
-
- | ФИО |
- Дивизион |
-
-
-
-
-
-
-
-
-
Детализация по сотрудникам
-
-
-
-
- | ФИО |
- Дивизион |
- Курс |
- Статус |
- Оценка |
- Тренер |
-
-
-
-
-
+
+
Все записи
+
+ | ФИО | Дивизион | Тема курса | Статус | Оценка | Тренер |
+
-
-
+
+
+