chto-ty-umeesh/index.html

526 lines
19 KiB
HTML
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.

<!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; }
.vendor-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.vendor-chip {
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: #f0f0f0;
color: #555;
border: 2px solid transparent;
transition: all 0.2s;
}
.vendor-chip.active {
background: #e3f2fd;
border-color: #1e3c72;
color: #1e3c72;
}
.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; }
#filter-levels { display: flex; gap: 8px; align-items: center; }
.chip { padding: 6px 14px; border-radius: 16px; cursor: pointer; font-size: 13px; font-weight: 500; user-select: none; transition: all 0.2s; }
.chip-level-Специалист { background: #e8f5e9; color: #2e7d32; border: 2px solid #a5d6a7; }
.chip-level-Профессионал { background: #fff3e0; color: #ef6c00; border: 2px solid #ffcc80; }
.chip-level-Эксперт { background: #f3e5f5; color: #7b1fa2; border: 2px solid #ce93d8; }
.chip.active { transform: scale(1.05); box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.chip-level-Специалист.active { background: #2e7d32; color: #fff; border-color: #1b5e20; }
.chip-level-Профессионал.active { background: #ef6c00; color: #fff; border-color: #e65100; }
.chip-level-Эксперт.active { background: #7b1fa2; color: #fff; border-color: #4a148c; }
.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>
<button onclick="load()" style="display:block;margin:0 auto 20px;padding:10px 25px;background:#fff;color:#1e3c72;border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:14px;">🔄 Обновить данные</button>
<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>
</div>
<div class="filters" style="padding-top: 10px; padding-bottom: 10px;">
<label>🏷️ Вендоры:</label>
<div class="vendor-filter" id="filter-vendors"></div>
<button class="export-btn" onclick="exportCsv()" style="margin-left: auto;">📥 Выгрузить в 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();
let selectedVendors = new Set();
const GOOGLE_SHEET_CSV = 'https://docs.google.com/spreadsheets/d/1U4a2241JGTh2fwjzc36Vpdw8B-TtaCAWPLEIIDnAV-8/export?format=csv&gid=1832793992';
async function load() {
document.getElementById('stat-total').textContent = '...';
try {
const csvText = await fetch(GOOGLE_SHEET_CSV).then(r => r.text());
allData = parseCSV(csvText);
filteredData = [...allData];
initFilters();
render();
} catch (e) {
console.error('Ошибка загрузки данных:', e);
document.getElementById('stat-total').textContent = 'Ошибка';
}
}
function parseCSV(text) {
const lines = text.split('\n').filter(l => l.trim());
const result = [];
let currentRow = null;
let id = 0;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = parseCSVLine(line);
if (parts[2] && parts[2].trim()) {
if (currentRow) {
result.push(currentRow);
}
id++;
currentRow = {
id: id,
city: parts[1] || '',
name: parts[2] || '',
position: parts[3] || '',
vendor: parts[4] || '',
certificate: parts[5] || '',
level: normalizeLevel(parts[6] || '')
};
} else if (currentRow && parts[5]) {
result.push({
id: currentRow.id,
city: currentRow.city,
name: currentRow.name,
position: currentRow.position,
vendor: parts[4] || currentRow.vendor,
certificate: parts[5] || '',
level: normalizeLevel(parts[6] || currentRow.level)
});
}
}
if (currentRow) result.push(currentRow);
return result;
}
function parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
function normalizeLevel(level) {
if (!level) return 'Специалист';
const l = level.toLowerCase();
if (l.includes('эксперт')) return 'Эксперт';
if (l.includes('профессионал') || l.includes('professional')) return 'Профессионал';
return 'Специалист';
}
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);
});
const vendors = [...new Set(allData.map(d => d.vendor))].filter(v => v).sort();
const vendorsContainer = document.getElementById('filter-vendors');
vendors.forEach(v => {
const chip = document.createElement('span');
chip.className = 'vendor-chip';
chip.textContent = v;
chip.onclick = () => toggleVendor(v, chip);
vendorsContainer.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 toggleVendor(vendor, chip) {
if (selectedVendors.has(vendor)) {
selectedVendors.delete(vendor);
chip.classList.remove('active');
} else {
selectedVendors.add(vendor);
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);
const matchVendor = selectedVendors.size === 0 || selectedVendors.has(d.vendor);
return matchSearch && matchCity && matchLevel && matchVendor;
});
render();
}
function render() {
renderStats();
renderCharts();
renderTable();
}
function renderStats() {
const total = allData.length;
const vendors = new Set(allData.map(d => d.vendor)).size;
// Группируем по сотрудникам и находим максимальный уровень
const levelPriority = { 'Специалист': 1, 'Профессионал': 2, 'Эксперт': 3 };
const employeeMaxLevel = {};
allData.forEach(d => {
if (d.name) {
const currentMax = employeeMaxLevel[d.name] || 0;
const newLevel = levelPriority[d.level] || 0;
if (newLevel > currentMax) {
employeeMaxLevel[d.name] = newLevel;
}
}
});
const employees = Object.keys(employeeMaxLevel).length;
const specialist = Object.values(employeeMaxLevel).filter(l => l === 1).length;
const professional = Object.values(employeeMaxLevel).filter(l => l === 2).length;
const expert = Object.values(employeeMaxLevel).filter(l => l === 3).length;
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>