465 lines
20 KiB
HTML
465 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-theme="auto">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Аналитическая панель</title>
|
||
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-components.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-page.css">
|
||
<style>
|
||
.dashboard { display: grid; gap: var(--kt-ai-space-6); grid-template-columns: 1fr; }
|
||
.upload-zone { border: 2px dashed var(--kt-ai-border); border-radius: var(--kt-ai-radius-2xl); padding: var(--kt-ai-space-10); text-align: center; background: var(--kt-ai-bg-soft); cursor: pointer; transition: all 0.3s; }
|
||
.upload-zone:hover { border-color: var(--kt-ai-primary); background: var(--kt-ai-card-bg); }
|
||
.upload-zone.dragover { border-color: var(--kt-ai-primary); background: var(--kt-ai-primary); color: white; }
|
||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kt-ai-space-4); }
|
||
.stat-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); }
|
||
.stat-value { font-size: 2rem; font-weight: var(--kt-ai-weight-bold); color: var(--kt-ai-primary); }
|
||
.stat-label { color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm); }
|
||
.insights-list { display: flex; flex-direction: column; gap: var(--kt-ai-space-3); }
|
||
.insight-item { padding: var(--kt-ai-space-4); border-radius: var(--kt-ai-radius-lg); border-left: 4px solid; }
|
||
.insight-item.info { border-left-color: var(--kt-ai-primary); background: rgba(0, 184, 204, 0.1); }
|
||
.insight-item.warning { border-left-color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
|
||
.insight-item.metric { border-left-color: #10b981; background: rgba(16, 185, 129, 0.1); }
|
||
.filters-bar { display: flex; gap: var(--kt-ai-space-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--kt-ai-space-4); }
|
||
.filters-bar select, .filters-bar input { min-width: 200px; }
|
||
.table-container { overflow-x: auto; background: var(--kt-ai-card-bg); border-radius: var(--kt-ai-radius-xl); border: 1px solid var(--kt-ai-card-border); }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { padding: var(--kt-ai-space-3) var(--kt-ai-space-4); text-align: left; border-bottom: 1px solid var(--kt-ai-border); }
|
||
th { background: var(--kt-ai-bg-soft); font-weight: var(--kt-ai-weight-semibold); position: sticky; top: 0; }
|
||
tr:hover { background: var(--kt-ai-bg-soft); }
|
||
.chart-container { background: var(--kt-ai-card-bg); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); border: 1px solid var(--kt-ai-card-border); }
|
||
.bar-chart { display: flex; align-items: flex-end; gap: 8px; height: 200px; padding-top: var(--kt-ai-space-4); overflow-x: auto; }
|
||
.bar { flex: 1; min-width: 40px; background: linear-gradient(to top, var(--kt-ai-primary), rgba(0, 184, 204, 0.6)); border-radius: var(--kt-ai-radius-md) var(--kt-ai-radius-md) 0 0; transition: all 0.3s; position: relative; }
|
||
.bar:hover { opacity: 0.8; transform: scaleX(1.05); }
|
||
.bar-label { position: absolute; bottom: -25px; left: 50%; transform: translateX(-50%); font-size: 10px; white-space: nowrap; color: var(--kt-ai-fg-muted); }
|
||
.bar-value { position: absolute; top: -20px; left: 50%; transform: translateX(-50%); font-size: 11px; font-weight: var(--kt-ai-weight-semibold); }
|
||
.hidden { display: none; }
|
||
.loading { opacity: 0.5; pointer-events: none; }
|
||
.tabs { display: flex; gap: var(--kt-ai-space-2); margin-bottom: var(--kt-ai-space-4); border-bottom: 1px solid var(--kt-ai-border); padding-bottom: var(--kt-ai-space-2); }
|
||
.tab { padding: var(--kt-ai-space-2) var(--kt-ai-space-4); border-radius: var(--kt-ai-radius-md); cursor: pointer; transition: all 0.2s; }
|
||
.tab:hover { background: var(--kt-ai-bg-soft); }
|
||
.tab.active { background: var(--kt-ai-primary); color: white; }
|
||
.section-title { font-size: var(--kt-ai-text-xl); font-weight: var(--kt-ai-weight-semibold); margin-bottom: var(--kt-ai-space-3); }
|
||
@media (min-width: 768px) { .dashboard { grid-template-columns: 2fr 1fr; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="kt-ai-pagehead">
|
||
<div class="kt-ai-wrap kt-ai-pagehead-in">
|
||
<strong>📊 Аналитическая панель</strong>
|
||
<span class="kt-ai-spacer"></span>
|
||
<button class="kt-ai-btn" data-variant="ghost" onclick="clearData()">Очистить данные</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="kt-ai-wrap" style="padding: var(--kt-ai-space-8) 0;">
|
||
|
||
<div id="uploadSection">
|
||
<div class="upload-zone" id="dropZone">
|
||
<div style="font-size: 3rem; margin-bottom: var(--kt-ai-space-4);">📁</div>
|
||
<h2 style="margin-bottom: var(--kt-ai-space-2);">Загрузите файл с данными</h2>
|
||
<p style="color: var(--kt-ai-fg-muted); margin-bottom: var(--kt-ai-space-4);">
|
||
Перетащите файл сюда или кликните для выбора<br>
|
||
<small>Поддерживаются: Excel (.xlsx, .xls), CSV</small>
|
||
</p>
|
||
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv" style="display: none;">
|
||
<button class="kt-ai-btn" data-variant="primary" data-size="lg" onclick="document.getElementById('fileInput').click()">
|
||
Выбрать файл
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="dashboardSection" class="hidden">
|
||
<div class="tabs">
|
||
<div class="tab active" data-tab="overview" onclick="switchTab('overview')">Обзор</div>
|
||
<div class="tab" data-tab="table" onclick="switchTab('table')">Таблица</div>
|
||
<div class="tab" data-tab="charts" onclick="switchTab('charts')">Графики</div>
|
||
</div>
|
||
|
||
<div id="overviewTab">
|
||
<div class="dashboard">
|
||
<div>
|
||
<div class="section-title">Ключевые показатели</div>
|
||
<div class="stats-grid" id="statsGrid" style="margin-bottom: var(--kt-ai-space-6);"></div>
|
||
|
||
<div class="section-title">Данные</div>
|
||
<div class="table-container">
|
||
<div class="filters-bar" style="padding: var(--kt-ai-space-4);">
|
||
<select id="filterColumn" class="kt-ai-select" onchange="applyFilters()">
|
||
<option value="">Все колонки</option>
|
||
</select>
|
||
<input type="text" id="filterValue" class="kt-ai-input" placeholder="Поиск..." oninput="applyFilters()" style="flex: 1;">
|
||
<select id="sortColumn" class="kt-ai-select" onchange="applyFilters()">
|
||
<option value="">Сортировка</option>
|
||
</select>
|
||
<select id="sortOrder" class="kt-ai-select" onchange="applyFilters()">
|
||
<option value="asc">По возрастанию</option>
|
||
<option value="desc">По убыванию</option>
|
||
</select>
|
||
</div>
|
||
<table id="dataTable">
|
||
<thead id="tableHead"></thead>
|
||
<tbody id="tableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div style="padding: var(--kt-ai-space-3); color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm);">
|
||
Показано <span id="shownCount">0</span> из <span id="totalCount">0</span> записей
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="section-title">Аналитические выводы</div>
|
||
<div class="insights-list" id="insightsList"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="tableTab" class="hidden">
|
||
<div class="section-title">Полная таблица данных</div>
|
||
<div class="filters-bar">
|
||
<select id="fullFilterColumn" class="kt-ai-select" onchange="applyFullFilters()">
|
||
<option value="">Все колонки</option>
|
||
</select>
|
||
<input type="text" id="fullFilterValue" class="kt-ai-input" placeholder="Поиск..." oninput="applyFullFilters()" style="flex: 1;">
|
||
<button class="kt-ai-btn" data-variant="ghost" onclick="exportToCSV()">Экспорт в CSV</button>
|
||
</div>
|
||
<div class="table-container">
|
||
<table id="fullTable">
|
||
<thead id="fullTableHead"></thead>
|
||
<tbody id="fullTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="chartsTab" class="hidden">
|
||
<div class="section-title">Визуализация данных</div>
|
||
<div style="display: flex; flex-direction: column; gap: var(--kt-ai-space-6);">
|
||
<div id="chartsContainer"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<script>
|
||
let currentData = { columns: [], rows: [], stats: {}, insights: [] };
|
||
let allData = { columns: [], rows: [], stats: {}, insights: [] };
|
||
|
||
const dropZone = document.getElementById('dropZone');
|
||
const fileInput = document.getElementById('fileInput');
|
||
|
||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||
dropZone.addEventListener(eventName, preventDefaults, false);
|
||
});
|
||
|
||
function preventDefaults(e) { e.preventDefault(); e.stopPropagation(); }
|
||
|
||
['dragenter', 'dragover'].forEach(eventName => {
|
||
dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
|
||
});
|
||
|
||
['dragleave', 'drop'].forEach(eventName => {
|
||
dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
|
||
});
|
||
|
||
dropZone.addEventListener('drop', handleDrop, false);
|
||
fileInput.addEventListener('change', handleFileSelect, false);
|
||
|
||
function handleDrop(e) {
|
||
const files = e.dataTransfer.files;
|
||
if (files.length) uploadFile(files[0]);
|
||
}
|
||
|
||
function handleFileSelect(e) {
|
||
const files = e.target.files;
|
||
if (files.length) uploadFile(files[0]);
|
||
}
|
||
|
||
function uploadFile(file) {
|
||
dropZone.classList.add('loading');
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
fetch('/api/upload', { method: 'POST', body: formData })
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data.error) { alert('Ошибка: ' + data.error); }
|
||
else { loadData(data); }
|
||
})
|
||
.catch(e => alert('Ошибка загрузки: ' + e.message))
|
||
.finally(() => dropZone.classList.remove('loading'));
|
||
}
|
||
|
||
function loadData(data) {
|
||
currentData = data;
|
||
allData = JSON.parse(JSON.stringify(data));
|
||
document.getElementById('uploadSection').classList.add('hidden');
|
||
document.getElementById('dashboardSection').classList.remove('hidden');
|
||
|
||
renderStats(data.stats);
|
||
renderInsights(data.insights);
|
||
renderTable(data);
|
||
renderCharts();
|
||
populateFilters();
|
||
}
|
||
|
||
function renderStats(stats) {
|
||
const grid = document.getElementById('statsGrid');
|
||
const entries = Object.entries(stats);
|
||
if (entries.length === 0) {
|
||
grid.innerHTML = '<div class="stat-card"><div class="stat-value">0</div><div class="stat-label">Числовых полей не найдено</div></div>';
|
||
return;
|
||
}
|
||
grid.innerHTML = entries.slice(0, 6).map(([col, s]) => `
|
||
<div class="stat-card">
|
||
<div class="stat-value">${formatNumber(s.sum)}</div>
|
||
<div class="stat-label">${col} (сумма)</div>
|
||
<div style="font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); margin-top: var(--kt-ai-space-2);">
|
||
Среднее: ${s.avg.toFixed(1)} | Мин: ${s.min} | Макс: ${s.max}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderInsights(insights) {
|
||
const list = document.getElementById('insightsList');
|
||
if (!insights || insights.length === 0) {
|
||
list.innerHTML = '<div class="insight-item info">Загрузите данные для получения аналитических выводов</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = insights.map(i => `
|
||
<div class="insight-item ${i.type}">
|
||
<strong>${i.category}:</strong> ${i.text}
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderTable(data) {
|
||
const thead = document.getElementById('tableHead');
|
||
const tbody = document.getElementById('tableBody');
|
||
|
||
if (data.columns.length === 0) return;
|
||
|
||
thead.innerHTML = '<tr>' + data.columns.map(col => `<th>${col}</th>`).join('') + '</tr>';
|
||
tbody.innerHTML = data.rows.slice(0, 100).map(row =>
|
||
'<tr>' + data.columns.map(col => `<td>${row[col] !== undefined ? row[col] : ''}</td>`).join('') + '</tr>'
|
||
).join('');
|
||
|
||
document.getElementById('shownCount').textContent = Math.min(data.rows.length, 100);
|
||
document.getElementById('totalCount').textContent = data.rows.length;
|
||
}
|
||
|
||
function populateFilters() {
|
||
const filterSelect = document.getElementById('filterColumn');
|
||
const sortSelect = document.getElementById('sortColumn');
|
||
const fullFilterSelect = document.getElementById('fullFilterColumn');
|
||
|
||
const options = currentData.columns.map(col => `<option value="${col}">${col}</option>`).join('');
|
||
filterSelect.innerHTML = '<option value="">Все колонки</option>' + options;
|
||
sortSelect.innerHTML = '<option value="">Сортировка</option>' + options;
|
||
fullFilterSelect.innerHTML = '<option value="">Все колонки</option>' + options;
|
||
}
|
||
|
||
function applyFilters() {
|
||
const filterColumn = document.getElementById('filterColumn').value;
|
||
const filterValue = document.getElementById('filterValue').value;
|
||
const sortColumn = document.getElementById('sortColumn').value;
|
||
const sortOrder = document.getElementById('sortOrder').value;
|
||
|
||
let filtered = [...allData.rows];
|
||
|
||
if (filterColumn && filterValue) {
|
||
filtered = filtered.filter(row =>
|
||
String(row[filterColumn] || '').toLowerCase().includes(filterValue.toLowerCase())
|
||
);
|
||
}
|
||
|
||
if (sortColumn) {
|
||
filtered.sort((a, b) => {
|
||
const aVal = parseFloat(a[sortColumn]) || a[sortColumn];
|
||
const bVal = parseFloat(b[sortColumn]) || b[sortColumn];
|
||
if (aVal < bVal) return sortOrder === 'asc' ? -1 : 1;
|
||
if (aVal > bVal) return sortOrder === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
currentData.rows = filtered;
|
||
renderTable(currentData);
|
||
renderStats(calculateStats(filtered, currentData.columns));
|
||
}
|
||
|
||
function applyFullFilters() {
|
||
const filterColumn = document.getElementById('fullFilterColumn').value;
|
||
const filterValue = document.getElementById('fullFilterValue').value;
|
||
|
||
let filtered = [...allData.rows];
|
||
if (filterColumn && filterValue) {
|
||
filtered = filtered.filter(row =>
|
||
String(row[filterColumn] || '').toLowerCase().includes(filterValue.toLowerCase())
|
||
);
|
||
}
|
||
|
||
const tbody = document.getElementById('fullTableBody');
|
||
const thead = document.getElementById('fullTableHead');
|
||
|
||
if (allData.columns.length === 0) return;
|
||
|
||
thead.innerHTML = '<tr>' + allData.columns.map(col => `<th>${col}</th>`).join('') + '</tr>';
|
||
tbody.innerHTML = filtered.slice(0, 1000).map(row =>
|
||
'<tr>' + allData.columns.map(col => `<td>${row[col] !== undefined ? row[col] : ''}</td>`).join('') + '</tr>'
|
||
).join('');
|
||
}
|
||
|
||
function renderCharts() {
|
||
const container = document.getElementById('chartsContainer');
|
||
const stats = allData.stats;
|
||
const numericCols = Object.keys(stats);
|
||
|
||
if (numericCols.length === 0) {
|
||
container.innerHTML = '<div class="insight-item info">Нет числовых данных для построения графиков</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = numericCols.slice(0, 5).map(col => {
|
||
const s = stats[col];
|
||
const maxValue = s.max;
|
||
const bars = [
|
||
{ label: 'Мин', value: s.min, color: 'var(--kt-ai-primary)' },
|
||
{ label: 'Среднее', value: s.avg, color: 'var(--kt-ai-primary)' },
|
||
{ label: 'Медиана', value: s.median, color: '#10b981' },
|
||
{ label: 'Макс', value: s.max, color: '#f59e0b' }
|
||
];
|
||
|
||
return `
|
||
<div class="chart-container">
|
||
<h3 style="margin-bottom: var(--kt-ai-space-4);">${col}</h3>
|
||
<div class="bar-chart">
|
||
${bars.map(b => `
|
||
<div class="bar" style="height: ${(b.value / maxValue * 100)}%; background: ${b.color};">
|
||
<div class="bar-value">${formatNumber(b.value)}</div>
|
||
<div class="bar-label">${b.label}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const stringCols = allData.columns.filter(col => !stats[col]);
|
||
if (stringCols.length > 0) {
|
||
const col = stringCols[0];
|
||
const counts = {};
|
||
allData.rows.forEach(row => {
|
||
const val = row[col] || 'Пусто';
|
||
counts[val] = (counts[val] || 0) + 1;
|
||
});
|
||
const topEntries = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||
const maxCount = Math.max(...topEntries.map(([, c]) => c));
|
||
|
||
container.innerHTML += `
|
||
<div class="chart-container">
|
||
<h3 style="margin-bottom: var(--kt-ai-space-4);">Топ значений: ${col}</h3>
|
||
<div class="bar-chart">
|
||
${topEntries.map(([label, count]) => `
|
||
<div class="bar" style="height: ${(count / maxCount * 100)}%;">
|
||
<div class="bar-value">${count}</div>
|
||
<div class="bar-label" style="transform: translateX(-50%) rotate(-45deg);">${label.substring(0, 15)}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
function switchTab(tabName) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
|
||
|
||
document.getElementById('overviewTab').classList.add('hidden');
|
||
document.getElementById('tableTab').classList.add('hidden');
|
||
document.getElementById('chartsTab').classList.add('hidden');
|
||
|
||
document.getElementById(tabName + 'Tab').classList.remove('hidden');
|
||
|
||
if (tabName === 'table') applyFullFilters();
|
||
if (tabName === 'charts') renderCharts();
|
||
}
|
||
|
||
function clearData() {
|
||
if (confirm('Удалить все загруженные данные?')) {
|
||
fetch('/api/analysis', { method: 'DELETE' }).catch(() => {});
|
||
location.reload();
|
||
}
|
||
}
|
||
|
||
function exportToCSV() {
|
||
const filterColumn = document.getElementById('fullFilterColumn').value;
|
||
const filterValue = document.getElementById('fullFilterValue').value;
|
||
|
||
let filtered = [...allData.rows];
|
||
if (filterColumn && filterValue) {
|
||
filtered = filtered.filter(row =>
|
||
String(row[filterColumn] || '').toLowerCase().includes(filterValue.toLowerCase())
|
||
);
|
||
}
|
||
|
||
const csv = '\uFEFF' + allData.columns.join(';') + '\n' +
|
||
filtered.map(row => allData.columns.map(col =>
|
||
'"' + String(row[col] || '').replace(/"/g, '""') + '"'
|
||
).join(';')).join('\n');
|
||
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = 'export.csv';
|
||
link.click();
|
||
}
|
||
|
||
function calculateStats(rows, columns) {
|
||
const stats = {};
|
||
const numericCols = columns.filter(col => {
|
||
const sample = rows.slice(0, 10).map(r => r[col]);
|
||
return sample.some(v => !isNaN(parseFloat(v)) && isFinite(v));
|
||
});
|
||
|
||
numericCols.forEach(col => {
|
||
const values = rows.map(r => parseFloat(r[col])).filter(v => !isNaN(v));
|
||
if (values.length > 0) {
|
||
const sum = values.reduce((a, b) => a + b, 0);
|
||
const avg = sum / values.length;
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const median = sorted.length % 2 === 0
|
||
? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
|
||
: sorted[Math.floor(sorted.length / 2)];
|
||
stats[col] = { sum, avg, min, max, median, count: values.length };
|
||
}
|
||
});
|
||
return stats;
|
||
}
|
||
|
||
function formatNumber(num) {
|
||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||
return num.toFixed(1);
|
||
}
|
||
|
||
fetch('/api/analysis')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
if (data && data.columns && data.columns.length > 0) {
|
||
loadData(data);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|