376 lines
13 KiB
HTML
376 lines
13 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Дашборд сертификации сотрудников</title>
|
||
<style>
|
||
* { margin: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||
min-height: 100vh;
|
||
padding: 20px;
|
||
}
|
||
.container { max-width: 1600px; margin: 0 auto; }
|
||
h1 { color: #fff; text-align: center; margin-bottom: 25px; font-size: clamp(24px, 4vw, 36px); }
|
||
|
||
.stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 15px;
|
||
margin-bottom: 25px;
|
||
}
|
||
.stat-card {
|
||
background: rgba(255,255,255,0.95);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
|
||
}
|
||
.stat-card .num { font-size: 36px; font-weight: bold; color: #1e3c72; }
|
||
.stat-card .label { color: #666; font-size: 14px; margin-top: 5px; }
|
||
|
||
.filters {
|
||
background: rgba(255,255,255,0.95);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 15px;
|
||
align-items: center;
|
||
}
|
||
.filters label { font-weight: 600; color: #333; margin-right: 8px; }
|
||
.filters select, .filters input {
|
||
padding: 10px 14px;
|
||
border: 2px solid #e0e0e0;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
background: #fff;
|
||
}
|
||
.filters input { flex: 1; min-width: 200px; }
|
||
.chip {
|
||
display: inline-block;
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border: 2px solid transparent;
|
||
transition: all 0.2s;
|
||
}
|
||
.chip.active { border-color: #1e3c72; background: #e8f0fe; }
|
||
.chip-level-Специалист { background: #e8f5e9; color: #2e7d32; }
|
||
.chip-level-Профессионал { background: #fff3e0; color: #ef6c00; }
|
||
.chip-level-Эксперт { background: #f3e5f5; color: #6a1b9a; }
|
||
|
||
.charts-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||
gap: 20px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.chart-card {
|
||
background: rgba(255,255,255,0.95);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||
}
|
||
.chart-card h3 { margin-bottom: 15px; color: #333; font-size: 16px; }
|
||
.bar-chart { display: flex; flex-direction: column; gap: 8px; }
|
||
.bar-row { display: flex; align-items: center; gap: 10px; }
|
||
.bar-label { width: 120px; font-size: 13px; color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.bar-track { flex: 1; height: 24px; background: #e0e0e0; border-radius: 4px; overflow: hidden; }
|
||
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.3s; }
|
||
.bar-value { width: 40px; text-align: right; font-size: 13px; font-weight: 600; color: #333; }
|
||
|
||
.table-card {
|
||
background: rgba(255,255,255,0.95);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
|
||
overflow-x: auto;
|
||
}
|
||
table { width: 100%; border-collapse: collapse; min-width: 900px; }
|
||
th, td { padding: 12px 10px; text-align: left; border-bottom: 1px solid #e0e0e0; }
|
||
th { background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: #fff; font-weight: 600; position: sticky; top: 0; }
|
||
tr:hover { background: #f5f5f5; }
|
||
.level-badge {
|
||
display: inline-block;
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.level-Специалист { background: #e8f5e9; color: #2e7d32; }
|
||
.level-Профессионал { background: #fff3e0; color: #ef6c00; }
|
||
.level-Эксперт { background: #f3e5f5; color: #6a1b9a; }
|
||
.city-badge { background: #e3f2fd; color: #1565c0; padding: 4px 10px; border-radius: 12px; font-size: 12px; }
|
||
|
||
.empty-state { text-align: center; padding: 60px 20px; color: #888; }
|
||
.export-btn {
|
||
padding: 10px 20px;
|
||
background: #2e7d32;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
margin-left: auto;
|
||
}
|
||
.export-btn:hover { background: #1b5e20; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>📊 Дашборд сертификации сотрудников</h1>
|
||
|
||
<div class="stats">
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-total">0</div>
|
||
<div class="label">Всего сертификатов</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-employees">0</div>
|
||
<div class="label">Сотрудников</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-specialist">0</div>
|
||
<div class="label">Специалистов</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-professional">0</div>
|
||
<div class="label">Профессионалов</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-expert">0</div>
|
||
<div class="label">Экспертов</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="num" id="stat-vendors">0</div>
|
||
<div class="label">Вендоров</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<label>🔍 Поиск:</label>
|
||
<input type="text" id="search" placeholder="ФИО, должность, сертификат...">
|
||
<label>Город:</label>
|
||
<select id="filter-city"><option value="">Все</option></select>
|
||
<label>Уровень:</label>
|
||
<div id="filter-levels"></div>
|
||
<button class="export-btn" onclick="exportCsv()">📥 Выгрузить в Excel</button>
|
||
</div>
|
||
|
||
<div class="charts-row">
|
||
<div class="chart-card">
|
||
<h3>📈 По уровням сертификации</h3>
|
||
<div class="bar-chart" id="chart-levels"></div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<h3>🏢 По городам</h3>
|
||
<div class="bar-chart" id="chart-cities"></div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<h3>🏷️ Топ вендоров</h3>
|
||
<div class="bar-chart" id="chart-vendors"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-card">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>№</th>
|
||
<th>Город</th>
|
||
<th>ФИО сотрудника</th>
|
||
<th>Должность</th>
|
||
<th>Вендор</th>
|
||
<th>Сертификат</th>
|
||
<th>Уровень</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="table-body"></tbody>
|
||
</table>
|
||
<div class="empty-state" id="empty-state">
|
||
<p>Ничего не найдено</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let allData = [];
|
||
let filteredData = [];
|
||
let selectedLevels = new Set();
|
||
|
||
async function load() {
|
||
const res = await fetch('/data.json').then(r => r.json());
|
||
allData = res.employees || [];
|
||
filteredData = [...allData];
|
||
initFilters();
|
||
render();
|
||
}
|
||
|
||
function initFilters() {
|
||
const cities = [...new Set(allData.map(d => d.city))].sort();
|
||
const citySelect = document.getElementById('filter-city');
|
||
cities.forEach(c => {
|
||
if (c) {
|
||
const opt = document.createElement('option');
|
||
opt.value = c;
|
||
opt.textContent = c;
|
||
citySelect.appendChild(opt);
|
||
}
|
||
});
|
||
|
||
const levels = ['Специалист', 'Профессионал', 'Эксперт'];
|
||
const levelsContainer = document.getElementById('filter-levels');
|
||
levels.forEach(l => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'chip chip-level-' + l;
|
||
chip.textContent = l;
|
||
chip.onclick = () => toggleLevel(l, chip);
|
||
levelsContainer.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
function toggleLevel(level, chip) {
|
||
if (selectedLevels.has(level)) {
|
||
selectedLevels.delete(level);
|
||
chip.classList.remove('active');
|
||
} else {
|
||
selectedLevels.add(level);
|
||
chip.classList.add('active');
|
||
}
|
||
applyFilters();
|
||
}
|
||
|
||
function applyFilters() {
|
||
const search = document.getElementById('search').value.toLowerCase();
|
||
const city = document.getElementById('filter-city').value;
|
||
|
||
filteredData = allData.filter(d => {
|
||
const matchSearch = !search ||
|
||
(d.name && d.name.toLowerCase().includes(search)) ||
|
||
(d.position && d.position.toLowerCase().includes(search)) ||
|
||
(d.certificate && d.certificate.toLowerCase().includes(search)) ||
|
||
(d.vendor && d.vendor.toLowerCase().includes(search));
|
||
const matchCity = !city || d.city === city;
|
||
const matchLevel = selectedLevels.size === 0 || selectedLevels.has(d.level);
|
||
return matchSearch && matchCity && matchLevel;
|
||
});
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
renderStats();
|
||
renderCharts();
|
||
renderTable();
|
||
}
|
||
|
||
function renderStats() {
|
||
const total = allData.length;
|
||
const employees = new Set(allData.map(d => d.name)).size;
|
||
const specialist = allData.filter(d => d.level === 'Специалист').length;
|
||
const professional = allData.filter(d => d.level === 'Профессионал').length;
|
||
const expert = allData.filter(d => d.level === 'Эксперт').length;
|
||
const vendors = new Set(allData.map(d => d.vendor)).size;
|
||
|
||
document.getElementById('stat-total').textContent = total;
|
||
document.getElementById('stat-employees').textContent = employees;
|
||
document.getElementById('stat-specialist').textContent = specialist;
|
||
document.getElementById('stat-professional').textContent = professional;
|
||
document.getElementById('stat-expert').textContent = expert;
|
||
document.getElementById('stat-vendors').textContent = vendors;
|
||
}
|
||
|
||
function renderCharts() {
|
||
const levelCounts = {};
|
||
const cityCounts = {};
|
||
const vendorCounts = {};
|
||
|
||
allData.forEach(d => {
|
||
levelCounts[d.level] = (levelCounts[d.level] || 0) + 1;
|
||
cityCounts[d.city] = (cityCounts[d.city] || 0) + 1;
|
||
vendorCounts[d.vendor] = (vendorCounts[d.vendor] || 0) + 1;
|
||
});
|
||
|
||
renderBarChart('chart-levels', levelCounts, {
|
||
'Специалист': '#4caf50',
|
||
'Профессионал': '#ff9800',
|
||
'Эксперт': '#9c27b0'
|
||
});
|
||
|
||
const sortedCities = Object.entries(cityCounts).sort((a, b) => b[1] - a[1]);
|
||
renderBarChart('chart-cities', Object.fromEntries(sortedCities), '#1e3c72');
|
||
|
||
const sortedVendors = Object.entries(vendorCounts).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
||
renderBarChart('chart-vendors', Object.fromEntries(sortedVendors), '#2a5298');
|
||
}
|
||
|
||
function renderBarChart(containerId, data, colorMap) {
|
||
const container = document.getElementById(containerId);
|
||
const max = Math.max(...Object.values(data), 1);
|
||
const getColor = key => typeof colorMap === 'object' ? (colorMap[key] || '#1e3c72') : colorMap;
|
||
|
||
container.innerHTML = Object.entries(data).map(([key, val]) => `
|
||
<div class="bar-row">
|
||
<div class="bar-label">${key || 'Без города'}</div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill" style="width: ${(val / max) * 100}%; background: ${getColor(key)}"></div>
|
||
</div>
|
||
<div class="bar-value">${val}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderTable() {
|
||
const tbody = document.getElementById('table-body');
|
||
const empty = document.getElementById('empty-state');
|
||
|
||
if (filteredData.length === 0) {
|
||
tbody.innerHTML = '';
|
||
empty.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
empty.style.display = 'none';
|
||
tbody.innerHTML = filteredData.map((d, i) => `
|
||
<tr>
|
||
<td>${i + 1}</td>
|
||
<td><span class="city-badge">${d.city}</span></td>
|
||
<td>${escapeHtml(d.name || '—')}</td>
|
||
<td>${escapeHtml(d.position || '—')}</td>
|
||
<td>${escapeHtml(d.vendor || '—')}</td>
|
||
<td>${escapeHtml(d.certificate || '—')}</td>
|
||
<td><span class="level-badge level-${d.level}">${d.level}</span></td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function exportCsv() {
|
||
const head = ['№', 'Город', 'ФИО', 'Должность', 'Вендор', 'Сертификат', 'Уровень'];
|
||
const body = filteredData.map((d, i) => [
|
||
i + 1, d.city, d.name, d.position, d.vendor, d.certificate, d.level
|
||
].map(v => '"' + String(v || '').replace(/"/g, '""') + '"').join(';'));
|
||
const csv = '\uFEFF' + [head.join(';'), ...body].join('\n');
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
||
a.download = 'sertifikacii.csv';
|
||
a.click();
|
||
}
|
||
|
||
document.getElementById('search').addEventListener('input', applyFilters);
|
||
document.getElementById('filter-city').addEventListener('change', applyFilters);
|
||
|
||
load();
|
||
</script>
|
||
</body>
|
||
</html>
|