590 lines
23 KiB
HTML
590 lines
23 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Топ услуг B2C по регионам</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg-primary: #0f1218;
|
||
--bg-secondary: #1a1d24;
|
||
--bg-card: #232730;
|
||
--text-primary: #f4f4f5;
|
||
--text-secondary: #a1a1aa;
|
||
--accent: #00b8cc;
|
||
--accent-hover: #00d4ec;
|
||
--success: #10b981;
|
||
--border: #33333a;
|
||
--shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
background: var(--bg-primary);
|
||
color: var(--text-primary);
|
||
min-height: 100vh;
|
||
padding: 24px;
|
||
}
|
||
.container { max-width: 1400px; margin: 0 auto; }
|
||
|
||
/* Header */
|
||
header {
|
||
margin-bottom: 32px;
|
||
}
|
||
h1 {
|
||
font-size: clamp(24px, 4vw, 36px);
|
||
font-weight: 700;
|
||
margin-bottom: 8px;
|
||
background: linear-gradient(135deg, var(--accent), #00ff88);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
.subtitle {
|
||
color: var(--text-secondary);
|
||
font-size: 14px;
|
||
}
|
||
|
||
/* Summary cards */
|
||
.summary {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
.card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.card-label {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.card-value {
|
||
font-size: clamp(24px, 3vw, 32px);
|
||
font-weight: 700;
|
||
color: var(--accent);
|
||
}
|
||
.card-sub {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Charts section */
|
||
.charts {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||
gap: 24px;
|
||
margin-bottom: 32px;
|
||
}
|
||
.chart-container {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.chart-title {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin-bottom: 16px;
|
||
}
|
||
.bar-chart {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
}
|
||
.bar-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.bar-label {
|
||
min-width: 120px;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
text-align: right;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.bar-track {
|
||
flex: 1;
|
||
height: 24px;
|
||
background: var(--bg-secondary);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
.bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--accent), #00ff88);
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
.bar-value {
|
||
min-width: 80px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-align: right;
|
||
}
|
||
|
||
/* Filters */
|
||
.filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
align-items: center;
|
||
}
|
||
.search-box {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
}
|
||
.search-box input {
|
||
width: 100%;
|
||
padding: 10px 16px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
color: var(--text-primary);
|
||
font-size: 14px;
|
||
}
|
||
.search-box input::placeholder { color: var(--text-secondary); }
|
||
.search-box input:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
}
|
||
.filter-chip {
|
||
padding: 8px 16px;
|
||
background: var(--bg-secondary);
|
||
border: 1px solid var(--border);
|
||
border-radius: 20px;
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
.filter-chip:hover { border-color: var(--accent); }
|
||
.filter-chip.active {
|
||
background: var(--accent);
|
||
color: #000;
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
/* Table */
|
||
.table-container {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 14px;
|
||
}
|
||
thead {
|
||
background: var(--bg-secondary);
|
||
}
|
||
th {
|
||
padding: 14px 16px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
th:hover { color: var(--accent); }
|
||
th.sorted-asc::after { content: ' ▲'; }
|
||
th.sorted-desc::after { content: ' ▼'; }
|
||
td {
|
||
padding: 12px 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
tbody tr {
|
||
transition: background 0.2s;
|
||
}
|
||
tbody tr:hover {
|
||
background: var(--bg-secondary);
|
||
}
|
||
.rank {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
background: var(--bg-secondary);
|
||
font-weight: 600;
|
||
font-size: 12px;
|
||
}
|
||
.rank.top-3 {
|
||
background: linear-gradient(135deg, var(--accent), #00ff88);
|
||
color: #000;
|
||
}
|
||
.service-name {
|
||
max-width: 400px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.region-tag {
|
||
display: inline-block;
|
||
padding: 4px 10px;
|
||
background: var(--bg-secondary);
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
}
|
||
.amount {
|
||
font-family: 'Courier New', monospace;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Scrollbar */
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: var(--bg-secondary); }
|
||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
|
||
|
||
/* Responsive */
|
||
@media (max-width: 768px) {
|
||
body { padding: 16px; }
|
||
.charts { grid-template-columns: 1fr; }
|
||
.table-container { overflow-x: auto; }
|
||
table { min-width: 800px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<header>
|
||
<h1>Топ услуг B2C по регионам</h1>
|
||
<p class="subtitle">Данные за июль 2026 года • Источник: ClickHouse АО «Казахтелеком»</p>
|
||
</header>
|
||
|
||
<div class="summary" id="summary"></div>
|
||
|
||
<div class="charts">
|
||
<div class="chart-container">
|
||
<div class="chart-title">Топ-10 регионов по количеству покупок</div>
|
||
<div class="bar-chart" id="chart-regions"></div>
|
||
</div>
|
||
<div class="chart-container">
|
||
<div class="chart-title">Топ-10 услуг по количеству покупок</div>
|
||
<div class="bar-chart" id="chart-services"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<div class="search-box">
|
||
<input type="text" id="search" placeholder="Поиск по услуге...">
|
||
</div>
|
||
<div id="region-filters"></div>
|
||
</div>
|
||
|
||
<div class="table-container">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th data-sort="rank">#</th>
|
||
<th data-sort="region">Регион</th>
|
||
<th data-sort="service">Услуга</th>
|
||
<th data-sort="qty">Кол-во покупок</th>
|
||
<th data-sort="amount">Сумма (₸)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="table-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// === ДАННЫЕ ИЗ БРИФА (50 строк) ===
|
||
const RAW_DATA = [
|
||
{rank:1, region:"Астана", service:"B2C Абонплата ШПД по FTTx ГТС (New)", qty:37372, amount:238463786.09},
|
||
{rank:2, region:"г.Алматы", service:"B2C Абонплата ШПД по FTTx ГТС (New)", qty:39671, amount:237005698.16},
|
||
{rank:3, region:"г.Алматы", service:"Абонплата ШПД по FTTx для B2C", qty:35588, amount:152893561.28},
|
||
{rank:4, region:"Астана", service:"2025 B2C Абонплата за Домашний интернет FTTx ГТС", qty:35637, amount:136187938.99},
|
||
{rank:5, region:"Астана", service:"B2C TV Plus Абонплата Full ГТС", qty:47661, amount:132060372.93},
|
||
{rank:6, region:"Астана", service:"2021 B2C Абонплата за мобильный номер Kcell GSM закр.", qty:61700, amount:123403164.43},
|
||
{rank:7, region:"г.Алматы", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:57378, amount:110497209.55},
|
||
{rank:8, region:"г.Алматы", service:"2025 B2C Абонплата за Домашний интернет FTTx ГТС", qty:29997, amount:106749675.59},
|
||
{rank:9, region:"Астана", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:52504, amount:99235780.41},
|
||
{rank:10, region:"Астана", service:"2022 B2C Абонплата за Домашний интернет FTTx ГТС", qty:36946, amount:96430127.27},
|
||
{rank:11, region:"Астана", service:"Абонплата ШПД по FTTx для B2C", qty:19565, amount:88834659.73},
|
||
{rank:12, region:"Астана", service:"2024 B2C Абонплата за Домашний интернет FTTx ГТС", qty:22167, amount:83549325.34},
|
||
{rank:13, region:"г.Алматы", service:"B2C TV Plus Абонплата Full ГТС", qty:31221, amount:83511013.05},
|
||
{rank:14, region:"г.Алматы", service:"2021 B2C Абонплата за мобильный номер Kcell GSM закр.", qty:40838, amount:82748215.46},
|
||
{rank:15, region:"г.Семей", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:41486, amount:76400272.02},
|
||
{rank:16, region:"Астана", service:"TV Plus Абонплата за Премиальный пакет ГТС", qty:29210, amount:71278165.09},
|
||
{rank:17, region:"г.Алматы", service:"2022 B2C Абонплата за Домашний интернет FTTx ГТС", qty:24001, amount:62521274.30},
|
||
{rank:18, region:"Астана", service:"2021 B2C Абонплата за мобильный номер Kcell GSM откр.", qty:27864, amount:59635624.39},
|
||
{rank:19, region:"г.Алматы", service:"2024 B2C Абонплата за Домашний интернет FTTx ГТС", qty:15678, amount:58970815.48},
|
||
{rank:20, region:"г.Актобе", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:30641, amount:55819936.98},
|
||
{rank:21, region:"г.Алматы", service:"B2C Абонплата за основной телефон ГТС", qty:52732, amount:49636818.50},
|
||
{rank:22, region:"г.Актобе", service:"2021 B2C Абонплата за мобильный номер Kcell GSM закр.", qty:25178, amount:48000483.43},
|
||
{rank:23, region:"г.Алматы", service:"TV Plus Абонплата за Премиальный пакет ГТС", qty:19055, amount:45298192.89},
|
||
{rank:24, region:"г.Павлодар", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:23334, amount:44857518.84},
|
||
{rank:25, region:"г.Алматы", service:"B2C TV Plus Аренда STB оператора новое", qty:96183, amount:43252519.71},
|
||
{rank:26, region:"г.Караганда", service:"Абонплата ШПД по FTTx для B2C", qty:8916, amount:41624727.66},
|
||
{rank:27, region:"Астана", service:"2026 B2C Абонплата за Домашний интернет FTTx ГТС", qty:13685, amount:41618770.34},
|
||
{rank:28, region:"г.Алматы", service:"2018 B2C Абонплата за Домашний интернет FTTx ГТС", qty:11568, amount:40199964.35},
|
||
{rank:29, region:"г.Тараз", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:21880, amount:39564813.34},
|
||
{rank:30, region:"г.Алматы", service:"Абонплата ШПД по ADSL для B2C", qty:10710, amount:36238454.72},
|
||
{rank:31, region:"г.Алматы", service:"2020 B2C Абонплата за Домашний интернет FTTx ГТС", qty:12148, amount:36010481.68},
|
||
{rank:32, region:"г.Усть-Каменогорск", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:18362, amount:35883527.39},
|
||
{rank:33, region:"г. Шымкент", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:19180, amount:35338634.51},
|
||
{rank:34, region:"г. Шымкент", service:"2021 B2C Абонплата за мобильный номер Kcell GSM закр.", qty:18945, amount:35138664.91},
|
||
{rank:35, region:"Астана", service:"B2C TV Plus Аренда STB оператора новое", qty:76655, amount:34746783.92},
|
||
{rank:36, region:"Астана", service:"2018 B2C Абонплата за Домашний интернет FTTx ГТС", qty:8705, amount:32905725.03},
|
||
{rank:37, region:"г.Актобе", service:"B2C Абонплата ШПД по FTTx ГТС (New)", qty:6742, amount:32313021.91},
|
||
{rank:38, region:"г.Усть-Каменогорск", service:"Абонплата ШПД по FTTx для B2C", qty:7269, amount:31834355.15},
|
||
{rank:39, region:"г.Атырау", service:"B2C Абонплата ШПД по FTTx ГТС (New)", qty:5495, amount:31688118.61},
|
||
{rank:40, region:"г.Усть-Каменогорск", service:"B2C Абонплата ШПД по FTTx ГТС (New)", qty:5600, amount:31520413.53},
|
||
{rank:41, region:"Астана", service:"B2C Аренда оборудования ONT оператора - новое", qty:87071, amount:31220264.09},
|
||
{rank:42, region:"г.Павлодар", service:"B2C Абонплата ШПД по FTTx ГТС (New)", qty:6104, amount:30831455.08},
|
||
{rank:43, region:"г.Кокшетау", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:16818, amount:30654924.73},
|
||
{rank:44, region:"г.Актобе", service:"2025 B2C Абонплата за Домашний интернет FTTx ГТС", qty:8613, amount:30562705.78},
|
||
{rank:45, region:"г.Костанай", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:16473, amount:30539180.35},
|
||
{rank:46, region:"Енбекшиказахский район", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:17347, amount:30429552.38},
|
||
{rank:47, region:"г.Актобе", service:"2022 B2C Абонплата за Домашний интернет FTTx ГТС", qty:11709, amount:30303557.00},
|
||
{rank:48, region:"г.Кызылорда", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:16539, amount:29772241.67},
|
||
{rank:49, region:"г.Алматы", service:"2026 B2C Абонплата за Домашний интернет FTTx ГТС", qty:10206, amount:29200316.21},
|
||
{rank:50, region:"г.Караганда", service:"2022 B2C Абонплата за мобильный номер MT-C GSM закр.", qty:15714, amount:28837871.98}
|
||
];
|
||
|
||
// === СОСТОЯНИЕ (localStorage) ===
|
||
const STATE = {
|
||
sortField: 'rank',
|
||
sortDir: 'asc',
|
||
filterRegion: 'all',
|
||
search: ''
|
||
};
|
||
|
||
function loadState() {
|
||
try {
|
||
const saved = localStorage.getItem('dashboardState');
|
||
if (saved) Object.assign(STATE, JSON.parse(saved));
|
||
} catch(e) {}
|
||
}
|
||
|
||
function saveState() {
|
||
localStorage.setItem('dashboardState', JSON.stringify(STATE));
|
||
}
|
||
|
||
// === УТИЛИТЫ ===
|
||
function formatNumber(n) {
|
||
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||
}
|
||
|
||
function formatAmount(n) {
|
||
return n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + ' ₸';
|
||
}
|
||
|
||
function getUniqueRegions() {
|
||
return [...new Set(RAW_DATA.map(d => d.region))].sort();
|
||
}
|
||
|
||
// === АГРЕГАЦИЯ ДАННЫХ ===
|
||
function getSummary() {
|
||
const totalQty = RAW_DATA.reduce((s, d) => s + d.qty, 0);
|
||
const totalAmount = RAW_DATA.reduce((s, d) => s + d.amount, 0);
|
||
const regions = new Set(RAW_DATA.map(d => d.region));
|
||
|
||
const byService = {};
|
||
RAW_DATA.forEach(d => {
|
||
byService[d.service] = (byService[d.service] || 0) + d.qty;
|
||
});
|
||
const topService = Object.entries(byService).sort((a,b) => b[1] - a[1])[0];
|
||
|
||
const byRegion = {};
|
||
RAW_DATA.forEach(d => {
|
||
byRegion[d.region] = (byRegion[d.region] || 0) + d.qty;
|
||
});
|
||
const topRegion = Object.entries(byRegion).sort((a,b) => b[1] - a[1])[0];
|
||
|
||
return { totalQty, totalAmount, regionsCount: regions.size, topService, topRegion };
|
||
}
|
||
|
||
function getRegionStats() {
|
||
const stats = {};
|
||
RAW_DATA.forEach(d => {
|
||
stats[d.region] = (stats[d.region] || 0) + d.qty;
|
||
});
|
||
return Object.entries(stats).sort((a,b) => b[1] - a[1]).slice(0, 10);
|
||
}
|
||
|
||
function getServiceStats() {
|
||
const stats = {};
|
||
RAW_DATA.forEach(d => {
|
||
stats[d.service] = (stats[d.service] || 0) + d.qty;
|
||
});
|
||
return Object.entries(stats).sort((a,b) => b[1] - a[1]).slice(0, 10);
|
||
}
|
||
|
||
// === РЕНДЕРИНГ ===
|
||
function renderSummary() {
|
||
const s = getSummary();
|
||
document.getElementById('summary').innerHTML = `
|
||
<div class="card">
|
||
<div class="card-label">Всего покупок</div>
|
||
<div class="card-value">${formatNumber(s.totalQty)}</div>
|
||
<div class="card-sub">за июль 2026</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Регионов</div>
|
||
<div class="card-value">${s.regionsCount}</div>
|
||
<div class="card-sub">в топ-50</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Услуга-лидер</div>
|
||
<div class="card-value" style="font-size:18px">${s.topService[0].split(' ').slice(0,4).join(' ')}...</div>
|
||
<div class="card-sub">${formatNumber(s.topService[1])} покупок</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Регион-лидер</div>
|
||
<div class="card-value">${s.topRegion[0]}</div>
|
||
<div class="card-sub">${formatNumber(s.topRegion[1])} покупок</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderBarChart(containerId, data, valueLabel) {
|
||
const max = Math.max(...data.map(d => d[1]));
|
||
const container = document.getElementById(containerId);
|
||
container.innerHTML = data.map(([label, value]) => `
|
||
<div class="bar-row">
|
||
<div class="bar-label" title="${label}">${label}</div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill" style="width:${(value/max)*100}%"></div>
|
||
</div>
|
||
<div class="bar-value">${formatNumber(value)}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderRegionFilters() {
|
||
const regions = getUniqueRegions();
|
||
const container = document.getElementById('region-filters');
|
||
container.innerHTML = `
|
||
<button class="filter-chip${STATE.filterRegion === 'all' ? ' active' : ''}" data-region="all">Все</button>
|
||
${regions.map(r => `
|
||
<button class="filter-chip${STATE.filterRegion === r ? ' active' : ''}" data-region="${r}">${r}</button>
|
||
`).join('')}
|
||
`;
|
||
container.querySelectorAll('.filter-chip').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
STATE.filterRegion = btn.dataset.region;
|
||
saveState();
|
||
render();
|
||
});
|
||
});
|
||
}
|
||
|
||
function getFilteredData() {
|
||
let data = [...RAW_DATA];
|
||
|
||
if (STATE.filterRegion !== 'all') {
|
||
data = data.filter(d => d.region === STATE.filterRegion);
|
||
}
|
||
|
||
if (STATE.search) {
|
||
const q = STATE.search.toLowerCase();
|
||
data = data.filter(d => d.service.toLowerCase().includes(q));
|
||
}
|
||
|
||
data.sort((a, b) => {
|
||
let aVal = a[STATE.sortField];
|
||
let bVal = b[STATE.sortField];
|
||
if (typeof aVal === 'string') aVal = aVal.toLowerCase();
|
||
if (typeof bVal === 'string') bVal = bVal.toLowerCase();
|
||
if (aVal < bVal) return STATE.sortDir === 'asc' ? -1 : 1;
|
||
if (aVal > bVal) return STATE.sortDir === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
|
||
return data;
|
||
}
|
||
|
||
function renderTable() {
|
||
const data = getFilteredData();
|
||
const tbody = document.getElementById('table-body');
|
||
|
||
if (data.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:var(--text-secondary)">Нет данных</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = data.map((d, i) => `
|
||
<tr>
|
||
<td><span class="rank${d.rank <= 3 ? ' top-3' : ''}">${d.rank}</span></td>
|
||
<td><span class="region-tag">${d.region}</span></td>
|
||
<td><div class="service-name" title="${d.service}">${d.service}</div></td>
|
||
<td>${formatNumber(d.qty)}</td>
|
||
<td class="amount">${formatAmount(d.amount)}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||
th.classList.remove('sorted-asc', 'sorted-desc');
|
||
if (th.dataset.sort === STATE.sortField) {
|
||
th.classList.add(STATE.sortDir === 'asc' ? 'sorted-asc' : 'sorted-desc');
|
||
}
|
||
});
|
||
}
|
||
|
||
function render() {
|
||
renderSummary();
|
||
renderBarChart('chart-regions', getRegionStats());
|
||
renderBarChart('chart-services', getServiceStats());
|
||
renderRegionFilters();
|
||
renderTable();
|
||
}
|
||
|
||
// === СОБЫТИЯ ===
|
||
function initEvents() {
|
||
document.querySelectorAll('th[data-sort]').forEach(th => {
|
||
th.addEventListener('click', () => {
|
||
const field = th.dataset.sort;
|
||
if (STATE.sortField === field) {
|
||
STATE.sortDir = STATE.sortDir === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
STATE.sortField = field;
|
||
STATE.sortDir = 'asc';
|
||
}
|
||
saveState();
|
||
render();
|
||
});
|
||
});
|
||
|
||
document.getElementById('search').addEventListener('input', (e) => {
|
||
STATE.search = e.target.value;
|
||
saveState();
|
||
render();
|
||
});
|
||
}
|
||
|
||
// === ЗАПУСК ===
|
||
loadState();
|
||
render();
|
||
initEvents();
|
||
</script>
|
||
</body>
|
||
</html>
|