dashbord-po-zakazam-crm-crm/index.html

153 lines
14 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>