Опубликовано через кнопку
This commit is contained in:
parent
cd37571b0c
commit
ad2749328d
71
brief.md
71
brief.md
@ -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
181
data-kt.js
Normal 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) + ' млн ₸';
|
||||
}
|
||||
411
index.html
411
index.html
@ -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>
|
||||
Обновить
|
||||
</button>
|
||||
</div>
|
||||
</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-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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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="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>
|
||||
|
||||
<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>
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<div class="chart-wrap">
|
||||
<canvas id="salesChart" height="80"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<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>Статус</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="regionsTable"></tbody>
|
||||
</table>
|
||||
</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>
|
||||
<section class="dash-panel" aria-label="Регионы">
|
||||
<div class="dash-panel-head">
|
||||
<h2>Регионы</h2>
|
||||
<span class="dash-panel-hint" id="tableCount"></span>
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
<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
433
script.js
Normal 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, '"');
|
||||
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, '"');
|
||||
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
315
style.css
Normal 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); }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user