Опубликовано через кнопку

This commit is contained in:
tore 2026-09-26 05:27:15 +00:00
parent cd37571b0c
commit ad2749328d
5 changed files with 1093 additions and 318 deletions

View File

@ -1,38 +1,27 @@
# Дашборд продаж по регионам
## Тип решения
Статический сайт (index.html + style.css + script.js на ванильном JS с Tailwind CDN).
Сделай дашборд по продажам Казахтелекома по всем регионам Казахстана.
## Задача
Создать визуализацию показателей продаж по регионам Казахстана за текущий месяц для презентации в Telegram-группе.
Цель: Визуализировать показатели продаж по всем регионам Казахстана.
## Кто пользуется
Руководство департамента, менеджеры по продажам (просмотр).
Пользователи: Сотрудники, отслеживающие продажи
## Что делает прототип
- Отображает сводные карточки (KPI) сверху: общая выручка, выполнение плана (%), количество сделок.
- Показывает интерактивную карту или таблицу регионов (Алматы, Астана, Шымкент, ЗКО, ВКО и др.) с цветовым кодированием выполнения плана.
- Отрисовывает столбчатую диаграмму (Bar Chart) продаж по каждому региону за месяц.
- Реализует фильтр по месяцам (выпадающий список), меняющий данные на графиках без перезагрузки страницы.
- Кнопка «Обновить данные» имитирует подгрузку свежих чисел.
- Адаптивная верстка для просмотра с мобильных устройств в Telegram.
Экраны:
- Главный экран: карта Казахстана с регионами и сводные показатели продаж
## Демо-данные
- 16 регионов РК (области + города республиканского значения).
- Реалистичные суммы продаж в тенге (диапазон 10–500 млн ₸).
- Статусы выполнения плана: «Выполнен», «В работе», «Отставание».
- Даты транзакций в формате ДД.ММ.ГГГГ за текущий месяц.
Данные: Показатели продаж по каждому региону Казахстана
## Допущения
- Данные хранятся только в браузере (localStorage), при очистке кэша сбрасываются.
- Валюта отображается в тенге (₸).
- График строится на библиотеке Chart.js (подключается через CDN).
Правила брифа: интерфейс в стиле Казахтелекома на дизайн-системе KT AI, на русском; адаптивно для телефона; без входа по логину и паролю. Если настоящих данных нет — наполни приложение правдоподобными примерами. Не пиши на экране пометок «демо», «демонстрационная версия», «тестовые данные» и не утверждай, откуда взяты данные.
## Чего пока нет
- Авторизации и разграничения прав доступа.
- Интеграции с 1С, CRM и реальными базами данных.
- Выгрузки отчетов в Excel (только визуализация).
- Серверной части и постоянного хранилища.
## Объём работы
Собирай ровно то, что написано в задании. Не добавляй лишние разделы, журналы и настройки. Для статичной страницы достаточно index.html. Если запрошен статичный макет, не добавляй серверную часть на этом этапе.
Если задача требует общего хранения, настоящей регистрации или рабочего приложения, реализуй серверную часть и базу данных средствами Vibe AI. Создай необходимые файлы, таблицы и обработчики, даже если владелец не перечислил их отдельно. Не подменяй общее хранение localStorage или демонстрационным списком. Собственная база приложения не требует интеграции с системами КТ и отдельного утверждения дизайна в экспрессе. При доработке существующего проекта это правило заменяет прежнее ограничение одним HTML-файлом.
Проверь сохранение записи, чтение из другой сессии, обработку ошибок и ограничения доступа. Личные данные доступны только уполномоченному пользователю, секреты не попадают в браузерный код. Не придумывай доступы или готовые интеграции. Если публикация серверной части недоступна, укажи конкретную причину и не объявляй функцию работающей. Закончив, опубликуй приложение и сообщи фактический адрес и результаты проверки; сохраняй прежний адрес, если платформа это позволяет.
## Имена файлов
Если серверная часть не нужна, скрипт интерфейса называй script.js, стили — style.css. Не создавай в корне проекта файлы app.js, main.js, index.js, server.js, bot.js, app.py, main.py, server.py и package.json со scripts.start: платформа примет проект за серверное приложение и не опубликует статичный сайт. Эти имена — только для настоящего сервера.
## Дизайн — дизайн-система KT AI
@ -41,12 +30,38 @@
Подключай файлы ДС относительными путями и в этом порядке: `./design-system/kt-ai-fonts.css`,
`./design-system/kt-ai-tokens.css`, `./design-system/kt-ai-components.css`, `./design-system/kt-ai-page.css`.
Тема по умолчанию — светлая: `<html lang="ru" data-theme="light">`; не ставь `dark` и не ставь `auto`
(auto уходит в тёмную по системной теме). Переключатель темы можно оставить, но открываться страница должна светлой.
(auto уходит в тёмную по системной теме). `lang` — язык интерфейса из задания: казахский — `kk`, английский — `en`, иначе `ru`. Переключатель темы можно оставить, но открываться страница должна светлой.
Цвета, отступы и размеры — только токенами `var(--kt-ai-…)`, иконки — из `design-system/icons/kt-ai-lucide-sprite.svg`.
Плашки показателей — `.kt-ai-kpi` (`.value`, `.label`, `.hint`) в полосе `.kt-ai-kpi-strip`; таблицы — по правилам ДС (frameless, `.kt-ai-table-wrap`, числа вправо, `tabular-nums`);
графики — чистый SVG/div высотой 190–220px, столбцы 20–28px с радиусом 2px, цвета только `var(--kt-ai-chart-blue|green|orange|pink|red|salmon)`, подсказки значений — `data-kt-tip` + `./design-system/kt-ai-chart-tip.js`.
Имена токенов не выдумывай — только существующие в `design-system/kt-ai-tokens.css`: --kt-ai-fg / -fg-muted / -fg-faint, --kt-ai-bg / -bg-soft, --kt-ai-card-bg / -card-border, --kt-ai-border / -border-strong, --kt-ai-primary, --kt-ai-radius-xl / -3xl, --kt-ai-font-sans / -mono, --kt-ai-chart-blue / -green / -orange / -pink / -red / -salmon, --kt-ai-status-ok-fg / -warn-fg / -risk-fg.
Несуществующий токен (`--kt-ai-text-primary`, `--kt-ai-bg-surface`, `--kt-ai-font-family`) молча не применится — страница выйдет в Times New Roman без рамок.
Классы ДС (`.kt-ai-kpi`, `.kt-ai-card`, `.kt-ai-table`, `.kt-ai-btn`, `.kt-ai-chip`, `.kt-ai-input`) в своём `<style>` не переопределяй — ДС рисует их сама; свой `<style>` — только сетка и отступы. `<body class="kt-ai-app">`.
Перед публикацией проверь сам: в HTML не меньше десятка классов `kt-ai-*`, каждый `var(--kt-ai-…)` есть в tokens.css, свои hex-цвета — только внутри логотипа. Иначе это не дизайн-система, а перекраска.
Фильтры и пустое состояние тоже из ДС (`.kt-ai-select`, `.kt-ai-filter-chip`, `.kt-ai-empty`): каждый выведенный фильтр обязан менять все представления, которые задевает — таблицу, показатели, график и текст объяснения.
Таблицы, списки, журналы, дашборды и формы собирай из готовых классов ДС (`design-system/COMPONENTS.md`),
архетип экрана выбирай по `design-system/docs/ARCHETYPES.md`. Каркас страницы — из `design.md`, с обычными `<link>`.
Шаблон `design-system/templates/kt-ai-app-shell.html` целиком не копируй: его скрипты-загрузчики (`data-kt-ai-base-resolver`,
`data-kt-ai-boot`) тянут CSS с корня сайта по абсолютному пути `/design-system/…`, а сайт публикуется в подпапке — стили не загрузятся.
Подключай только файлы, которые реально лежат в `design-system/` (проверь `ls design-system`): файла `kt-ai-components.js` там нет.
Шапка страницы: слева логотип Казахтелекома — `<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" height="28">`, рядом название приложения.
Свой стиль не выдумывай, Bootstrap/Tailwind/Material/Google Fonts не подключай, сырые hex-цвета не пиши.
Папку `design-system/` закоммить вместе с проектом — иначе опубликованная страница останется без стилей.
## Телефон — основной экран
Сотрудники открывают приложение в мессенджере на телефоне. Всё рабочее — поиск, фильтры, кнопки действий, формы, переключатели — должно быть видно и работать при ширине 375px, без горизонтальной прокрутки страницы. Дизайн-система на экранах уже 640px скрывает поиск верхней панели (классы `.kt-ai-search` и `.kt-ai-search-field`): рабочий поиск по данным ставь в области контента над списком или таблицей обычным полем `.kt-ai-input`, а не этими классами.
## Без пометок «демо»
Не пиши на экране «Демо-данные», «демонстрационная версия», «тестовые данные», «макет» и похожие пометки. Если настоящих данных нет — наполни приложение правдоподобными примерами. Не утверждай, откуда взяты данные.
## Ничего не додумывай
НИЧЕГО НЕ ДОДУМЫВАЙ (правило продукта): источник — задание (в нём — слова сотрудника). Сведений о работе, которых там нет, не добавляй и не «уточняй» за сотрудника: компании и бренды, системы и интеграции, отделы и роли, адреса, числа, цены, сроки, показатели, источники данных. Чего нет — того не пиши. Предлагать своё можно, только если сотрудник сам просит предложить, придумать, подобрать, найти или изучить. Примеры в таблицах и на графиках — обычные и нейтральные, они только показывают устройство экрана. Чего-то не хватает — оставь поле пустым или добавь пустое состояние, а не выдуманный факт.
## Большие файлы — частями, скрипт — простой
Файл длиннее ~250 строк не пиши одной записью: создай его с первой частью и дописывай следующими правками (по экрану или блоку за раз). Запись слишком большого файла одним сообщением обрывается.
Скрипт пиши просто: обычные функции и циклы, без хитрых однострочников; методы строк (padStart, toFixed, slice) вызывай у значений нужного типа, к элементу обращайся после проверки, что он найден. После сборки перечитай скрипт: парные скобки, каждый экран рисуется без исключений, первый экран при открытии сразу показывает наполненные таблицы.
Интерпретатора JavaScript в песочнице нет. Не ищи его и НЕ ПИШИ никаких проверяльщиков синтаксиса (python-скрипты, токенайзеры, счётчики скобок) — это только тратит время: синтаксис и запуск опубликованной страницы проверит Tõre и вернёт, что исправить. Записал файлы — перечитай глазами и сразу закоммить и опубликуй. Серверы и предпросмотр не запускай, вопросов сотруднику не задавай (ответить некому — задание уже полное; чего нет — сделай по своему разумению).

