459 lines
22 KiB
HTML
459 lines
22 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>
|
||
<style>
|
||
:root {
|
||
--bg: #0f1218;
|
||
--card-bg: #1a1d26;
|
||
--text: #f4f4f5;
|
||
--text-muted: #a1a1aa;
|
||
--accent: #00b8cc;
|
||
--accent-hover: #00d9e8;
|
||
--border: #2d313a;
|
||
--success: #10b981;
|
||
--warning: #f59e0b;
|
||
}
|
||
* { margin: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
line-height: 1.5;
|
||
}
|
||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||
header {
|
||
margin-bottom: 32px;
|
||
padding-bottom: 24px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
h1 { font-size: 28px; font-weight: 600; margin-bottom: 8px; color: var(--accent); }
|
||
.subtitle { color: var(--text-muted); font-size: 14px; }
|
||
|
||
.metrics {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 32px;
|
||
}
|
||
.metric-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
}
|
||
.metric-label { color: var(--text-muted); font-size: 13px; margin-bottom: 8px; }
|
||
.metric-value { font-size: 28px; font-weight: 700; color: var(--accent); }
|
||
.metric-sub { font-size: 12px; color: var(--text-muted); margin-top: 8px; }
|
||
|
||
.charts {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: 24px;
|
||
margin-bottom: 32px;
|
||
}
|
||
@media (max-width: 900px) { .charts { grid-template-columns: 1fr; } }
|
||
.chart-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
}
|
||
.chart-title { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
|
||
.chart-container { position: relative; height: 280px; }
|
||
.bar-chart { display: flex; flex-direction: column; gap: 8px; height: 100%; overflow-y: auto; padding-right: 8px; }
|
||
.bar-row { display: flex; align-items: center; gap: 12px; }
|
||
.bar-label {
|
||
width: 180px;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.bar-track {
|
||
flex: 1;
|
||
height: 24px;
|
||
background: #2d313a;
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
.bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
|
||
border-radius: 4px;
|
||
transition: width 0.3s;
|
||
}
|
||
.bar-value {
|
||
width: 100px;
|
||
font-size: 12px;
|
||
text-align: right;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.filters {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
}
|
||
.filter-group { display: flex; align-items: center; gap: 8px; }
|
||
.filter-label { font-size: 13px; color: var(--text-muted); }
|
||
select, button {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
color: var(--text);
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
}
|
||
select:hover, button:hover { border-color: var(--accent); }
|
||
button.active { background: var(--accent); border-color: var(--accent); }
|
||
|
||
.table-card {
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.table-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.table-title { font-size: 16px; font-weight: 600; }
|
||
.table-wrapper { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td {
|
||
padding: 12px 20px;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
th {
|
||
background: #232730;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
th:hover { background: #2d313a; }
|
||
td { font-size: 13px; }
|
||
tr:hover { background: #232730; }
|
||
.rank { font-weight: 700; color: var(--accent); width: 40px; }
|
||
.region-tag {
|
||
display: inline-block;
|
||
padding: 4px 8px;
|
||
background: #2d313a;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
}
|
||
.amount { font-family: monospace; font-size: 13px; }
|
||
|
||
.legend {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-top: 12px;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
.legend-item { display: flex; align-items: center; gap: 6px; }
|
||
.legend-color { width: 12px; height: 12px; border-radius: 2px; }
|
||
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
right: 24px;
|
||
background: var(--card-bg);
|
||
border: 1px solid var(--accent);
|
||
color: var(--text);
|
||
padding: 12px 20px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
transition: opacity 0.3s, transform 0.3s;
|
||
z-index: 1000;
|
||
}
|
||
.toast.show { opacity: 1; transform: translateY(0); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<header>
|
||
<h1>Топ услуг B2C по регионам</h1>
|
||
<p class="subtitle">Данные за июль 2026 года • Источник: ClickHouse АО «Казахтелеком»</p>
|
||
</header>
|
||
|
||
<div class="metrics" id="metrics"></div>
|
||
|
||
<div class="charts">
|
||
<div class="chart-card">
|
||
<div class="chart-title">Топ-15 регионов по сумме (млн ₸)</div>
|
||
<div class="chart-container" id="regionsChart"></div>
|
||
</div>
|
||
<div class="chart-card">
|
||
<div class="chart-title">Топ-15 услуг по количеству покупок</div>
|
||
<div class="chart-container" id="servicesChart"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-card">
|
||
<div class="table-header">
|
||
<div class="table-title">Детальные данные</div>
|
||
<div class="filters">
|
||
<div class="filter-group">
|
||
<span class="filter-label">Регион:</span>
|
||
<select id="regionFilter">
|
||
<option value="">Все регионы</option>
|
||
</select>
|
||
</div>
|
||
<button id="resetFilters">Сбросить</button>
|
||
<button id="exportBtn">Экспорт CSV</button>
|
||
</div>
|
||
</div>
|
||
<div class="table-wrapper">
|
||
<table id="dataTable">
|
||
<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="tableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const 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 }
|
||
];
|
||
|
||
function formatNum(n) { return n.toLocaleString("ru-RU"); }
|
||
function formatMoney(n) { return n.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " ₸"; }
|
||
function formatMoneyShort(n) { return (n / 1e6).toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 2 }) + " млн"; }
|
||
|
||
function calcMetrics() {
|
||
const totalQty = DATA.reduce((s, r) => s + r.qty, 0);
|
||
const regions = [...new Set(DATA.map(r => r.region))];
|
||
const byService = {};
|
||
DATA.forEach(r => { byService[r.service] = (byService[r.service] || 0) + r.qty; });
|
||
const topService = Object.entries(byService).sort((a, b) => b[1] - a[1])[0][0];
|
||
const byRegion = {};
|
||
DATA.forEach(r => { byRegion[r.region] = (byRegion[r.region] || 0) + r.amount; });
|
||
const topRegion = Object.entries(byRegion).sort((a, b) => b[1] - a[1])[0][0];
|
||
return { totalQty, totalRegions: regions.length, topService, topRegion };
|
||
}
|
||
|
||
function renderMetrics() {
|
||
const m = calcMetrics();
|
||
document.getElementById("metrics").innerHTML = `
|
||
<div class="metric-card">
|
||
<div class="metric-label">Всего покупок</div>
|
||
<div class="metric-value">${formatNum(m.totalQty)}</div>
|
||
<div class="metric-sub">за июль 2026</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Регионов в топе</div>
|
||
<div class="metric-value">${m.totalRegions}</div>
|
||
<div class="metric-sub">городов и областей</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Услуга-лидер</div>
|
||
<div class="metric-value" style="font-size:18px">${m.topService.split(" ").slice(0, 4).join(" ")}</div>
|
||
<div class="metric-sub">по количеству покупок</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Регион-лидер</div>
|
||
<div class="metric-value">${m.topRegion}</div>
|
||
<div class="metric-sub">по сумме продаж</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderRegionsChart() {
|
||
const byRegion = {};
|
||
DATA.forEach(r => { byRegion[r.region] = (byRegion[r.region] || 0) + r.amount; });
|
||
const sorted = Object.entries(byRegion).sort((a, b) => b[1] - a[1]).slice(0, 15);
|
||
const max = sorted[0][1];
|
||
const container = document.getElementById("regionsChart");
|
||
container.innerHTML = `<div class="bar-chart">` + sorted.map(([reg, amt]) => `
|
||
<div class="bar-row">
|
||
<div class="bar-label" title="${reg}">${reg}</div>
|
||
<div class="bar-track"><div class="bar-fill" style="width:${(amt/max)*100}%"></div></div>
|
||
<div class="bar-value">${formatMoneyShort(amt)}</div>
|
||
</div>
|
||
`).join("") + `</div>`;
|
||
}
|
||
|
||
function renderServicesChart() {
|
||
const byService = {};
|
||
DATA.forEach(r => { byService[r.service] = (byService[r.service] || 0) + r.qty; });
|
||
const sorted = Object.entries(byService).sort((a, b) => b[1] - a[1]).slice(0, 15);
|
||
const max = sorted[0][1];
|
||
const container = document.getElementById("servicesChart");
|
||
container.innerHTML = `<div class="bar-chart">` + sorted.map(([svc, qty]) => `
|
||
<div class="bar-row">
|
||
<div class="bar-label" title="${svc}">${svc}</div>
|
||
<div class="bar-track"><div class="bar-fill" style="width:${(qty/max)*100}%"></div></div>
|
||
<div class="bar-value">${formatNum(qty)}</div>
|
||
</div>
|
||
`).join("") + `</div>`;
|
||
}
|
||
|
||
let currentSort = { field: "rank", dir: "asc" };
|
||
let currentFilter = "";
|
||
|
||
function getFilteredData() {
|
||
let d = [...DATA];
|
||
if (currentFilter) d = d.filter(r => r.region === currentFilter);
|
||
d.sort((a, b) => {
|
||
const va = a[currentSort.field], vb = b[currentSort.field];
|
||
const res = va > vb ? 1 : va < vb ? -1 : 0;
|
||
return currentSort.dir === "asc" ? res : -res;
|
||
});
|
||
return d;
|
||
}
|
||
|
||
function renderTable() {
|
||
const tbody = document.getElementById("tableBody");
|
||
const d = getFilteredData();
|
||
tbody.innerHTML = d.map(r => `
|
||
<tr>
|
||
<td class="rank">${r.rank}</td>
|
||
<td><span class="region-tag">${r.region}</span></td>
|
||
<td>${r.service}</td>
|
||
<td>${formatNum(r.qty)}</td>
|
||
<td class="amount">${formatMoney(r.amount)}</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
function initFilters() {
|
||
const regions = [...new Set(DATA.map(r => r.region))].sort();
|
||
const sel = document.getElementById("regionFilter");
|
||
regions.forEach(r => { sel.innerHTML += `<option value="${r}">${r}</option>`; });
|
||
|
||
const saved = localStorage.getItem("dashboardFilter");
|
||
if (saved) { currentFilter = saved; sel.value = saved; }
|
||
|
||
sel.addEventListener("change", () => {
|
||
currentFilter = sel.value;
|
||
localStorage.setItem("dashboardFilter", currentFilter);
|
||
renderTable();
|
||
});
|
||
|
||
document.getElementById("resetFilters").addEventListener("click", () => {
|
||
currentFilter = "";
|
||
currentSort = { field: "rank", dir: "asc" };
|
||
sel.value = "";
|
||
localStorage.removeItem("dashboardFilter");
|
||
renderTable();
|
||
showToast("Фильтры сброшены");
|
||
});
|
||
|
||
document.querySelectorAll("th[data-sort]").forEach(th => {
|
||
th.addEventListener("click", () => {
|
||
const f = th.dataset.sort;
|
||
currentSort.dir = currentSort.field === f && currentSort.dir === "asc" ? "desc" : "asc";
|
||
currentSort.field = f;
|
||
renderTable();
|
||
});
|
||
});
|
||
|
||
document.getElementById("exportBtn").addEventListener("click", exportCSV);
|
||
}
|
||
|
||
function exportCSV() {
|
||
const head = ["№", "Регион", "Услуга", "Кол-во покупок", "Сумма (₸)"];
|
||
const rows = getFilteredData().map(r => [r.rank, r.region, r.service, r.qty, r.amount.toFixed(2).replace(".", ",")]);
|
||
const csv = "\uFEFF" + [head, ...rows].map(r => r.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(";")).join("\n");
|
||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = "top_uslug_b2c.csv";
|
||
a.click();
|
||
showToast("Файл скачан");
|
||
}
|
||
|
||
function showToast(msg) {
|
||
const t = document.getElementById("toast");
|
||
t.textContent = msg;
|
||
t.classList.add("show");
|
||
setTimeout(() => t.classList.remove("show"), 2500);
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", () => {
|
||
renderMetrics();
|
||
renderRegionsChart();
|
||
renderServicesChart();
|
||
initFilters();
|
||
renderTable();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|