311 lines
12 KiB
HTML
311 lines
12 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-theme="light">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<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>
|
||
</head>
|
||
<body>
|
||
|
||
<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>
|
||
<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>
|
||
</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>
|
||
<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>
|
||
|
||
</body>
|
||
</html>
|