181
data-kt.js Normal file
View File

@ -0,0 +1,181 @@
// Данные дашборда: 17 регионов Казахстана, полигоны карты,
// генерация стабильных показателей за 12 месяцев.
var KT_MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
// 17 регионов: id, полное имя, короткая подпись, центр подписи на карте
var KT_REGIONS = [
{ id: 'kso', name: 'Костанайская обл.', short: 'Костанайская', cx: 320, cy: 68 },
{ id: 'sko', name: 'Сев.-Казахстанская обл.', short: 'Сев.-Казах.', cx: 365, cy: 132 },
{ id: 'kgo', name: 'Павлодарская обл.', short: 'Павлодарская', cx: 490, cy: 88 },
{ id: 'oko', name: 'Восточно-Казахстанская обл.', short: 'Вост.-Казах.', cx: 575, cy: 184 },
{ id: 'wko', name: 'Атырауская обл.', short: 'Атырауская', cx: 120, cy: 214 },
{ id: 'zko', name: 'Зап.-Казахстанская обл.', short: 'Зап.-Казах.', cx: 55, cy: 292 },
{ id: 'ako', name: 'Актюбинская обл.', short: 'Актюбинская', cx: 152, cy: 324 },
{ id: 'kko', name: 'Мангистауская обл.', short: 'Мангистау', cx: 46, cy: 146 },
{ id: 'kzo', name: 'Карагандинская обл.', short: 'Караганда', cx: 305, cy: 274 },
{ id: 'ulytao',name:'Улытауская обл.', short: 'Улытау', cx: 358, cy: 248 },
{ id: 'ast', name: 'Астана', short: 'Астана', cx: 398, cy: 182 },
{ id: 'zho', name: 'Жамбылская обл.', short: 'Жамбылская', cx: 215, cy: 262 },
{ id: 'tgo', name: 'Туркестанская обл.', short: 'Туркестанская', cx: 270, cy: 342 },
{ id: 'alo', name: 'Алматинская обл.', short: 'Алматинская', cx: 150, cy: 368 },
{ id: 'alm', name: 'Алматы', short: 'Алматы', cx: 105, cy: 382 },
{ id: 'shym', name: 'Шымкент', short: 'Шымкент', cx: 315, cy: 378 },
{ id: 'symk', name: 'Южно-Казахстанская обл.', short: 'Южно-Казах.', cx: 330, cy: 340 }
];
// Полигоны карты (стилизованное упрощение, viewBox 0 0 1000 420).
// Грани соседних регионов общие — карта читается как единая территория.
var KT_MAP = {
kso: [[275,35],[400,30],[460,55],[450,95],[400,90],[280,95]],
sko: [[280,95],[400,90],[430,140],[390,175],[300,168],[285,135]],
kgo: [[460,55],[545,60],[590,105],[565,142],[500,135],[450,95]],
oko: [[430,140],[540,120],[650,150],[680,210],[610,250],[520,235],[470,215],[430,175]],
wko: [[60,170],[150,160],[185,205],[185,258],[110,262],[20,270],[20,200]],
zko: [[20,270],[110,262],[140,298],[110,330],[55,318],[20,300]],
ako: [[110,262],[185,258],[205,295],[175,335],[110,330],[140,298]],
kko: [[15,100],[95,90],[130,132],[95,170],[60,170],[20,200],[15,150]],
kzo: [[185,258],[265,250],[300,285],[275,322],[205,295]],
ulytao: [[320,238],[390,230],[405,262],[375,285],[345,270],[300,285]],
ast: [[375,160],[412,155],[418,182],[390,196]],
zho: [[150,246],[215,240],[245,268],[215,300],[205,295],[185,258]],
tgo: [[205,295],[275,322],[300,305],[300,340],[255,352],[215,352]],
alo: [[120,350],[175,335],[195,362],[155,390],[110,378]],
alm: [[82,362],[120,350],[110,378],[90,390]],
symk: [[300,305],[340,318],[345,355],[300,340],[300,340]],
shym: [[305,356],[335,352],[345,375],[315,384],[298,372]]
};
// Сезонный профиль года (доли): декабрь пик, февраль спад
var KT_SEASON = [0.82, 0.74, 0.95, 1.0, 1.05, 1.0, 0.9, 0.95, 1.1, 1.15, 1.2, 1.38];
// Базовые планы, млн ₸ — крупные регионы больше
var KT_PLAN_BASE = {
ast: 680, alm: 640, symk: 340, kzo: 320, wko: 250, kko: 230,
ako: 220, kso: 210, oko: 200, zko: 190, tgo: 185, kgo: 170,
zho: 170, sko: 160, alo: 150, ulytao: 110, shym: 260
};
// Склонность региона к выполнению плана (дефолт 1.0)
var KT_BIAS = {
ast: 1.12, alm: 1.1, shym: 1.05, symk: 1.0, kko: 1.04, kzo: 1.0,
wko: 0.92, ako: 1.02, kso: 1.08, sko: 0.88, kgo: 0.95, oko: 0.9,
zko: 1.05, tgo: 0.95, zho: 0.94, alo: 0.97, ulytao: 0.9
};
function ktHash(str) {
var h = 2166136261;
for (var i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
// Детерминированный псевдослучайный 0..1 по строке
function ktRand(seed) {
var x = ktHash(seed);
x ^= x << 13; x >>>= 0;
x ^= x >> 17;
x ^= x << 5; x >>>= 0;
return x / 4294967296;
}
function ktMonthLabel(offset) {
var now = new Date();
var m = new Date(now.getFullYear(), now.getMonth() - offset, 1);
return KT_MONTHS[m.getMonth()] + ' ' + m.getFullYear();
}
// Данные региона за месяц (offset от текущего)
function ktRegionMonth(rid, offset) {
var r = null;
for (var i = 0; i < KT_REGIONS.length; i++) {
if (KT_REGIONS[i].id === rid) { r = KT_REGIONS[i]; break; }
}
var planBase = KT_PLAN_BASE[rid];
if (!planBase) planBase = 150;
var now = new Date();
var m = new Date(now.getFullYear(), now.getMonth() - offset, 1);
var monIdx = m.getMonth();
var plan = Math.round(planBase * (0.9 + ktRand('plan_' + rid + '_' + m.getFullYear() + '_' + monIdx) * 0.2) * 10) / 10;
var bias = KT_BIAS[rid] || 1;
var seed = 'fact_' + rid + '_' + m.getFullYear() + '_' + monIdx + '_' + (window.KT_SEED || 'base');
var noise = 0.88 + ktRand(seed) * 0.3;
var fact = Math.round(plan * bias * KT_SEASON[monIdx] * noise * 10) / 10;
var percent = Math.round((fact / plan) * 100);
var deals = Math.floor(18 + ktRand('deals_' + seed) * 120 * (KT_SEASON[monIdx] - 0.4));
if (deals < 5) deals = 5;
var tone = 'risk';
if (percent >= 100) tone = 'ok';
else if (percent >= 90) tone = 'warn';
return {
id: rid,
name: r ? r.name : rid,
short: r ? r.short : rid,
cx: r ? r.cx : 0,
cy: r ? r.cy : 0,
plan: plan,
fact: fact,
percent: percent,
deals: deals,
tone: tone
};
}
// Свод месяца: { offset, label, rows, kpi }
function ktMonthData(offset) {
var rows = [];
for (var i = 0; i < KT_REGIONS.length; i++) {
rows.push(ktRegionMonth(KT_REGIONS[i].id, offset));
}
rows.sort(function(a, b) { return b.fact - a.fact; });
var planSum = 0, factSum = 0, dealsSum = 0;
for (var j = 0; j < rows.length; j++) {
planSum += rows[j].plan;
factSum += rows[j].fact;
dealsSum += rows[j].deals;
}
var top5 = {};
for (var t = 0; t < Math.min(5, rows.length); t++) top5[rows[t].id] = true;
var leaders = 0;
for (var l = 0; l < rows.length; l++) {
if (top5[rows[l].id] && rows[l].tone === 'ok') leaders++;
}
return {
offset: offset,
label: ktMonthLabel(offset),
rows: rows,
kpi: {
plan: Math.round(planSum),
fact: Math.round(factSum),
percent: Math.round((factSum / planSum) * 100),
deals: dealsSum,
leaders: leaders
}
};
}
// Форматирование: m в млн ₸
function ktFormatMillion(m) {
if (m >= 1000) {
var v = Math.round(m / 10) / 100;
return v.toFixed(2).replace('.', ',') + ' млрд';
}
return Math.round(m) + ' млн';
}
function ktFormatMoney(m) {
if (m >= 1000) {
var v = Math.round(m / 10) / 100;
return v.toFixed(2).replace('.', ',') + ' млрд ₸';
}
return Math.round(m) + ' млн ₸';
}

View File

@ -3,308 +3,139 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Дашборд продаж по регионам</title>
<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">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
.kpi-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kt-ai-space-6); margin-bottom: var(--kt-ai-space-8); }
.kpi-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-3xl); padding: var(--kt-ai-space-6); box-shadow: var(--kt-ai-card-shadow); }
.kpi-value { font-size: var(--kt-ai-text-3xl); font-weight: var(--kt-ai-weight-bold); color: var(--kt-ai-fg); line-height: 1; margin-bottom: var(--kt-ai-space-2); }
.kpi-label { font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); }
.kpi-hint { font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-faint); margin-top: var(--kt-ai-space-1); }
.chart-wrap { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-3xl); padding: var(--kt-ai-space-6); margin-bottom: var(--kt-ai-space-8); }
.table-wrap { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-3xl); overflow: hidden; }
.toolbar { display: flex; gap: var(--kt-ai-space-4); align-items: center; flex-wrap: wrap; margin-bottom: var(--kt-ai-space-6); }
.status-pill { display: inline-block; padding: 2px 10px; border-radius: var(--kt-ai-radius-full); font-size: var(--kt-ai-text-xs); font-weight: var(--kt-ai-weight-medium); }
.status-ok { background: var(--kt-ai-status-ok-bg); color: var(--kt-ai-status-ok-fg); }
.status-warn { background: var(--kt-ai-status-warn-bg); color: var(--kt-ai-status-warn-fg); }
.status-risk { background: var(--kt-ai-status-risk-bg); color: var(--kt-ai-status-risk-fg); }
.region-color { width: 12px; height: 12px; border-radius: 3px; display: inline-block; vertical-align: middle; margin-right: 8px; }
a.kt-ai-btn { text-decoration: none; }
.page-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-6); }
.page-title h1 { font-size: var(--kt-ai-text-2xl); font-weight: var(--kt-ai-weight-semibold); margin: 0; }
.page-title p { font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); margin: 4px 0 0; }
@media (max-width: 640px) {
.kpi-strip { grid-template-columns: 1fr; }
.toolbar { flex-direction: column; align-items: stretch; }
.toolbar > * { width: 100%; }
}
</style>
<link rel="stylesheet" href="style.css">
</head>
<body>
<body class="kt-ai-app">
<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" id="refreshBtn">
<svg class="kt-icon kt-icon-sm" style="margin-right:6px"><use href="design-system/icons/kt-ai-lucide-sprite.svg#refreshCw"></use></svg>
Обновить
<div class="kt-ai-shell dash-shell">
<aside class="kt-ai-sidebar dash-sidebar">
<div class="kt-ai-sidebar-brand">
<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" height="24">
<span class="dash-brand-name">Дашборд продаж</span>
</div>
<nav class="kt-ai-sidebar-nav">
<div class="kt-ai-nav-group">
<a class="kt-ai-nav-item is-active" href="#">
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="design-system/icons/kt-ai-lucide-sprite.svg#dashboard"></use></svg>
<span>Регионы</span>
</a>
<a class="kt-ai-nav-item" href="#" data-soon="true">
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="design-system/icons/kt-ai-lucide-sprite.svg#chart"></use></svg>
<span>Динамика</span>
</a>
</div>
</nav>
<div class="dash-sidebar-note">Казахстан · 17 регионов</div>
</aside>
<main class="dash-main">
<header class="dash-topbar">
<div class="dash-topbar-titles">
<span class="dash-crumb">Продажи</span>
<h1 class="dash-title">По регионам</h1>
</div>
<div class="dash-topbar-actions">
<div class="kt-ai-select-wrap" style="min-width:170px">
<select class="kt-ai-select" id="monthSelect" aria-label="Период"></select>
</div>
<button class="kt-ai-btn kt-ai-btn-sm" id="refreshBtn" type="button">
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="design-system/icons/kt-ai-lucide-sprite.svg#refresh"></use></svg>
Обновить данные
</button>
</div>
</header>
</header>
<main class="kt-ai-wrap" style="padding-top:var(--kt-ai-space-9);padding-bottom:var(--kt-ai-space-9)">
<div class="page-header">
<div class="page-title">
<h1>Продажи по регионам</h1>
<p>Сводные показатели за выбранный период</p>
<div class="kt-ai-kpi-strip dash-kpis" id="kpiStrip">
<div class="kt-ai-kpi" data-kpi="revenue">
<span class="label">Выручка</span>
<span class="value" data-long="true" id="kpiRevenue">—</span>
<span class="hint" id="kpiRevenueHint">за период</span>
</div>
<div class="toolbar" style="margin:0">
<div class="kt-ai-field" style="margin:0;min-width:180px">
<label class="kt-ai-label" style="margin-bottom:4px">Период</label>
<select class="kt-ai-select" id="monthSelect">
<option value="0">Текущий месяц</option>
<option value="1">Прошлый месяц</option>
<option value="2">2 месяца назад</option>
<option value="3">3 месяца назад</option>
</select>
<div class="kt-ai-kpi" data-kpi="plan">
<span class="label">Выполнение плана</span>
<span class="value" id="kpiPlan">—</span>
<span class="hint" id="kpiPlanHint">факт / план</span>
</div>
<div class="kt-ai-kpi" data-kpi="deals">
<span class="label">Сделки</span>
<span class="value" id="kpiDeals">—</span>
<span class="hint" id="kpiDealsHint">закрыто за период</span>
</div>
<div class="kt-ai-kpi" data-kpi="leaders">
<span class="label">Лидеры по плану</span>
<span class="value" id="kpiLeaders">—</span>
<span class="hint">из 5 топовых регионов</span>
</div>
</div>
<div class="kpi-strip" id="kpiStrip">
<div class="kpi-card">
<div class="kpi-value" id="kpiRevenue">0 ₸</div>
<div class="kpi-label">Общая выручка</div>
<div class="kpi-hint" id="kpiRevenueHint">за период</div>
<div class="dash-grid">
<section class="dash-panel" aria-label="Карта регионов">
<div class="dash-panel-head">
<h2>Карта регионов</h2>
<div class="dash-legend">
<span class="dash-legend-item"><i class="dash-legend-dot" data-tone="ok"></i>выполнен</span>
<span class="dash-legend-item"><i class="dash-legend-dot" data-tone="warn"></i>90–99%</span>
<span class="dash-legend-item"><i class="dash-legend-dot" data-tone="risk"></i>ниже 90%</span>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpiPlan">0%</div>
<div class="kpi-label">Выполнение плана</div>
<div class="kpi-hint" id="kpiPlanHint">среднее по регионам</div>
</div>
<div class="kpi-card">
<div class="kpi-value" id="kpiDeals">0</div>
<div class="kpi-label">Количество сделок</div>
<div class="kpi-hint" id="kpiDealsHint">за период</div>
<div class="dash-map-wrap">
<svg id="kzMap" viewBox="0 0 1000 420" role="img" aria-label="Карта Казахстана по регионам"></svg>
</div>
<div class="dash-map-selected" id="mapSelected"></div>
</section>
<section class="dash-panel" aria-label="Сравнение плана и факта">
<div class="dash-panel-head">
<h2>План и факт по выручке</h2>
<span class="dash-panel-hint" id="chartHint"></span>
</div>
<div class="dash-chart" id="chartBars"></div>
</section>
</div>
<div class="chart-wrap">
<canvas id="salesChart" height="80"></canvas>
<section class="dash-panel" aria-label="Регионы">
<div class="dash-panel-head">
<h2>Регионы</h2>
<span class="dash-panel-hint" id="tableCount"></span>
</div>
<div class="table-wrap">
<div class="kt-ai-toolbar" style="display:flex;gap:var(--kt-ai-space-4);flex-wrap:wrap;align-items:center;margin-bottom:var(--kt-ai-space-5)">
<input class="kt-ai-input dash-search" id="searchInput" type="search" placeholder="Найти регион" aria-label="Поиск по регионам">
<div class="dash-sort" role="group" aria-label="Сортировка">
<button class="kt-ai-btn kt-ai-btn-sm" data-sort="actual" type="button">По выручке</button>
<button class="kt-ai-btn kt-ai-btn-sm" data-sort="percent" type="button">По %</button>
<button class="kt-ai-btn kt-ai-btn-sm" data-sort="name" type="button">По алфавиту</button>
</div>
</div>
<div class="kt-ai-table-wrap" style="border:none">
<table class="kt-ai-table">
<thead>
<tr>
<th class="kt-ai-sticky-first">Регион</th>
<th style="text-align:right">План</th>
<th style="text-align:right">Факт</th>
<th style="text-align:right">%</th>
<th class="dash-num">План</th>
<th class="dash-num">Факт</th>
<th class="dash-num">%</th>
<th class="dash-num">Сделки</th>
<th>Статус</th>
</tr>
</thead>
<tbody id="regionsTable"></tbody>
</table>
</div>
<div class="kt-ai-empty" id="tableEmpty" hidden>
<p>По запросу регионы не найдены</p>
<button class="kt-ai-btn kt-ai-btn-sm" id="clearSearch" type="button">Сбросить поиск</button>
</div>
</section>
</main>
</div>
</main>
<script>
const REGIONS = [
{ id: 'almaty', name: 'Алматы', color: '#0096d7' },
{ id: 'astana', name: 'Астана', color: '#4b9ce2' },
{ id: 'shymkent', name: 'Шымкент', color: '#7fc794' },
{ id: 'zko', name: 'ЗКО', color: '#f5a35f' },
{ id: 'vko', name: 'ВКО', color: '#bb9ae0' },
{ id: 'sko', name: 'СКО', color: '#f0928a' },
{ id: 'ako', name: 'АКО', color: '#1683f3' },
{ id: 'zho', name: 'Жамбылская обл.', color: '#34a853' },
{ id: 'kzo', name: 'Кызылординская обл.', color: '#ff6a00' },
{ id: 'ako2', name: 'Актюбинская обл.', color: '#d2785f' },
{ id: 'mangistau', name: 'Мангистауская обл.', color: '#ea2f6b' },
{ id: 'atyrau', name: 'Атырауская обл.', color: '#ef4444' },
{ id: 'pavlodar', name: 'Павлодарская обл.', color: '#8a5fb0' },
{ id: 'karaganda', name: 'Карагандинская обл.', color: '#0284c7' },
{ id: 'kostanay', name: 'Костанайская обл.', color: '#16a34a' },
{ id: 'turkestan', name: 'Туркестанская обл.', color: '#f97316' }
];
const MONTH_NAMES = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'];
function generateData(monthOffset) {
const now = new Date();
const targetMonth = new Date(now.getFullYear(), now.getMonth() - monthOffset, 1);
const data = [];
let totalRevenue = 0;
let totalPlan = 0;
let totalActual = 0;
let totalDeals = 0;
REGIONS.forEach((region, idx) => {
const basePlan = 50 + Math.random() * 150;
const plan = Math.round(basePlan * 1e6) * 1e6;
const performance = 0.75 + Math.random() * 0.35;
const actual = Math.round(plan * performance);
const deals = Math.floor(15 + Math.random() * 85);
totalRevenue += actual;
totalPlan += plan;
totalActual += actual;
totalDeals += deals;
data.push({
region: region.name,
color: region.color,
plan: plan,
actual: actual,
percent: Math.round(performance * 100),
deals: deals,
status: performance >= 1 ? 'ok' : performance >= 0.9 ? 'warn' : 'risk'
});
});
return {
data,
kpi: {
revenue: totalRevenue,
planPercent: Math.round((totalActual / totalPlan) * 100),
deals: totalDeals
}
};
}
function formatMoney(num) {
if (num >= 1e9) return (num / 1e9).toFixed(1) + ' млрд ₸';
if (num >= 1e6) return (num / 1e6).toFixed(0) + ' млн ₸';
return num.toLocaleString('ru-RU') + ' ₸';
}
function getStatusClass(status) {
return status === 'ok' ? 'status-ok' : status === 'warn' ? 'status-warn' : 'status-risk';
}
function getStatusText(status) {
return status === 'ok' ? 'Выполнен' : status === 'warn' ? 'В работе' : 'Отставание';
}
let chart = null;
function renderChart(data) {
const ctx = document.getElementById('salesChart').getContext('2d');
if (chart) chart.destroy();
chart = new Chart(ctx, {
type: 'bar',
data: {
labels: data.map(d => d.region),
datasets: [{
label: 'Продажи',
data: data.map(d => d.actual),
backgroundColor: data.map(d => d.color),
borderRadius: 4,
borderSkipped: false
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return formatMoney(context.raw);
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
if (value >= 1e9) return (value / 1e9).toFixed(0) + ' млрд';
if (value >= 1e6) return (value / 1e6).toFixed(0) + ' млн';
return value;
}
}
},
x: {
ticks: { maxRotation: 45, minRotation: 45 }
}
}
}
});
}
function renderTable(data) {
const tbody = document.getElementById('regionsTable');
tbody.innerHTML = data.map(d => `
<tr>
<td class="kt-ai-sticky-first">
<span class="region-color" style="background:${d.color}"></span>
${d.region}
</td>
<td style="text-align:right">${formatMoney(d.plan)}</td>
<td style="text-align:right">${formatMoney(d.actual)}</td>
<td style="text-align:right;font-weight:600">${d.percent}%</td>
<td><span class="status-pill ${getStatusClass(d.status)}">${getStatusText(d.status)}</span></td>
</tr>
`).join('');
}
function renderKPI(kpi) {
document.getElementById('kpiRevenue').textContent = formatMoney(kpi.revenue);
document.getElementById('kpiPlan').textContent = kpi.planPercent + '%';
document.getElementById('kpiDeals').textContent = kpi.deals;
}
function loadData() {
const monthOffset = parseInt(document.getElementById('monthSelect').value) || 0;
const storageKey = 'dashboard_data_' + monthOffset;
let cached = localStorage.getItem(storageKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
return parsed;
} catch (e) {}
}
const fresh = generateData(monthOffset);
localStorage.setItem(storageKey, JSON.stringify(fresh));
return fresh;
}
function refresh() {
const monthOffset = parseInt(document.getElementById('monthSelect').value) || 0;
const storageKey = 'dashboard_data_' + monthOffset;
localStorage.removeItem(storageKey);
const btn = document.getElementById('refreshBtn');
const original = btn.innerHTML;
btn.innerHTML = '<svg class="kt-icon kt-icon-sm" style="margin-right:6px"><use href="design-system/icons/kt-ai-lucide-sprite.svg#loader"></use></svg> Загрузка...';
setTimeout(() => {
const data = loadData();
renderChart(data.data);
renderTable(data.data);
renderKPI(data.kpi);
btn.innerHTML = original;
}, 600);
}
document.getElementById('monthSelect').addEventListener('change', function() {
const data = loadData();
renderChart(data.data);
renderTable(data.data);
renderKPI(data.kpi);
});
document.getElementById('refreshBtn').addEventListener('click', refresh);
const data = loadData();
renderChart(data.data);
renderTable(data.data);
renderKPI(data.kpi);
</script>
<script src="design-system/kt-ai-chart-tip.js"></script>
<script src="data-kt.js"></script>
<script src="script.js"></script>
</body>
</html>

