kpi-dashboard/app_stats/index.html
Iliyas ccf82b026f feat: add Метрики МП page + app-metrics updater
- new page app_stats/index.html (login-gated, same style/nav)
- app_stats/app_metrics.json data (year-over-year comparison, NEW badges)
- updater/update_app_metrics.py: adaptive SQL (Jan 1 -> yesterday vs prev year)
- run both updaters from run_update.bat; refactor shared git push
2026-06-16 17:34:19 +05:00

561 lines
21 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.0">
<title>Метрики МП — Казахтелеком 2026</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--color-bg: #F4F6F9;
--color-surface: #FFFFFF;
--color-border: #E5E7EB;
--color-text-primary: #111827;
--color-text-secondary: #6B7280;
--color-brand: #0052CC;
--color-brand-light: #EFF6FF;
--color-green: #10B981;
--color-green-bg: #ECFDF5;
--color-yellow: #F59E0B;
--color-yellow-bg: #FFFBEB;
--color-red: #EF4444;
--color-red-bg: #FEF2F2;
--color-purple: #8B5CF6;
--color-purple-bg: #F5F3FF;
--radius-card: 12px;
--radius-btn: 8px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-card-hover: 0 4px 12px rgba(0,0,0,0.1);
--font-base: 'Inter', system-ui, sans-serif;
--font-mono: 'Roboto Mono', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: var(--font-base); background: var(--color-bg); color: var(--color-text-primary); font-size: 14px; line-height: 1.5; }
@keyframes spin { to { transform: rotate(360deg); } }
/* LOGIN */
#login-screen { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #0052CC 0%, #0070F3 100%); }
.login-box { background: #fff; border-radius: 20px; padding: 48px 56px; text-align: center; max-width: 420px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,0.15); }
.login-logo { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; background: var(--color-brand); color: #fff; font-size: 24px; font-weight: 700; border-radius: 16px; margin-bottom: 20px; }
.login-box h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
.login-box > p { color: var(--color-text-secondary); margin-bottom: 28px; font-size: 14px; }
.txt-input { width: 100%; padding: 11px 14px; border: 1px solid var(--color-border); border-radius: var(--radius-btn); font-size: 15px; font-family: var(--font-base); outline: none; transition: border-color 0.15s; }
.txt-input:focus { border-color: var(--color-brand); }
.btn-primary { padding: 11px 40px; background: var(--color-brand); color: #fff; border: none; border-radius: var(--radius-btn); font-size: 14px; font-weight: 600; cursor: pointer; font-family: var(--font-base); transition: background 0.15s; }
.btn-primary:hover { background: #003fa3; }
#login-error { color: var(--color-red); font-size: 13px; margin-top: 10px; min-height: 18px; }
/* HEADER */
#app { display: none; }
.header { background: var(--color-brand); color: #fff; padding: 0 24px; height: 64px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 8px rgba(0,82,204,0.3); }
.header-left { display: flex; align-items: center; gap: 14px; }
.header-logo { width: 40px; height: 40px; background: rgba(255,255,255,0.2); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; flex-shrink: 0; }
.header-left h1 { font-size: 16px; font-weight: 700; line-height: 1.2; }
.header-left p { font-size: 12px; opacity: 0.75; }
.header-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
#generated-at { font-size: 12px; opacity: 0.85; }
.btn { padding: 7px 14px; border-radius: var(--radius-btn); font-size: 13px; font-weight: 500; cursor: pointer; border: none; font-family: var(--font-base); transition: all 0.15s; text-decoration: none; display: inline-block; }
.btn-ghost { background: rgba(255,255,255,0.15); color: #fff; }
.btn-ghost:hover { background: rgba(255,255,255,0.25); }
/* SUMMARY */
.wrap { padding: 20px 24px 40px; max-width: 1400px; margin: 0 auto; }
.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 22px; }
.sum-card { background: var(--color-surface); border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--shadow-card); border-top: 4px solid var(--color-brand); }
.sum-card.accent-green { border-top-color: var(--color-green); }
.sum-card.accent-purple { border-top-color: var(--color-purple); }
.sum-card.accent-yellow { border-top-color: var(--color-yellow); }
.sum-label { font-size: 11px; font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 8px; }
.sum-value { font-size: 26px; font-weight: 700; font-family: var(--font-mono); line-height: 1.1; }
.sum-sub { font-size: 12px; color: var(--color-text-secondary); margin-top: 4px; }
/* CONTROLS */
.controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
.controls .grow { flex: 1 1 220px; }
.controls label { font-size: 13px; color: var(--color-text-secondary); display: flex; align-items: center; gap: 6px; cursor: pointer; }
select.txt-input, input.txt-input { font-size: 13px; padding: 9px 12px; }
select.txt-input { cursor: pointer; background: #fff; }
/* METRIC CARDS */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.card { background: var(--color-surface); border-radius: var(--radius-card); padding: 16px 18px; box-shadow: var(--shadow-card); border-left: 4px solid var(--color-border); transition: box-shadow 0.2s, transform 0.2s; }
.card:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-2px); }
.card.up { border-left-color: var(--color-green); }
.card.down { border-left-color: var(--color-red); }
.card.flat { border-left-color: var(--color-yellow); }
.card.new { border-left-color: var(--color-purple); }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
.card-title { font-size: 13px; font-weight: 600; line-height: 1.35; }
.badge { font-size: 11px; font-weight: 700; padding: 3px 9px; border-radius: 20px; white-space: nowrap; flex-shrink: 0; }
.badge.up { background: var(--color-green-bg); color: var(--color-green); }
.badge.down { background: var(--color-red-bg); color: var(--color-red); }
.badge.flat { background: var(--color-yellow-bg); color: #92400E; }
.badge.new { background: var(--color-purple-bg); color: var(--color-purple); }
.card-value { font-size: 30px; font-weight: 700; font-family: var(--font-mono); line-height: 1; }
.card-value-year { font-size: 11px; color: var(--color-text-secondary); font-weight: 600; margin-left: 2px; }
.cmp { margin-top: 14px; display: flex; flex-direction: column; gap: 7px; }
.cmp-row { display: grid; grid-template-columns: 38px 1fr auto; align-items: center; gap: 8px; }
.cmp-year { font-size: 11px; color: var(--color-text-secondary); font-weight: 600; font-family: var(--font-mono); }
.cmp-track { height: 8px; background: var(--color-bg); border-radius: 8px; overflow: hidden; }
.cmp-fill { height: 100%; border-radius: 8px; transition: width 0.6s ease; min-width: 2px; }
.cmp-fill.cur { background: var(--color-brand); }
.cmp-fill.prev { background: #C7D2E8; }
.cmp-num { font-size: 11px; font-family: var(--font-mono); color: var(--color-text-secondary); white-space: nowrap; }
.empty-note { text-align: center; color: var(--color-text-secondary); padding: 40px; grid-column: 1 / -1; }
/* loading / error */
#status-msg { text-align:center; padding: 60px 20px; color: var(--color-text-secondary); }
@media (max-width: 900px) { .summary-grid { grid-template-columns: repeat(2,1fr); } }
@media (max-width: 560px) { .summary-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<!-- LOGIN -->
<div id="login-screen">
<div class="login-box">
<div class="login-logo">KT</div>
<h1>Метрики МП</h1>
<p>Казахтелеком · 2026</p>
<input type="password" id="login-password" class="txt-input" placeholder="Введите пароль" style="text-align:center;margin-bottom:12px;">
<button class="btn-primary" id="btn-login">Войти</button>
<p id="login-error"></p>
</div>
</div>
<!-- APP -->
<div id="app">
<header class="header">
<div class="header-left">
<div class="header-logo">KT</div>
<div>
<h1>📱 Метрики МП</h1>
<p>Использование функций мобильного приложения</p>
</div>
</div>
<div class="header-right">
<span id="generated-at"></span>
<a class="btn btn-ghost" href="../index.html">← KPI Dashboard</a>
</div>
</header>
<div class="wrap">
<div id="status-msg">Загрузка данных…</div>
<div id="content" style="display:none">
<div class="summary-grid" id="summary-grid"></div>
<div class="controls">
<input type="text" id="search" class="txt-input grow" placeholder="🔍 Поиск метрики…">
<select id="sort" class="txt-input">
<option value="cur">Сортировка: по использованию ↓</option>
<option value="growth">по приросту ↓</option>
<option value="label">по названию (А→Я)</option>
</select>
<label><input type="checkbox" id="only-new"> только новые</label>
</div>
<div class="cards" id="cards"></div>
</div>
</div>
</div>
<script>
const LOGIN_PASSWORD = 'KTdash1';
const DATA_URL = 'app_metrics.json';
// Резервные данные (на случай file:// или недоступности файла). Обновляются скриптом updater.
const EMBEDDED_METRICS = /*__DATA__*/{
"generated_at": "2026-06-16T17:30:19",
"cur_year": 2026,
"prev_year": 2025,
"period_label": "с 1 января по 15 июня",
"range": {
"start": "2026-01-01",
"end": "2026-06-15"
},
"metrics": [
{
"key": "my_services",
"label": "Мои услуги",
"prev": 1459571,
"cur": 1769470,
"growth": 0.21232197680003234,
"is_new": false
},
{
"key": "traffic",
"label": "Детализация трафика",
"prev": 1079736,
"cur": 1271563,
"growth": 0.17766102084213178,
"is_new": false
},
{
"key": "payments",
"label": "Платежи",
"prev": 553808,
"cur": 730185,
"growth": 0.3184804119839367,
"is_new": false
},
{
"key": "orders",
"label": "Заявки",
"prev": 635621,
"cur": 826255,
"growth": 0.2999177182629271,
"is_new": false
},
{
"key": "loyalty",
"label": "Лояльность",
"prev": 464365,
"cur": 470969,
"growth": 0.014221571393192856,
"is_new": false
},
{
"key": "pay",
"label": "Оплата",
"prev": 302510,
"cur": 337103,
"growth": 0.11435324452084229,
"is_new": false
},
{
"key": "billing_detail",
"label": "Детали счета",
"prev": 358290,
"cur": 496915,
"growth": 0.3869072539004717,
"is_new": false
},
{
"key": "viktorina",
"label": "Викторина KT Club",
"prev": 298475,
"cur": 213879,
"growth": -0.28342742273222216,
"is_new": false
},
{
"key": "partners",
"label": "Акции партнеров",
"prev": 94639,
"cur": 197009,
"growth": 1.08168936696288,
"is_new": false
},
{
"key": "tv_plus",
"label": "TV+",
"prev": 95647,
"cur": 64104,
"growth": -0.32978556567377965,
"is_new": false
},
{
"key": "boosters",
"label": "Бустеры",
"prev": 53649,
"cur": 121065,
"growth": 1.2566124252082984,
"is_new": false
},
{
"key": "roaming",
"label": "Роуминг",
"prev": 39200,
"cur": 22160,
"growth": -0.4346938775510204,
"is_new": false
},
{
"key": "pereoform",
"label": "Переоформление",
"prev": 23537,
"cur": 34570,
"growth": 0.46875132769681777,
"is_new": false
},
{
"key": "aitu_music",
"label": "Aitu Music",
"prev": 0,
"cur": 8651,
"growth": null,
"is_new": true
},
{
"key": "online_booking",
"label": "Онлайн очередь",
"prev": 5421,
"cur": 22144,
"growth": 3.084855192768862,
"is_new": false
},
{
"key": "my_docs",
"label": "Мои документы",
"prev": 0,
"cur": 53376,
"growth": null,
"is_new": true
},
{
"key": "dz_statement",
"label": "Справка о ДЗ",
"prev": 0,
"cur": 132795,
"growth": null,
"is_new": true
},
{
"key": "new_boosters_roaming_kcell",
"label": "Новая линейка бустеров и роумингов Кселл",
"prev": 0,
"cur": 28626,
"growth": null,
"is_new": true
},
{
"key": "adsl",
"label": "ADSL отключение услуги",
"prev": 0,
"cur": 69,
"growth": null,
"is_new": true
},
{
"key": "law_and_order",
"label": "Закон и порядок",
"prev": 0,
"cur": 1555,
"growth": null,
"is_new": true
},
{
"key": "acs",
"label": "ACS",
"prev": 0,
"cur": 9154,
"growth": null,
"is_new": true
},
{
"key": "kaspi_freedom_pay",
"label": "Прием платежей через Freedom и Kaspi",
"prev": 0,
"cur": 61481,
"growth": null,
"is_new": true
},
{
"key": "csat",
"label": "CSAT",
"prev": 0,
"cur": 2486,
"growth": null,
"is_new": true
},
{
"key": "multicustomer",
"label": "Мультикастомер",
"prev": 0,
"cur": 164,
"growth": null,
"is_new": true
},
{
"key": "tv_plus_setup",
"label": "Настройка TV+",
"prev": 0,
"cur": 4545,
"growth": null,
"is_new": true
},
{
"key": "static_ip",
"label": "Статический IP",
"prev": 0,
"cur": 108,
"growth": null,
"is_new": true
},
{
"key": "turbo_button",
"label": "Turbo кнопка",
"prev": 0,
"cur": 4312,
"growth": null,
"is_new": true
},
{
"key": "real_estate_docs",
"label": "Справка о недвижимости",
"prev": 0,
"cur": 78,
"growth": null,
"is_new": true
}
]
};
const App = { data: null };
// ───────── helpers ─────────
const fmtInt = n => (n || 0).toLocaleString('ru-RU');
function fmtPct(g) {
if (g === null || g === undefined) return '';
const s = (g * 100);
return (s >= 0 ? '+' : '') + s.toFixed(1) + '%';
}
function classify(m) {
if (m.is_new) return 'new';
if (m.growth === null) return 'flat';
if (m.growth > 0.0005) return 'up';
if (m.growth < -0.0005) return 'down';
return 'flat';
}
function badgeText(m, cls) {
if (cls === 'new') return 'NEW';
if (cls === 'flat') return '≈ 0%';
return fmtPct(m.growth);
}
// ───────── render ─────────
function renderSummary() {
const d = App.data;
const totCur = d.metrics.reduce((s, m) => s + m.cur, 0);
const totPrev = d.metrics.reduce((s, m) => s + m.prev, 0);
const overall = totPrev > 0 ? (totCur - totPrev) / totPrev : null;
const nNew = d.metrics.filter(m => m.is_new).length;
const nUp = d.metrics.filter(m => !m.is_new && m.growth > 0).length;
const nDown = d.metrics.filter(m => !m.is_new && m.growth < 0).length;
const cards = [
{ cls: '', label: 'Период', value: d.cur_year + ' / ' + d.prev_year, sub: d.period_label },
{ cls: 'accent-green', label: 'Обращений · ' + d.cur_year, value: fmtInt(totCur),
sub: 'против ' + fmtInt(totPrev) + ' в ' + d.prev_year + (overall !== null ? ' · ' + fmtPct(overall) : '') },
{ cls: 'accent-yellow', label: 'Динамика', value: '▲ ' + nUp + ' ▼ ' + nDown,
sub: nUp + ' выросли, ' + nDown + ' снизились' },
{ cls: 'accent-purple', label: 'Новых функций', value: String(nNew),
sub: 'появились в ' + d.cur_year + ' году' },
];
document.getElementById('summary-grid').innerHTML = cards.map(c => `
<div class="sum-card ${c.cls}">
<div class="sum-label">${c.label}</div>
<div class="sum-value">${c.value}</div>
<div class="sum-sub">${c.sub}</div>
</div>`).join('');
}
function renderCards() {
const d = App.data;
const q = document.getElementById('search').value.trim().toLowerCase();
const sort = document.getElementById('sort').value;
const onlyNew = document.getElementById('only-new').checked;
let list = d.metrics.slice();
if (onlyNew) list = list.filter(m => m.is_new);
if (q) list = list.filter(m => m.label.toLowerCase().includes(q));
list.sort((a, b) => {
if (sort === 'label') return a.label.localeCompare(b.label, 'ru');
if (sort === 'growth') {
// новые — в конце при сортировке по приросту (у них нет базы сравнения)
const ga = a.is_new ? -Infinity : (a.growth ?? -Infinity);
const gb = b.is_new ? -Infinity : (b.growth ?? -Infinity);
return gb - ga;
}
return b.cur - a.cur; // по использованию
});
const el = document.getElementById('cards');
if (!list.length) { el.innerHTML = '<div class="empty-note">Ничего не найдено</div>'; return; }
el.innerHTML = list.map(m => {
const cls = classify(m);
const max = Math.max(m.cur, m.prev, 1);
const curW = (m.cur / max * 100).toFixed(1);
const prevW = (m.prev / max * 100).toFixed(1);
return `
<div class="card ${cls}">
<div class="card-head">
<div class="card-title">${m.label}</div>
<span class="badge ${cls}">${badgeText(m, cls)}</span>
</div>
<div class="card-value">${fmtInt(m.cur)}<span class="card-value-year"> · ${d.cur_year}</span></div>
<div class="cmp">
<div class="cmp-row">
<span class="cmp-year">${d.cur_year}</span>
<span class="cmp-track"><span class="cmp-fill cur" style="width:${curW}%"></span></span>
<span class="cmp-num">${fmtInt(m.cur)}</span>
</div>
<div class="cmp-row">
<span class="cmp-year">${d.prev_year}</span>
<span class="cmp-track"><span class="cmp-fill prev" style="width:${prevW}%"></span></span>
<span class="cmp-num">${fmtInt(m.prev)}</span>
</div>
</div>
</div>`;
}).join('');
}
function renderAll() {
document.getElementById('status-msg').style.display = 'none';
document.getElementById('content').style.display = 'block';
const gen = App.data.generated_at ? new Date(App.data.generated_at) : null;
document.getElementById('generated-at').textContent =
'Обновлено: ' + (gen ? gen.toLocaleDateString('ru-RU') : '—');
renderSummary();
renderCards();
}
// ───────── load ─────────
async function loadData() {
if (window.location.protocol !== 'file:') {
try {
const r = await fetch(DATA_URL, { cache: 'no-cache' });
if (r.ok) { App.data = await r.json(); renderAll(); return; }
} catch (e) { /* fall through */ }
}
if (EMBEDDED_METRICS && EMBEDDED_METRICS.metrics) {
App.data = EMBEDDED_METRICS; renderAll(); return;
}
document.getElementById('status-msg').textContent =
'Не удалось загрузить данные (app_metrics.json).';
}
['search', 'sort', 'only-new'].forEach(id =>
document.addEventListener('input', e => { if (e.target.id === id) renderCards(); }));
// ───────── login ─────────
function showApp() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').style.display = 'block';
loadData();
}
function handleLogin() {
const inp = document.getElementById('login-password');
if (inp.value === LOGIN_PASSWORD) {
if (window.sessionStorage) sessionStorage.setItem('kpi_auth', '1');
showApp();
} else {
document.getElementById('login-error').textContent = 'Неверный пароль';
inp.value = '';
}
}
document.getElementById('btn-login').addEventListener('click', handleLogin);
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') handleLogin(); });
document.addEventListener('DOMContentLoaded', () => {
if (window.sessionStorage && sessionStorage.getItem('kpi_auth') === '1') showApp();
});
</script>
</body>
</html>