153 lines
14 KiB
HTML
153 lines
14 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Заказы CRM — дашборд</title>
|
||
<link rel="icon" href="design-system/brand/kt-ai-logo.png">
|
||
<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">
|
||
<style>
|
||
/* Компоновка дашборда: только раскладка, цвета и размеры — токены ДС */
|
||
.kt-ai-shell { min-height: 100vh; }
|
||
.kt-ai-sidebar { display: flex; flex-direction: column; gap: 2px; padding: var(--kt-ai-space-5) var(--kt-ai-space-3); }
|
||
.kt-ai-sidebar .kt-ai-brand-row { padding: var(--kt-ai-space-2) var(--kt-ai-space-3) var(--kt-ai-space-5); }
|
||
.kt-ai-sidebar .sb-bottom { margin-top: auto; padding: var(--kt-ai-space-3); font-size: var(--kt-ai-text-2xs); color: var(--kt-ai-fg-muted); line-height: 1.4; }
|
||
.kt-ai-main { padding: 0 var(--kt-ai-space-8) var(--kt-ai-space-10); }
|
||
.kt-ai-topbar { display: flex; align-items: center; gap: var(--kt-ai-space-4); padding: var(--kt-ai-space-4) 0; border-bottom: 1px solid var(--kt-ai-divider); }
|
||
.kt-ai-topbar h1 { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); margin: 0; }
|
||
.kt-ai-topbar .spacer { flex: 1; }
|
||
.kt-ai-segmented { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-full); background: var(--kt-ai-bg-soft); }
|
||
.kt-ai-segmented .seg { border: 0; background: transparent; color: var(--kt-ai-fg-muted); padding: 4px 12px; border-radius: var(--kt-ai-radius-full); font: inherit; font-size: var(--kt-ai-text-2xs); cursor: pointer; }
|
||
.kt-ai-segmented .seg[data-active="true"] { background: var(--kt-ai-bg-elevated); color: var(--kt-ai-fg); box-shadow: var(--kt-ai-shadow-sm); }
|
||
.grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--kt-ai-space-5); margin-top: var(--kt-ai-space-2); }
|
||
.grid > * { grid-column: span 6; min-width: 0; }
|
||
.grid > .span-12 { grid-column: span 12; } .grid > .span-4 { grid-column: span 4; }
|
||
@media (max-width: 1100px) { .grid > *, .grid > .span-4 { grid-column: span 12; } }
|
||
.kt-ai-chart-card { padding: var(--kt-ai-space-5) var(--kt-ai-space-6); }
|
||
.cc-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-4); }
|
||
.cc-title { font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-semibold); }
|
||
.cc-total { font-size: var(--kt-ai-text-2xs); color: var(--kt-ai-fg-muted); }
|
||
.bars { display: flex; align-items: flex-end; gap: 6px; height: 160px; }
|
||
.bars .b { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 0; }
|
||
.bars .b i { display: block; width: 100%; border-radius: var(--kt-ai-radius-sm) var(--kt-ai-radius-sm) 0 0; background: var(--kt-ai-chart-bar-fill, var(--kt-ai-primary)); }
|
||
.bars .b span { font-size: 10px; color: var(--kt-ai-fg-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||
.bars .b b { font-size: var(--kt-ai-text-2xs); font-weight: var(--kt-ai-weight-medium); }
|
||
.hbars { display: flex; flex-direction: column; gap: 8px; }
|
||
.hbars .r { display: grid; grid-template-columns: 160px 1fr 56px; align-items: center; gap: var(--kt-ai-space-3); font-size: var(--kt-ai-text-2xs); }
|
||
.hbars .r .k { color: var(--kt-ai-fg-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.hbars .r .t { height: 8px; background: var(--kt-ai-bg-soft); border-radius: var(--kt-ai-radius-full); overflow: hidden; }
|
||
.hbars .r .t i { display: block; height: 100%; background: var(--kt-ai-chart-bar-fill, var(--kt-ai-primary)); border-radius: inherit; }
|
||
.hbars .r .v { text-align: right; font-variant-numeric: tabular-nums; }
|
||
.funnel { display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--kt-ai-space-3); }
|
||
.funnel .st { padding: var(--kt-ai-space-3); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-lg); background: var(--kt-ai-bg-soft); }
|
||
.funnel .st .n { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); font-variant-numeric: tabular-nums; }
|
||
.funnel .st .l { font-size: var(--kt-ai-text-2xs); color: var(--kt-ai-fg-muted); }
|
||
.funnel .st .p { font-size: var(--kt-ai-text-2xs); color: var(--kt-ai-fg-muted); margin-top: 2px; }
|
||
@media (max-width: 900px) { .funnel { grid-template-columns: repeat(3, 1fr); } }
|
||
.src { display: inline-flex; align-items: center; gap: 6px; font-size: var(--kt-ai-text-2xs); padding: 3px 10px; border-radius: var(--kt-ai-radius-full); border: 1px solid var(--kt-ai-border); color: var(--kt-ai-fg-muted); }
|
||
.src[data-live="true"] { color: var(--kt-ai-chart-green, var(--kt-ai-fg)); border-color: var(--kt-ai-chart-green, var(--kt-ai-border)); }
|
||
.src .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||
.kt-ai-empty { padding: var(--kt-ai-space-8); text-align: center; color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm); }
|
||
</style>
|
||
</head>
|
||
<body class="kt-ai-app">
|
||
<div class="kt-ai-shell">
|
||
<aside class="kt-ai-sidebar">
|
||
<div class="kt-ai-brand-row"><img src="design-system/brand/kazakhtelecom-logo.png" alt="Казахтелеком"><span>Заказы CRM</span></div>
|
||
<nav class="kt-ai-nav-group" aria-label="Разделы">
|
||
<a class="kt-ai-nav-item" data-active="true" href="#overview"><svg class="kt-icon"><use href="#dashboard"></use></svg><span>Обзор</span></a>
|
||
<a class="kt-ai-nav-item" href="#filials"><svg class="kt-icon"><use href="#building"></use></svg><span>Филиалы и города</span></a>
|
||
<a class="kt-ai-nav-item" href="#offers"><svg class="kt-icon"><use href="#package"></use></svg><span>Продукты</span></a>
|
||
<a class="kt-ai-nav-item" href="#rejects"><svg class="kt-icon"><use href="#alert"></use></svg><span>Отказы</span></a>
|
||
</nav>
|
||
<div class="sb-bottom">Источник: таблица <code>crm_order</code>, база <code>orders</code>. Только агрегаты, без персональных данных.</div>
|
||
</aside>
|
||
<main class="kt-ai-main">
|
||
<header class="kt-ai-topbar">
|
||
<h1 id="overview">Заказы CRM</h1>
|
||
<span class="src" id="src"><span class="dot"></span><span id="srcText">загрузка…</span></span>
|
||
<div class="spacer"></div>
|
||
<div class="kt-ai-segmented" id="range">
|
||
<button class="seg" data-days="30">30 дней</button>
|
||
<button class="seg" data-days="90" data-active="true">90 дней</button>
|
||
<button class="seg" data-days="180">180 дней</button>
|
||
</div>
|
||
<button class="kt-ai-icon-button" id="reload" title="Обновить"><svg class="kt-icon"><use href="#refresh"></use></svg></button>
|
||
</header>
|
||
|
||
<div class="kt-ai-kpi-strip" data-cards="true" id="kpi"></div>
|
||
|
||
<div class="grid">
|
||
<section class="kt-ai-chart-card span-12"><div class="cc-head"><div class="cc-title">Создано заказов по месяцам</div><div class="cc-total" id="tMonth"></div></div><div class="bars" id="cMonth"></div></section>
|
||
<section class="kt-ai-chart-card span-12"><div class="cc-head"><div class="cc-title">Воронка: от заявки до завершения</div><div class="cc-total">доля от созданных</div></div><div class="funnel" id="cFunnel"></div></section>
|
||
<section class="kt-ai-chart-card" id="filials"><div class="cc-head"><div class="cc-title">По филиалам</div><div class="cc-total" id="tFilial"></div></div><div class="hbars" id="cFilial"></div></section>
|
||
<section class="kt-ai-chart-card"><div class="cc-head"><div class="cc-title">По каналам продаж</div><div class="cc-total" id="tChannel"></div></div><div class="hbars" id="cChannel"></div></section>
|
||
<section class="kt-ai-chart-card" id="offers"><div class="cc-head"><div class="cc-title">Топ продуктовых предложений</div><div class="cc-total" id="tOffers"></div></div><div class="kt-ai-table-wrap"><table class="kt-ai-table"><thead><tr><th>Предложение</th><th style="text-align:right">Заказов</th><th style="text-align:right">Доля</th></tr></thead><tbody id="tblOffers"></tbody></table></div></section>
|
||
<section class="kt-ai-chart-card" id="rejects"><div class="cc-head"><div class="cc-title">Причины отказов и оттока</div><div class="cc-total" id="tRejects"></div></div><div class="hbars" id="cRejects"></div></section>
|
||
<section class="kt-ai-chart-card span-12"><div class="cc-head"><div class="cc-title">Города</div><div class="cc-total" id="tTown"></div></div><div class="bars" id="cTown"></div></section>
|
||
</div>
|
||
|
||
<section class="kt-ai-conclusion" id="conclusion"></section>
|
||
</main>
|
||
</div>
|
||
<script src="design-system/icons/kt-ai-icons.js"></script>
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const fmt = (n) => (n == null ? "—" : Number(n).toLocaleString("ru-RU"));
|
||
const pct = (a, b) => (b ? Math.round((a / b) * 100) + "%" : "—");
|
||
const MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
|
||
let days = 90;
|
||
|
||
function bars(el, rows, labelFn) {
|
||
const max = Math.max(1, ...rows.map((r) => Number(r[1]) || 0));
|
||
el.innerHTML = rows.length ? rows.map((r) => `<div class="b"><b>${fmt(r[1])}</b><i style="height:${Math.max(3, Math.round((Number(r[1]) / max) * 120))}px"></i><span title="${r[0]}">${labelFn ? labelFn(r[0]) : r[0]}</span></div>`).join("") : '<div class="kt-ai-empty">Нет данных за период</div>';
|
||
}
|
||
function hbars(el, rows) {
|
||
const max = Math.max(1, ...rows.map((r) => Number(r[1]) || 0));
|
||
el.innerHTML = rows.length ? rows.map((r) => `<div class="r"><span class="k" title="${r[0]}">${r[0] || "—"}</span><span class="t"><i style="width:${Math.round((Number(r[1]) / max) * 100)}%"></i></span><span class="v">${fmt(r[1])}</span></div>`).join("") : '<div class="kt-ai-empty">Нет данных за период</div>';
|
||
}
|
||
function render(d) {
|
||
const live = d.source === "clickhouse";
|
||
$("src").dataset.live = live;
|
||
$("srcText").textContent = live ? `живые данные · ${d.database} · ${d.from} — ${d.to}` : `демо-данные · база пока недоступна`;
|
||
$("src").title = live ? "Запросы к crm_order через платформу" : (d.reason || "");
|
||
const k = d.kpi && d.kpi[0] ? d.kpi[0] : [0, 0, 0, 0];
|
||
const [orders, closed, rejected, installed] = k.map(Number);
|
||
$("kpi").innerHTML = [
|
||
{ v: fmt(orders), l: "Создано заказов", h: `${d.from} — ${d.to}` },
|
||
{ v: fmt(installed), l: "Инсталляция выполнена", h: pct(installed, orders) + " от созданных" },
|
||
{ v: fmt(closed), l: "Процесс завершён", h: pct(closed, orders) + " от созданных" },
|
||
{ v: fmt(rejected), l: "Отказы и отток", h: pct(rejected, orders) + " от созданных" },
|
||
].map((x) => `<div class="kt-ai-kpi"><span class="value">${x.v}</span><span class="label">${x.l}</span><span class="hint">${x.h}</span></div>`).join("");
|
||
bars($("cMonth"), d.byMonth || [], (m) => { const dt = new Date(m); return isNaN(dt) ? m : MONTHS[dt.getMonth()] + " " + String(dt.getFullYear()).slice(2); });
|
||
$("tMonth").textContent = `${fmt(orders)} за период`;
|
||
const f = d.funnel && d.funnel[0] ? d.funnel[0].map(Number) : [];
|
||
const steps = ["Создано", "Запрос техвозможности", "ТВ определена", "Направлено на инсталляцию", "Инсталляция выполнена", "Процесс завершён"];
|
||
$("cFunnel").innerHTML = steps.map((s, i) => `<div class="st"><div class="n">${fmt(f[i])}</div><div class="l">${s}</div><div class="p">${i ? pct(f[i], f[0]) : "100%"}</div></div>`).join("");
|
||
hbars($("cFilial"), d.byFilial || []); $("tFilial").textContent = `${(d.byFilial || []).length} филиалов`;
|
||
hbars($("cChannel"), d.byChannel || []); $("tChannel").textContent = `${(d.byChannel || []).length} каналов`;
|
||
const offers = d.topOffers || []; const sumO = offers.reduce((s, r) => s + Number(r[1]), 0);
|
||
$("tblOffers").innerHTML = offers.length ? offers.map((r) => `<tr><td>${r[0]}</td><td style="text-align:right">${fmt(r[1])}</td><td style="text-align:right">${pct(Number(r[1]), sumO)}</td></tr>`).join("") : '<tr><td colspan="3" class="kt-ai-empty">Нет данных</td></tr>';
|
||
$("tOffers").textContent = `${offers.length} позиций`;
|
||
hbars($("cRejects"), d.rejects || []); $("tRejects").textContent = `${fmt(rejected)} всего`;
|
||
bars($("cTown"), d.byTown || []); $("tTown").textContent = `${(d.byTown || []).length} городов`;
|
||
const topF = (d.byFilial || [])[0], topR = (d.rejects || [])[0];
|
||
$("conclusion").innerHTML = `<span class="title">Вывод</span><p class="text">За период создано ${fmt(orders)} заказов, до выполненной инсталляции дошло ${pct(installed, orders)}, отказов ${pct(rejected, orders)}.${topF ? ` Больше всего заказов в филиале «${topF[0]}» (${fmt(topF[1])}).` : ""}${topR ? ` Главная причина отказов — «${topR[0]}».` : ""}${live ? "" : " Показаны демо-данные: подключите ClickHouse в «Интеграциях», и цифры станут настоящими."}</p>`;
|
||
}
|
||
async function load() {
|
||
const to = new Date(); const from = new Date(Date.now() - (days - 1) * 864e5);
|
||
const iso = (x) => x.toISOString().slice(0, 10);
|
||
$("srcText").textContent = "загрузка…";
|
||
try { const r = await fetch(`api/summary?from=${iso(from)}&to=${iso(to)}`); render(await r.json()); }
|
||
catch (e) { $("srcText").textContent = "ошибка загрузки: " + e.message; }
|
||
}
|
||
$("range").addEventListener("click", (e) => { const b = e.target.closest(".seg"); if (!b) return; days = Number(b.dataset.days); [...$("range").children].forEach((x) => x.dataset.active = x === b); load(); });
|
||
$("reload").addEventListener("click", load);
|
||
document.querySelectorAll(".kt-ai-nav-item").forEach((a) => a.addEventListener("click", () => document.querySelectorAll(".kt-ai-nav-item").forEach((x) => x.dataset.active = x === a)));
|
||
load();
|
||
</script>
|
||
</body>
|
||
</html>
|