433
script.js Normal file
View File

@ -0,0 +1,433 @@
// Дашборд: состояние, карта, KPI, график, таблица, фильтры.
// Интерактив (период, поиск, выбор региона, сортировка) — в localStorage.
// Показатели за месяцы детерминированы сидом — стабильны между сессиями.
// ---------- Состояние ----------
var state = {
offset: 0, // 0 = текущий месяц, 1..11 = назад
selected: null, // id выбранного региона (клик по карте)
search: '',
sort: 'actual', // 'actual' | 'percent' | 'name'
seed: ''
};
try {
var ss = localStorage.getItem('kt_dash_seed');
state.seed = ss || String(Date.now());
localStorage.setItem('kt_dash_seed', state.seed);
} catch (e) { state.seed = String(Date.now()); }
window.KT_SEED = state.seed;
try {
var so = parseInt(localStorage.getItem('kt_dash_offset') || '0', 10);
if (!isNaN(so) && so >= 0 && so <= 11) state.offset = so;
state.selected = localStorage.getItem('kt_dash_selected') || null;
state.search = localStorage.getItem('kt_dash_search') || '';
state.sort = localStorage.getItem('kt_dash_sort') || 'actual';
} catch (e) {}
function saveState() {
try {
localStorage.setItem('kt_dash_seed', state.seed);
localStorage.setItem('kt_dash_offset', String(state.offset));
localStorage.setItem('kt_dash_selected', state.selected || '');
localStorage.setItem('kt_dash_search', state.search);
localStorage.setItem('kt_dash_sort', state.sort);
} catch (e) {}
}
// ---------- Данные ----------
var currentData = null;
function loadData() {
currentData = ktMonthData(state.offset);
}
// ---------- Связывание контролов ----------
function bindMonthSelect() {
var sel = document.getElementById('monthSelect');
if (!sel) return;
for (var i = 0; i <= 11; i++) {
var op = document.createElement('option');
op.value = String(i);
op.textContent = ktMonthLabel(i);
sel.appendChild(op);
}
sel.value = String(state.offset);
sel.addEventListener('change', function() {
state.offset = parseInt(sel.value, 10) || 0;
saveState();
render();
});
}
function bindRefresh() {
var btn = document.getElementById('refreshBtn');
if (!btn) return;
var label = btn.innerHTML;
btn.addEventListener('click', function() {
// новый сид -> показатели пересчитываются, дальше снова стабильны
state.seed = String(Date.now());
window.KT_SEED = state.seed;
saveState();
btn.disabled = true;
document.body.style.cursor = 'progress';
setTimeout(function() {
loadData();
render();
btn.disabled = false;
document.body.style.cursor = '';
}, 450);
});
}
function bindSearch() {
var inp = document.getElementById('searchInput');
var clear = document.getElementById('clearSearch');
if (inp) {
inp.value = state.search;
inp.addEventListener('input', function() {
state.search = inp.value;
saveState();
renderTable();
});
}
if (clear) {
clear.addEventListener('click', function() {
state.search = '';
if (inp) inp.value = '';
saveState();
renderTable();
});
}
}
function markSortButtons() {
var btns = document.querySelectorAll('.dash-sort .kt-ai-btn');
for (var i = 0; i < btns.length; i++) {
if (btns[i].getAttribute('data-sort') === state.sort) btns[i].classList.add('is-on');
else btns[i].classList.remove('is-on');
}
}
function bindSort() {
var btns = document.querySelectorAll('.dash-sort .kt-ai-btn');
for (var i = 0; i < btns.length; i++) {
btns[i].addEventListener('click', function() {
state.sort = this.getAttribute('data-sort');
saveState();
markSortButtons();
renderTable();
});
}
markSortButtons();
}
// ---------- Rендер: KPI ----------
function renderKpis() {
var k = currentData.kpi;
var rev = document.getElementById('kpiRevenue');
var plan = document.getElementById('kpiPlan');
var deals = document.getElementById('kpiDeals');
var leaders = document.getElementById('kpiLeaders');
if (rev) rev.textContent = ktFormatMoney(k.fact);
if (plan) plan.textContent = k.percent + '%';
if (deals) deals.textContent = String(k.deals);
if (leaders) leaders.textContent = String(k.leaders);
var rh = document.getElementById('kpiRevenueHint');
var ph = document.getElementById('kpiPlanHint');
var dh = document.getElementById('kpiDealsHint');
if (rh) rh.textContent = currentData.label;
if (ph) ph.textContent = ktFormatMillion(k.fact) + ' / ' + ktFormatMillion(k.plan);
if (dh) dh.textContent = currentData.label;
}
// ---------- Rендер: карта ----------
function buildMap() {
var mapEl = document.getElementById('kzMap');
if (!mapEl) return;
var ns = 'http://www.w3.org/2000/svg';
var frag = document.createDocumentFragment();
for (var i = 0; i < KT_REGIONS.length; i++) {
var r = KT_REGIONS[i];
var pts = KT_MAP[r.id];
if (!pts) continue;
var poly = document.createElementNS(ns, 'polygon');
var str = '';
for (var p = 0; p < pts.length; p++) str += pts[p][0] + ',' + pts[p][1] + ' ';
poly.setAttribute('points', str);
poly.setAttribute('class', 'dash-map-region');
poly.setAttribute('data-id', r.id);
poly.setAttribute('tabindex', '0');
poly.setAttribute('role', 'button');
poly.setAttribute('aria-label', r.name);
frag.appendChild(poly);
}
for (var k = 0; k < KT_REGIONS.length; k++) {
var rr = KT_REGIONS[k];
if (!rr.cx) continue;
var lab = document.createElementNS(ns, 'text');
lab.setAttribute('class', 'dash-map-label');
lab.setAttribute('x', String(rr.cx));
lab.setAttribute('y', String(rr.cy));
lab.textContent = rr.short;
frag.appendChild(lab);
}
mapEl.innerHTML = '';
mapEl.appendChild(frag);
var polys = mapEl.querySelectorAll('.dash-map-region');
for (var b = 0; b < polys.length; b++) {
(function(p) {
var id = p.getAttribute('data-id');
var doSel = function() {
state.selected = (state.selected === id) ? null : id;
saveState();
renderMap();
renderTable();
};
p.addEventListener('click', doSel);
p.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); doSel(); }
});
})(polys[b]);
}
}
function renderMap() {
var mapEl = document.getElementById('kzMap');
if (!mapEl) return;
var dataById = {};
for (var i = 0; i < currentData.rows.length; i++) {
dataById[currentData.rows[i].id] = currentData.rows[i];
}
var polys = mapEl.querySelectorAll('.dash-map-region');
for (var j = 0; j < polys.length; j++) {
var p = polys[j];
var id = p.getAttribute('data-id');
var d = dataById[id];
if (!d) continue;
p.setAttribute('data-tone', d.tone);
if (state.selected === id) p.setAttribute('data-selected', 'true');
else p.removeAttribute('data-selected');
var tip = JSON.stringify({
title: d.name + ' — ' + currentData.label,
rows: [
{ label: 'Факт', value: ktFormatMoney(d.fact) },
{ label: 'План', value: ktFormatMoney(d.plan) },
{ label: '% плана', value: d.percent + '%' }
]
}).replace(/"/g, '&quot;');
p.setAttribute('data-kt-tip', tip);
}
renderMapSelectedBlock();
}
function renderMapSelectedBlock() {
var box = document.getElementById('mapSelected');
if (!box) return;
if (!state.selected) {
box.innerHTML = 'Выберите регион на карте — его показатели подсветятся в списке, и график покажет его детализацию.';
return;
}
var d = null;
for (var i = 0; i < currentData.rows.length; i++) {
if (currentData.rows[i].id === state.selected) { d = currentData.rows[i]; break; }
}
if (!d) { state.selected = null; saveState(); renderMap(); return; }
var st = d.tone === 'ok' ? 'выполнен' : d.tone === 'warn' ? 'близко к плану' : 'ниже плана';
box.innerHTML =
'<strong>' + d.name + '</strong> — ' + d.percent + '% плана ' +
'(факт ' + ktFormatMoney(d.fact) + ', план ' + ktFormatMoney(d.plan) + '), ' +
d.deals + (d.deals === 1 ? ' сделка' : (d.deals < 5 ? ' сделки' : ' сделок')) +
', статус: ' + st;
}
// ---------- Rендер: график ----------
function renderChart() {
var box = document.getElementById('chartBars');
if (!box) return;
var rows;
var hintText;
if (state.selected) {
var one = [];
for (var s = 0; s < currentData.rows.length; s++) {
if (currentData.rows[s].id === state.selected) one.push(currentData.rows[s]);
}
// выбран регион: показываем его столбцы за последние 6 месяцев
var months = [];
for (var mm = 5; mm >= 0; mm--) {
months.push(ktRegionMonth(state.selected, mm));
}
rows = months;
hintText = one.length ? one[0].name + ' · последние 6 месяцев' : '';
} else {
rows = currentData.rows.slice(0, 8);
hintText = 'топ-8 регионов · ' + currentData.label;
}
if (rows.length === 0) { box.innerHTML = ''; return; }
var W = 520, H = 220, padL = 52, padR = 8, padT = 12, padB = 52;
var plotW = W - padL - padR;
var plotH = H - padT - padB;
var maxVal = 0;
for (var i = 0; i < rows.length; i++) if (rows[i].fact > maxVal) maxVal = rows[i].fact;
maxVal = Math.ceil(maxVal / 100) * 100;
if (maxVal < 100) maxVal = 100;
var slot = plotW / rows.length;
var bw = Math.min(26, Math.max(14, slot * 0.5));
var lines = ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '">'];
var steps = 4;
for (var g = 0; g <= steps; g++) {
var gy = (padT + plotH - (plotH * g / steps)).toFixed(1);
var gv = Math.round(maxVal * g / steps);
lines.push('<line class="dcc-grid" x1="' + padL + '" y1="' + gy + '" x2="' + (W - padR) + '" y2="' + gy + '"/>');
lines.push('<text class="dcc-axis" x="' + (padL - 6) + '" y="' + (gy + 3) + '" text-anchor="end">' +
(gv >= 1000 ? (Math.round(gv / 10) / 100).toFixed(1).replace('.', ',') + ' млрд' : gv + ' млн') +
'</text>');
}
for (var b = 0; b < rows.length; b++) {
var r = rows[b];
var bx = (padL + slot * b + (slot - bw) / 2).toFixed(1);
var bh = Math.max(2, plotH * (r.fact / maxVal));
var by = (padT + plotH - bh).toFixed(1);
var fill = r.tone === 'ok' ? 'var(--kt-ai-chart-green)'
: r.tone === 'warn' ? 'var(--kt-ai-chart-orange)'
: 'var(--kt-ai-chart-blue)';
var label = r.short || r.name;
var tipTitle = state.selected ? label + ' · ' + ktMonthLabel(rows.length - 1 - b) : label + ' · ' + currentData.label;
var tip = JSON.stringify({
title: tipTitle,
rows: [
{ label: 'Факт', value: ktFormatMoney(r.fact) },
{ label: 'План', value: ktFormatMoney(r.plan) },
{ label: '% плана', value: r.percent + '%' }
]
}).replace(/"/g, '&quot;');
lines.push('<rect class="dcc-bar" tabindex="0" data-kt-tip="' + tip + '" ' +
'x="' + bx + '" y="' + by + '" width="' + bw.toFixed(1) + '" height="' + bh.toFixed(1) + '" rx="2" fill="' + fill + '"/>');
var lx = (padL + slot * b + slot / 2).toFixed(1);
var ly = H - padB + 16;
lines.push('<text class="dcc-axis" x="' + lx + '" y="' + ly + '" text-anchor="end" transform="rotate(-32 ' + lx + ' ' + ly + ')">' +
(state.selected ? (KT_MONTHS[new Date().getMonth() - (rows.length - 1 - b)] || '').slice(0, 3) : label) + '</text>');
}
lines.push('</svg>');
box.innerHTML = lines.join('');
var hint = document.getElementById('chartHint');
if (hint) hint.textContent = hintText;
}
// ---------- Rендер: таблица ----------
function renderTable() {
var tbody = document.getElementById('regionsTable');
if (!tbody) return;
var countEl = document.getElementById('tableCount');
var empty = document.getElementById('tableEmpty');
var rows = currentData.rows.slice();
var q = state.search.trim().toLowerCase();
if (q) {
rows = rows.filter(function(r) {
return (r.name + ' ' + r.short).toLowerCase().indexOf(q) !== -1;
});
}
if (state.sort === 'name') rows.sort(function(a, b) { return a.name.localeCompare(b.name, 'ru'); });
else if (state.sort === 'percent') rows.sort(function(a, b) { return b.percent - a.percent; });
else rows.sort(function(a, b) { return b.fact - a.fact; });
if (rows.length === 0) {
tbody.innerHTML = '';
if (empty) empty.hidden = false;
if (countEl) countEl.textContent = '0 из ' + currentData.rows.length;
return;
}
if (empty) empty.hidden = true;
if (countEl) countEl.textContent = rows.length + ' из ' + currentData.rows.length;
var html = [];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
var stText = r.tone === 'ok' ? 'Выполнен' : r.tone === 'warn' ? 'Близко к плану' : 'Ниже плана';
var barW = Math.min(100, r.percent);
var selAttr = (state.selected === r.id) ? ' data-sel="1"' : '';
html.push(
'<tr' + selAttr + ' data-row="' + r.id + '" style="cursor:pointer">' +
'<td class="kt-ai-sticky-first">' + r.name + '</td>' +
'<td class="dash-num">' + ktFormatMoney(r.plan) + '</td>' +
'<td class="dash-num">' + ktFormatMoney(r.fact) + '</td>' +
'<td class="dash-num dash-pct">' + r.percent + '%' +
'<span class="dash-progress" aria-hidden="true"><i data-tone="' + r.tone + '" style="width:' + barW + '%"></i></span></td>' +
'<td class="dash-num">' + r.deals + '</td>' +
'<td><span class="kt-ai-status-pill" data-status="' + r.tone + '">' + stText + '</span></td>' +
'</tr>'
);
}
tbody.innerHTML = html.join('');
// Клик по строке — выбирает регион (синхронно с картой)
var trs = tbody.querySelectorAll('tr');
for (var t = 0; t < trs.length; t++) {
(function(tr) {
tr.addEventListener('click', function() {
var id = tr.getAttribute('data-row');
state.selected = (state.selected === id) ? null : id;
saveState();
renderMap();
renderChart();
renderTable();
});
})(trs[t]);
}
}
// Подсветка выбранной строки таблицы
function markSelectedRow() {
var trs = document.querySelectorAll('#regionsTable tr');
for (var i = 0; i < trs.length; i++) {
if (trs[i].getAttribute('data-row') === state.selected) {
trs[i].setAttribute('data-active', 'true');
} else {
trs[i].removeAttribute('data-active');
}
}
}
// ---------- Rендер всего ----------
function render() {
renderKpis();
renderMap();
renderChart();
renderTable();
}
// ---------- Старт ----------
function init() {
bindMonthSelect();
bindRefresh();
bindSearch();
bindSort();
buildMap();
loadData();
render();
}
document.addEventListener('DOMContentLoaded', init);

