- 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
561 lines
21 KiB
HTML
561 lines
21 KiB
HTML
<!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>
|