315
style.css Normal file
View File

@ -0,0 +1,315 @@
/* ============================================================
Дашборд продаж — только компоновка.
Цвета, шрифты и размеры — токены KT AI (kt-ai-tokens.css).
============================================================ */
/* ---------- каркас: sidebar + рабочая область ---------- */
.dash-shell {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
min-height: 100vh;
}
.dash-sidebar {
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
padding: var(--kt-ai-space-5) var(--kt-ai-space-4);
border-right: 1px solid var(--kt-ai-border);
background: var(--kt-ai-bg-soft);
}
/* Липкая первая колонка таблицей — фон под строкой нужен,
иначе при горизонтальном скролле цифры «плывут» под ней */
.kt-ai-table td.kt-ai-sticky-first,
.kt-ai-table th.kt-ai-sticky-first {
background: var(--kt-ai-card-bg);
}
.dash-brand-name {
display: block;
margin-top: var(--kt-ai-space-2);
font-size: var(--kt-ai-text-xs);
line-height: 1.35;
color: var(--kt-ai-fg-muted);
letter-spacing: .02em;
}
.dash-sidebar-note {
margin-top: auto;
padding-top: var(--kt-ai-space-4);
border-top: 1px solid var(--kt-ai-divider);
font-size: var(--kt-ai-text-2xs);
color: var(--kt-ai-fg-faint);
}
.dash-main {
min-width: 0;
padding: var(--kt-ai-space-7) var(--kt-ai-space-8) var(--kt-ai-space-12);
}
/* ---------- шапка ---------- */
.dash-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--kt-ai-space-6);
flex-wrap: wrap;
margin-bottom: var(--kt-ai-space-6);
}
.dash-crumb {
display: block;
font-size: var(--kt-ai-text-2xs);
letter-spacing: .06em;
text-transform: uppercase;
color: var(--kt-ai-fg-faint);
}
.dash-title {
margin: 2px 0 0;
font-size: var(--kt-ai-text-2xl);
font-weight: var(--kt-ai-weight-bold);
letter-spacing: -.02em;
}
.dash-topbar-actions {
display: flex;
align-items: center;
gap: var(--kt-ai-space-3);
flex-wrap: wrap;
}
/* ---------- KPI-полоса (сетка из 4 карточек) ---------- */
.kt-ai-kpi-strip.dash-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--kt-ai-space-4);
padding: 0 0 var(--kt-ai-space-7);
}
.kt-ai-kpi-strip.dash-kpis .kt-ai-kpi {
padding: var(--kt-ai-space-5);
background: var(--kt-ai-card-bg);
border: 1px solid var(--kt-ai-card-border);
border-radius: var(--kt-ai-radius-xl);
}
/* ---------- сетка: карта + график ---------- */
.dash-grid {
display: grid;
grid-template-columns: minmax(0, 7fr) minmax(0, 5fr);
gap: var(--kt-ai-space-5);
align-items: stretch;
margin-bottom: var(--kt-ai-space-5);
}
.dash-panel {
min-width: 0;
padding: var(--kt-ai-space-6);
background: var(--kt-ai-card-bg);
border: 1px solid var(--kt-ai-card-border);
border-radius: var(--kt-ai-radius-xl);
}
.dash-panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--kt-ai-space-4);
flex-wrap: wrap;
margin-bottom: var(--kt-ai-space-5);
}
.dash-panel-head h2 {
margin: 0;
font-size: var(--kt-ai-text-md);
font-weight: var(--kt-ai-weight-semibold);
}
.dash-panel-hint {
font-size: var(--kt-ai-text-2xs);
color: var(--kt-ai-fg-muted);
}
/* ---------- карта ---------- */
.dash-map-wrap svg {
display: block;
width: 100%;
height: auto;
max-height: 430px;
}
.dash-map-region {
cursor: pointer;
stroke: var(--kt-ai-card-bg);
stroke-width: 2;
transition: opacity .15s ease;
}
.dash-map-region:hover,
.dash-map-region:focus-visible { opacity: .82; outline: none; }
.dash-map-region[data-selected="true"] {
stroke: var(--kt-ai-fg);
stroke-width: 2.5;
}
.dash-map-region[data-tone="ok"] { fill: color-mix(in srgb, var(--kt-ai-chart-green) 34%, var(--kt-ai-bg)); }
.dash-map-region[data-tone="warn"] { fill: color-mix(in srgb, var(--kt-ai-chart-orange) 38%, var(--kt-ai-bg)); }
.dash-map-region[data-tone="risk"] { fill: color-mix(in srgb, var(--kt-ai-chart-red) 38%, var(--kt-ai-bg)); }
.dash-map-label {
font-size: 11px;
font-weight: var(--kt-ai-weight-semibold);
fill: var(--kt-ai-fg);
pointer-events: none;
user-select: none;
text-anchor: middle;
}
.dash-legend {
display: flex;
gap: var(--kt-ai-space-4);
flex-wrap: wrap;
}
.dash-legend-item {
display: inline-flex;
align-items: center;
gap: var(--kt-ai-space-2);
font-size: var(--kt-ai-text-2xs);
color: var(--kt-ai-fg-muted);
}
.dash-legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: var(--kt-ai-radius-full);
}
.dash-legend-dot[data-tone="ok"] { background: color-mix(in srgb, var(--kt-ai-chart-green) 55%, var(--kt-ai-bg)); }
.dash-legend-dot[data-tone="warn"] { background: color-mix(in srgb, var(--kt-ai-chart-orange) 55%, var(--kt-ai-bg)); }
.dash-legend-dot[data-tone="risk"] { background: color-mix(in srgb, var(--kt-ai-chart-red) 55%, var(--kt-ai-bg)); }
.dash-map-selected {
margin-top: var(--kt-ai-space-5);
padding: var(--kt-ai-space-4) var(--kt-ai-space-5);
border: 1px solid var(--kt-ai-divider);
border-radius: var(--kt-ai-radius-lg);
background: var(--kt-ai-bg-soft);
font-size: var(--kt-ai-text-sm);
color: var(--kt-ai-fg-muted);
}
.dash-map-selected strong {
color: var(--kt-ai-fg);
font-weight: var(--kt-ai-weight-semibold);
}
/* ---------- график: чистый SVG ---------- */
.dash-chart {
position: relative;
width: 100%;
height: 220px;
}
.dash-chart svg {
display: block;
width: 100%;
height: 100%;
}
.dcc-bar { cursor: pointer; transition: opacity .15s ease; }
.dcc-bar:hover, .dcc-bar:focus-visible { opacity: .8; outline: none; }
.dcc-grid { stroke: var(--kt-ai-divider); stroke-dasharray: 3 4; }
.dcc-axis { font-size: 10px; fill: var(--kt-ai-fg-faint); font-family: var(--kt-ai-font-mono); }
/* ---------- таблица ---------- */
.dash-search { max-width: 240px; }
.dash-sort {
display: flex;
gap: var(--kt-ai-space-2);
flex-wrap: wrap;
}
.dash-sort .is-on {
background: var(--kt-ai-primary-subtle);
border-color: var(--kt-ai-primary);
color: var(--kt-ai-primary);
font-weight: var(--kt-ai-weight-semibold);
}
.kt-ai-table .dash-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.kt-ai-table td.dash-pct {
font-weight: var(--kt-ai-weight-semibold);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* выбранная строка (клик по карте или по строке) */
.kt-ai-table tr[data-sel="1"] td,
.kt-ai-table tr[data-active="true"] td {
background: var(--kt-ai-primary-subtle);
}
.kt-ai-table tbody tr:hover td {
background: var(--kt-ai-bg-hover);
}
.kt-ai-table tbody tr[data-sel="1"]:hover td,
.kt-ai-table tbody tr[data-active="true"]:hover td {
background: var(--kt-ai-primary-subtle);
}
.dash-progress {
display: inline-block;
vertical-align: middle;
width: 56px;
height: 6px;
margin-left: var(--kt-ai-space-3);
border-radius: var(--kt-ai-radius-full);
background: var(--kt-ai-bg-sunken);
overflow: hidden;
}
.dash-progress i {
display: block;
height: 100%;
border-radius: var(--kt-ai-radius-full);
}
.dash-progress i[data-tone="ok"] { background: var(--kt-ai-chart-green); }
.dash-progress i[data-tone="warn"] { background: var(--kt-ai-chart-orange); }
.dash-progress i[data-tone="risk"] { background: var(--kt-ai-chart-red); }
/* ---------- адаптив ---------- */
@media (max-width: 1100px) {
.dash-grid { grid-template-columns: 1fr; }
.kt-ai-kpi-strip.dash-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 860px) {
.dash-shell { grid-template-columns: 1fr; }
.dash-sidebar { display: none; }
.dash-main { padding: var(--kt-ai-space-5) var(--kt-ai-space-4) var(--kt-ai-space-10); }
}
@media (max-width: 640px) {
.dash-topbar { flex-direction: column; align-items: stretch; }
.dash-topbar-actions { justify-content: space-between; }
.dash-topbar-actions .kt-ai-btn { flex: 1; }
.kt-ai-select { flex: 1; }
.dash-search { width: 100%; max-width: none; }
.kt-ai-kpi-strip.dash-kpis { grid-template-columns: 1fr; }
.dash-panel { padding: var(--kt-ai-space-4); }
}