svodnyy-finansovyy-analiz-do/script.js

585 lines
48 KiB
JavaScript
Raw 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.

/* Сводный финансовый анализ дочерних компаний — статичный макет, состояние в localStorage */
(function () {
"use strict";
var LS = "sfa-do-v1";
function loadState() {
var base = { screen: "upload", fileStatus: {}, fileManual: false, mappingMode: "auto", unitFixed: [], deviations: [], aiAccepted: false, sent: false, analysis: {}, done: { recognize: true, check: false, analysis: false, result: false }, theme: "light" };
try {
var raw = localStorage.getItem(LS);
if (raw) { var s = JSON.parse(raw); for (var k in base) if (s && k in s) base[k] = s[k]; }
} catch (e) { if (!localStorage.getItem(LS)) base.resetFlag = true; }
return base;
}
function save() { try { localStorage.setItem(LS, JSON.stringify(S)); } catch (e) {} }
var S = loadState();
var COMPANIES = [
["ООО А", 12500, 11800, 3100, 3050, 420, 510, 640, 590, 1840, 2110, 1240, 1215],
["ООО Б", 8200, 7950, 2100, 1980, 280, 310, 410, 390, 1220, 1150, 860, 842],
["ООО В", 15400, 13100, 4100, 3480, 530, 470, 880, 720, 2450, 2610, 1580, 1595],
["ООО Г", 9800, 10050, 2500, 2560, 340, 355, 520, 480, 1480, 1390, 1010, 998],
["ООО Д", 11300, 10900, 2850, 2700, 390, 430, 610, 585, 1620, 1705, 1130, 1128],
["ООО Е", 7600, 7420, 1900, 1830, 250, 270, 380, 350, 1090, 1160, 780, 775],
["ООО Ж", 13900, 14350, 3600, 3720, 480, 455, 760, 730, 2180, 2040, 1420, 1408],
["ООО З", 6700, 6510, 1680, 1610, 220, 240, 330, 300, 960, 1015, 690, 682],
["ООО И", 10400, 9980, 2640, 2490, 360, 385, 570, 545, 1490, 1565, 1040, 1035],
["ООО К", 8900, 8730, 2280, 2210, 300, 325, 450, 420, 1270, 1335, 915, 908],
["ООО Л", 12100, 12490, 3200, 3310, 430, 415, 690, 660, 1740, 1650, 1265, 1258],
["ООО М", 7100, 6890, 1780, 1720, 260, 280, 400, 365, 1030, 1085, 745, 740],
["ООО Н", 14600, 13850, 3850, 3640, 505, 525, 830, 790, 2320, 2415, 1495, 1488],
["ООО О", 9300, 8910, 2380, 2240, 330, 350, 510, 480, 1360, 1425, 965, 958],
["ООО П", 11700, 11980, 3000, 3080, 405, 395, 640, 615, 1580, 1495, 1175, 1168],
["ООО Р", 8400, 8160, 2150, 2060, 290, 310, 430, 400, 1180, 1240, 835, 830],
["ООО С", 13200, 12840, 3400, 3280, 460, 485, 710, 680, 1950, 2020, 1355, 1348]
];
var CO = COMPANIES.map(function (r) { return { name: r[0], vp: r[1], vf: r[2], ep: r[3], ef: r[4], cap: r[5], caf: r[6], dp: r[7], df: r[8], cp: r[9], cf: r[10], hp: r[11], hf: r[12] }; });
function dev(o, f, v) { return v > 0 ? Math.round(Math.abs(f - v) / v * 1000) / 10 : 0; }
CO.forEach(function (c) {
c.rDev = dev(c, c.vf, c.vp); c.eDev = dev(c, c.ef, c.ep); c.cDev = dev(c, c.caf, c.cap);
c.dDev = dev(c, c.df, c.dp); c.kDev = dev(c, c.cf, c.cp); c.maxDev = Math.max(c.rDev, c.eDev, c.cDev, c.dDev, c.kDev);
});
var FILES = [
["Отчет_ОАОА_июнь.xlsx", 0], ["Fin_report_ООО-Б_0626.xlsx", 1], ["Свод_В_план_факт.xlsx", 2],
["Отчет_Г_июнь_2026.xlsx", 3], ["Fin_Д_06-2026.xlsx", 4], ["Финансы_Е_июнь.xlsx", 5],
["Отчет_Ж.xlsx", 6], ["Годовой_отчет_З.xlsx", 7], ["Данные_И_июнь.xlsx", 8],
["Report_K_2026-06.xlsx", 9], ["Свод_Л_июнь.xlsx", 10], ["Fin_М_06.xlsx", 11],
["Отчет_Н_июнь_2026.xlsx", 12], ["Data_O_2026.06.xlsx", 13], ["Отчет_П.xlsx", 14],
["Фин_Р_июнь.xlsx", 15], ["Summary_S_06.2026.xlsx", 16]
];
var MAPPING = [
["Выручка", { 0: "Раздел_1_Оборот", 1: "Выручка_в_тыс", 2: "Выручка", 3: "ОБорот_за_отчетный_период", 4: "Revenue", 5: "ОП_Выручка", 6: "Выручка(нетто)", 7: "Выручка год", 8: "ОП-1 Выручка", 9: "REV_06", 10: "Выручка", 11: "Rev", 12: "Выручка", 13: "Sales", 14: "О_п_факт", 15: "Выручка_итоги", 16: "Revenue_06" }],
["EBITDA", { 0: "EBITDA", 1: "Прибыль_до_аморт", 2: "EBITDA_план", 3: "EBITDA факт", 4: "EBITDA", 5: "EBITDA_итого", 6: "Прибыль_EBITDA", 7: "EBITDA_год", 8: "EBITDA факт", 9: "EBITDA_06", 10: "EBITDA", 11: "eBITDA", 12: "EBITDA факт", 13: "EBITDA", 14: "EBITDA_факт", 15: "EBITDA", 16: "EBITDA_06" }],
["CAPEX", { 0: "Инвест_затраты", 1: "Капзатраты_тыс", 2: "CAPEX", 3: "Инвестпроект_затраты", 4: "Capex", 5: "CAPEX_итоги", 6: "Инвест.затраты", 7: "CAPEX_год", 8: "CAPEX факт", 9: "CAPEX_06", 10: "CAPEX", 11: "capex", 12: "CAPEX факт", 13: "CAPEX", 14: "ИЗ_факт", 15: "Капзатраты", 16: "Capex_06" }],
["Дебиторка", { 0: "Дебиторка_итог", 1: "ДК_в_сальдо", 2: "Дебиторская", 3: "Д-ка_на_конец_мес", 4: "AR", 5: "Дебиторка_кон", 6: "ДК(кон.ост.)", 7: "Дебиторка_год", 8: "ДК факт", 9: "AR_06", 10: "Дебиторская", 11: "AR", 12: "ДК к/мес", 13: "AR", 14: "ДК_факт", 15: "Дебиторка_итого", 16: "AR_06" }],
["Кредиторка", { 0: "Кредиторка_итог", 1: "КК_в_сальдо", 2: "Кредиторская", 3: "К-ка_на_конец_мес", 4: "AP", 5: "Кредиторка_кон", 6: "КК(кон.ост.)", 7: "Кредиторка_год", 8: "КК факт", 9: "AP_06", 10: "Кредиторская", 11: "AP", 12: "КК к/мес", 13: "AP", 14: "КК_факт", 15: "Кредиторка_итого", 16: "AP_06" }],
["Численность", { 0: "Штат_ФТ", 1: "Числ._по_окладу", 2: "Численность", 3: "Штатное_число", 4: "Heads", 5: "Численность_кон", 6: "ФТ_кон.оот.", 7: "Численность_год", 8: "Штат факт", 9: "HC_06", 10: "Численность", 11: "FTE", 12: "Штат к/мес", 13: "HC", 14: "Числ_факт", 15: "Численность_итого", 16: "Headcount_06" }]
];
var LOGS = [
["10:02:14", "ok", "Получено 17 вложений из общего ящика Outlook"],
["10:02:15", "info", "Форматы: 17 Excel, 3 нестандартных шаблона"],
["10:02:18", "ok", "ООО А · сопоставлено 6 столбцов (шаблон 2024)"],
["10:02:21", "warn", "ООО Б · «Выручка_в_тыс» → «Выручка», множитель 1/1000"],
["10:02:23", "ok", "ООО В..И · автоматическое сопоставление (6/6)"],
["10:02:27", "warn", "ООО О · пустые строки в файле, пропущены"],
["10:02:29", "ok", "ООО Л..С · автоматическое сопоставление (6/6)"],
["10:02:31", "info", "1 расхождение единиц измерения, отклонения свыше 5% — 3 компании"]
];
var ANALYSIS = {
0: "Выручка 11 800 млн тг — 94% плана (−6%). EBITDA фактически выше плана (101%), операционная эффективность сохранена: CAPEX 510 млн тг при плане 420. Дебиторка +8%, кредиторка +3% — оборотный капитал в пределах нормы. Численность 1215 чел. при плане 1240.",
1: "Выручка 7 950 млн тг — 97% плана. Обнаружено расхождение единиц: в файле выручка в тысячах тенге, при переносе применён множитель 1/1000. EBITDA 94% плана, отклонение в пределах допустимого. Численность 842 чел.",
2: "Выручка 13 100 млн тг — −15% к плану, крупнейшее отклонение в группе. EBITDA 3 480 млн тг при плане 4 100 (−15%), доля в выручке снизилась с 27% до 27%. Дебиторка 1 720 при 880 — рост 96% требует разбора. Кредиторка 2 610 (96% плана).",
3: "Выручка 10 050 млн тг — 103% плана, опережение. EBITDA 102% плана. CAPEX 355 (104%). Дебиторка 480 (92% плана). Компенсация плановых отставок, численность 998 чел. при плане 1 010.",
4: "Выручка 10 900 млн тг — 96% плана. EBITDA 2 700 при 2 850 (95%). CAPEX 430 (110% плана) — опережение инвестиционной программы. Численность 1 128 чел.",
5: "Выручка 7 420 млн тг — 98% плана. EBITDA 1 830 (96%). Отклонения в пределах 5% по всем статьям. Численность 775 чел.",
6: "Выручка 14 350 млн тг — 103% плана, опережение. EBITDA 3 720 (103%). Дебиторка 730 (96% плана). Рост органический, численность 1 408 чел.",
7: "Выручка 6 510 млн тг — 97% плана. EBITDA 1 610 (96%). Отклонений свыше 5% нет. Численность 682 чел.",
8: "Выручка 9 980 млн тг — 96% плана, снижение 4% к плану. EBITDA 2 490 (94%). Кредиторка 1 565 (106% плана). Численность 1 035 чел.",
9: "Выручка 8 730 млн тг — 98% плана. EBITDA 2 210 (97%). Отклонения минимальные. Численность 908 чел.",
10: "Выручка 12 490 млн тг — 103% плана, опережение. EBITDA 3 310 (103%). CAPEX 415 (97%). Численность 1 258 чел.",
11: "Выручка 6 890 млн тг — 97% плана. EBITDA 1 720 (97%). Отклонений свыше 5% нет. Численность 740 чел.",
12: "Выручка 13 850 млн тг — 95% плана. EBITDA 3 640 (95%). Дебиторка 790 (95% плана), кредиторка 2 415 (104%). Численность 1 488 чел.",
13: "Выручка 8 910 млн тг — 96% плана. EBITDA 2 240 (94%). Дебиторка 480 (94%). Численность 958 чел.",
14: "Выручка 11 980 млн тг — 103% плана, опережение. EBITDA 3 080 (103%). Кредиторка 1 495 (95% плана). Численность 1 168 чел.",
15: "Выручка 8 160 млн тг — 97% плана. EBITDA 2 060 (96%). Отклонения в пределах 5%. Численность 830 чел.",
16: "Выручка 12 840 млн тг — 97% плана. EBITDA 3 280 (96%). Дебиторка 680 (96%). Численность 1 348 чел."
};
CO.forEach(function (c, i) { c.analysis = ANALYSIS[i]; c.aStatus = 0; });
CO[2].aStatus = 2; var A_ERR = CO[2];
var STEPS = [
{ id: "upload", n: 1, t: "Загрузка", sub: "распознавание файлов" },
{ id: "check", n: 2, t: "Проверка", sub: "данные и отклонения" },
{ id: "analysis", n: 3, t: "Черновик", sub: "анализа" },
{ id: "result", n: 4, t: "Итог", sub: "результат и отправка" }
];
/* ---------- utils ---------- */
var T = "light";
function $(id) { return document.getElementById(id); }
function esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); }
var NLC = new Intl.NumberFormat("ru-RU");
function fmt(n) { return n == null ? "—" : NLC.format(n); }
function pct(d) { if (!d) return "0"; return "−" + String(d).replace(".", ","); }
function pctSign(d) { return (d > 0 ? "+" : "−") + String(d).replace(".", ","); }
function ic(name) { return '<svg class="kt-icon" aria-hidden="true"><use href="#' + name + '"></use></svg>'; }
function toast(msg, tone) {
var d = document.createElement("div");
d.className = "toast"; if (tone) d.setAttribute("data-tone", tone);
d.textContent = msg;
$("toasts").appendChild(d);
setTimeout(function () { d.style.transition = "opacity .3s"; d.style.opacity = "0"; setTimeout(function () { d.remove(); }, 320); }, 3600);
return true;
}
function sum(arr) { return arr.reduce(function (a, b) { return a + b; }, 0); }
function fileStatus(i) {
if (S.fileManual) return ["info", "Вручную"];
if (S.aiAccepted) return ["ok", "Сопоставлено"];
return S.fileStatus[i] || (i === 1 ? ["warn", "Единицы: ×1/1000"] : ["ok", "Сопоставлено"]);
}
/* ---------- shell ---------- */
function renderSidebar() {
var items = STEPS.map(function (st) {
var done = !!S.done[st.id]; var cur = S.screen === st.id;
return '<button class="kt-ai-nav-item" data-screen="' + st.id + '"' + (cur ? ' data-active="true"' : "") + '>' +
'<span class="lbl"><b>' + st.n + '. ' + st.t + '</b><small>' + st.sub + '</small></span>' +
(done ? '<span class="kt-ai-count-split done">' + esc("17") + '</span>' : "") +
'</button>';
});
items.push('<button class="kt-ai-nav-item" data-screen="about"' + (S.screen === "about" ? ' data-active="true"' : "") + '>' + '<span class="lbl"><b>О решении</b><small>паспорт</small></span></button>');
$("sbNav").innerHTML = '<div class="kt-ai-nav-group">' + items.join("") + '</div>';
}
function renderTopbar() {
var t = "";
if (S.screen === "upload") t = "Загрузка и распознавание · " + "пятница, 10:02";
else if (S.screen === "check") t = "Проверка данных и отклонений";
else if (S.screen === "analysis") t = "Черновик анализа";
else if (S.screen === "result") t = "Итоговый результат";
else t = "О решении";
$("topTitle").innerHTML = '<span class="t">' + t + '</span><span class="sub">пакет на пн, 15:00</span>';
}
function showScreen(id) {
if (id !== "about" && !canOpen(id)) { toast("Сначала завершите предыдущий шаг", "warn"); return; }
S.screen = id; save();
renderSidebar(); renderTopbar();
var r = { upload: viewUpload, check: viewCheck, analysis: viewAnalysis, result: viewResult, about: viewAbout }[id];
var el = $("view");
el.innerHTML = r();
el.scrollTop = 0;
document.querySelector(".kt-ai-main").scrollTop = 0;
bind(id);
closeMobile();
}
function canOpen(id) {
if (id === "upload") return true;
if (id === "check") return !!S.done.recognize;
if (id === "analysis") return !!S.done.check;
if (id === "result") return !!S.done.analysis || S.screen === "result";
return true;
}
function closeMobile() { var sb = $("sidebar"); if (sb) sb.removeAttribute("data-open"); $("scrim").classList.remove("on"); }
/* ---------- stepper ---------- */
function stepHtml(curId) {
var h = "";
STEPS.forEach(function (st, i) {
var done = !!S.done[st.id]; var cur = curId === st.id; var cls = [];
if (cur) cls.push('data-cur="true"');
if (done) cls.push('data-done="true"');
if (i > 0) h += '<span class="step-sep"></span>';
h += '<span class="step" data-goto="' + st.id + '" ' + cls.join(" ") + '><span class="ic">' + (done ? ic("check") : st.n) + '</span>' + st.t + '</span>';
});
return '<div class="steps" style="margin-bottom:var(--kt-ai-space-6)">' + h + '</div>';
}
/* ================= ЭКРАН 1 — загрузка ================= */
function viewUpload() {
var fileRows = FILES.map(function (f, i) {
var st = fileStatus(i);
return '<div class="kt-ai-row" data-file="' + i + '">' +
'<span class="f-ic">' + ic("spreadsheet") + '</span>' +
'<div style="min-width:0;flex:1"><div class="f-name">' + esc(f[0]) + '</div>' +
'<div class="f-meta">' + esc(CO[i].name) + ' · получен 10 июн, ' + (9 + i % 4) + ':' + String((5 + i * 7) % 60).padStart(2, "0") + '</div></div>' +
'<span class="kt-ai-status-pill" data-status="' + st[0] + '">' + st[1] + '</span></div>';
}).join("");
var mapRows = MAPPING.map(function (m) {
var orig = Object.keys(m[1]).map(function (k) { return m[1][k]; });
return '<tr><td><b style="font-weight:var(--kt-ai-weight-medium)">' + m[0] + '</b></td>' +
'<td class="cell-orig">' + orig.map(function (o) { return esc(o); }).join(" · ") + "</td>" +
'<td><span class="kt-ai-chip" data-tone="blue">' + m[0] + '</span></td></tr>';
}).join("");
var progress = S.fileManual ? 100 : S.aiAccepted ? 100 : 82;
var banner, tone;
if (S.fileManual) { tone = "warn"; banner = "Распознавание остановлено: 1 файл обработан в ручном режиме. Проверьте столбцы и верните автоматический режим."; }
else if (S.aiAccepted) { tone = "ok"; banner = "Все 17 файлов сопоставлены, расхождения единиц отмечены. Можно переходить к проверке данных."; }
else { tone = "info"; banner = "ИИ сопоставляет столбцы по 17 файла. Отклонения и единицы будут отмечены после завершения."; }
banner = '<div class="kt-ai-banner" data-tone="' + tone + '" style="margin-bottom:var(--kt-ai-space-5)">' + banner + '</div>';
return pageHead("Загрузка и распознавание", "Вложения из общего ящика Outlook · 17 дочерних компаний, отчёт за июнь",
'', '<button class="kt-ai-btn" data-act="outlook">' + ic("mail") + ' Загрузить из Outlook</button>' +
'<button class="kt-ai-btn" data-variant="primary" data-act="recognize">' + (S.aiAccepted ? ic("check-circle") + " Подтверждено" : ic("sparkles") + " Запустить распознавание") + '</button>') +
banner + stepHtml("upload") +
'<div class="grid-2">' +
'<div class="kt-ai-card block"><div class="block-head"><h2>Файлы</h2><span class="cap">17 шт. · июнь 2026</span><span class="spacer"></span>' +
(S.aiAccepted ? '<button class="kt-ai-btn" data-size="sm" data-act="manual">' + ic("tool") + ' Ручной режим</button>' : "") + '</div>' +
'<div class="file-list">' + fileRows + '</div></div>' +
'<div class="kt-ai-card block"><div class="block-head"><h2>Прогресс распознавания</h2></div>' +
'<div class="progress-head"><b>' + progress + '%</b><span class="cap">' + (S.aiAccepted ? "17/17 файлов" : "14/17 файлов") + '</span><span class="spacer" style="flex:1"></span><span class="kt-ai-chip" data-tone="blue">автоматически</span></div>' +
'<div class="kt-ai-progress"><div style="width:' + progress + '%"></div></div>' +
'<div class="block-sub" style="margin-top:var(--kt-ai-space-5)">Сопоставление: 6 целевых показателей во всех файлах</div>' +
'<div class="block-head" style="margin-top:var(--kt-ai-space-6)"><h2>Журнал действий ИИ</h2></div>' +
'<div class="log-list">' + LOGS.map(function (l) {
return '<div class="log-item"><span class="log-time">' + l[0] + '</span>' +
'<span class="kt-ai-status-pill log-tag" data-status="' + l[1] + '">' + (l[1] === "ok" ? "готово" : l[1] === "warn" ? "внимание" : "инфо") + '</span>' +
'<p>' + l[2] + '</p></div>';
}).join("") + '</div></div>' +
'</div>' +
'<div class="kt-ai-card block" style="margin-top:var(--kt-ai-space-6)"><div class="block-head"><h2>Сопоставление столбцов</h2><span class="cap">исходный формат → единый шаблон</span></div>' +
'<div class="kt-ai-table-wrap"><table class="kt-ai-table"><thead><tr><th style="width:160px">Показатель</th><th>Исходный столбец (варианты)</th><th style="width:170px">Целевой столбец</th></tr></thead><tbody>' +
mapRows + '</tbody></table></div></div>';
}
/* ================= ЭКРАН 2 — проверка ================= */
var chk = { company: "all", only: "all" };
function viewCheck() {
var opts = "<option value='all'>Все компании (17)</option>" + CO.map(function (c, i) { return "<option value='" + i + "'>" + esc(c.name) + "</option>"; }).join("");
var devs = CO.filter(function (c) { return c.maxDev > 5; }).length;
var kpis =
kpi("Компаний в сводном файле", "17 / 17", "план и факт, июнь") +
kpi("Отклонения плана > 5%", devs, "из 17 компаний") +
kpi("Единицы измерения", S.unitFixed.length ? "исправлено" : "1 / 17", "ООО Б · ×1/1000") +
kpi("Структура", "6 показателей", "итоговый лист + 17 вкладок");
return pageHead("Проверка данных и отклонения", "Сводный файл сформирован ИИ · проверьте единицы измерения и отклонения от плана",
'', '<button class="kt-ai-btn" data-variant="primary" data-act="to-check">' + ic("arrowRight") + " Перейти к черновику анализа</button>") +
stepHtml("check") +
'<div class="kt-ai-kpi-strip">' + kpis + '</div>' +
'<div class="kt-ai-card block"><div class="block-head"><h2>Сводная таблица</h2><span class="spacer"></span>' +
'<div class="filter-row" style="margin:0">' +
'<div class="kt-ai-select-wrap"><select class="kt-ai-select" id="fCompany">' + opts + '</select></div>' +
'<div class="kt-ai-segmented" id="fOnly"><button class="seg" data-v="all" data-active="true">Все</button><button class="seg" data-v="dev">С отклонением</button><button class="seg" data-v="unit">Единицы</button></div>' +
'<span class="filter-note" id="fNote"></span>' +
'</div></div>' +
'<div class="table-wrap kt-ai-table-wrap" id="sumTable"></div>' +
'<div class="kt-ai-conclusion"><p class="text">Отклонения подсвечены: двухсегментная шкала — 2% и 5%. Строка с ошибками единиц помечена красным; исправление — по клику на ячейку. Итоговая строка — сумма по выбранным компаниям.</p></div>' +
'</div>' +
unitBanner();
}
function kpi(label, value, hint) { return '<div class="kt-ai-kpi"><span class="label">' + label + '</span><span class="value">' + value + '</span><span class="hint">' + hint + '</span></div>'; }
function unitBanner() {
var fixed = S.unitFixed.indexOf(1) !== -1;
return fixed
? '<div class="kt-ai-banner" data-tone="ok" style="margin-top:var(--kt-ai-space-5)">Единицы ООО Б исправлены: выручка переведена из тысяч в миллионы тенге.</div>'
: '<div class="kt-ai-banner" data-tone="risk" style="margin-top:var(--kt-ai-space-5)"><div><b>Расхождение единиц: ООО Б.</b> В файле выручка в тысячах тенге, в шаблоне — в миллионах. Клик по ячейке выручки — исправить вручную.</div></div>';
}
function totalRow() {
var r = ["Всего", sum(CO.map(function (c) { return c.vp; })), sum(CO.map(function (c) { return c.vf; })), sum(CO.map(function (c) { return c.ep; })), sum(CO.map(function (c) { return c.ef; })), sum(CO.map(function (c) { return c.cap; })), sum(CO.map(function (c) { return c.caf; })), sum(CO.map(function (c) { return c.dp; })), sum(CO.map(function (c) { return c.df; })), sum(CO.map(function (c) { return c.cp; })), sum(CO.map(function (c) { return c.cf; })), sum(CO.map(function (c) { return c.hp; })), sum(CO.map(function (c) { return c.hf; }))];
return r;
}
function renderSumTable() {
var box = $("sumTable"); if (!box) return;
var filtered = CO.filter(function (c, i) { return chk.company === "all" || chk.company === String(i); });
var rows = CO.map(function (c, i) {
var show = chk.company === "all" || chk.company === String(i);
if (!show) return "";
if (chk.only === "dev" && c.maxDev <= 5) return "";
if (chk.only === "unit" && c.name !== "ООО Б") return "";
var unitFix = S.unitFixed.indexOf(1) !== -1;
var dR = c.rDev, dE = c.eDev, dC = c.cDev, dD = c.dDev, dK = c.kDev;
function cell(plan, fact, d, extra) {
var cls = "num cell-num" + (d >= 5 ? " cell-dev-3" : d > 2 ? " cell-dev-2" : "");
return '<td class="' + cls + (extra ? " " + extra : "") + '">' + fmt(plan) + ' / ' + fmt(fact) + ' <span class="' + (d >= 5 ? "delta-neg" : "delta-pos") + '">(' + (d > 0 ? pct(d) : "0") + '%)</span></td>';
}
var vfCell = c.name === "ООО Б"
? '<td class="num cell-num ' + (unitFix ? "cell-unit-fixed" : "cell-unit-w") + '" data-unit="1">' + fmt(c.vp) + ' / ' + fmt(c.vf) + ' <span class="' + (dR >= 5 ? "delta-neg" : "delta-pos") + '">(' + pct(dR) + '%)</span> ' + (unitFix ? '<span class="kt-ai-chip" data-tone="green">исправлено</span>' : '<span class="kt-ai-chip" data-tone="red">×1 000</span>') + '</td>'
: cell(c.vp, c.vf, dR);
return '<tr data-clickable="true" data-oi="' + i + '"><td><b style="font-weight:var(--kt-ai-weight-medium)">' + esc(c.name) + '</b></td>' + vfCell +
cell(c.ep, c.ef, dE) + cell(c.cap, c.caf, dC) + cell(c.dp, c.df, dD) + cell(c.cp, c.cf, dK) +
'<td class="num">' + fmt(c.hp) + ' / ' + fmt(c.hf) + '</td></tr>';
}).join("");
var totalN = filtered.length;
var tsum = filtered.map(function (c) {
return { vp: c.vp, vf: c.vf, ep: c.ep, ef: c.ef, cap: c.cap, caf: c.caf, dp: c.dp, df: c.df, cp: c.cp, cf: c.cf, hp: c.hp, hf: c.hf };
}).reduce(function (a, b) { for (var k in a) a[k] += b[k]; return a; }, { vp: 0, vf: 0, ep: 0, ef: 0, cap: 0, caf: 0, dp: 0, df: 0, cp: 0, cf: 0, hp: 0, hf: 0 });
var dV = dev({}, tsum.vf, tsum.vp), dE = dev({}, tsum.ef, tsum.ep), dC = dev({}, tsum.caf, tsum.cap), dD = dev({}, tsum.df, tsum.dp), dK = dev({}, tsum.cf, tsum.cp);
function t(p, f, d) { return '<td class="num cell-num">' + fmt(p) + ' / ' + fmt(f) + ' <span class="' + (d >= 5 ? "delta-neg" : "delta-pos") + '">(' + (d > 0 ? pct(d) : "0") + '%)</span></td>'; }
var totalRowHtml = '<tr style="background:var(--kt-ai-bg-soft)"><td><b>Итого, ' + (chk.company === "all" ? "17 компаний" : esc(CO[+chk.company].name)) + '</b></td>' +
t(tsum.vp, tsum.vf, dV) + t(tsum.ep, tsum.ef, dE) + t(tsum.cap, tsum.caf, dC) + t(tsum.dp, tsum.df, dD) + t(tsum.cp, tsum.cf, dK) +
'<td class="num cell-num">' + fmt(tsum.hp) + ' / ' + fmt(tsum.hf) + '</td></tr>';
var any = rows.indexOf("<tr") !== -1;
box.innerHTML = '<table class="kt-ai-table" data-density="compact"><thead><tr><th>Компания</th>' +
"<th class='num'>Выручка, млн тг</th><th class='num'>EBITDA, млн тг</th><th class='num'>CAPEX, млн тг</th><th class='num'>Дебиторка, млн тг</th><th class='num'>Кредиторка, млн тг</th><th class='num'>Численность, чел.</th></tr></thead><tbody>" +
(any ? rows + totalRowHtml : '<tr><td colspan="7" style="text-align:center;padding:var(--kt-ai-space-8);color:var(--kt-ai-fg-faint)">Под фильтр не попали компании</td></tr>') +
"</tbody></table>";
var note = $("fNote"); if (note) note.textContent = any ? totalN + " из 17 · план / факт, откл. в %" : "";
}
function pageHead(title, sub, left, right) {
return '<div class="page-head"><div class="ph-text"><h1>' + title + '</h1><p class="sub">' + (sub || "") + '</p></div>' +
(right ? '<div class="ph-action">' + right + '</div>' : "") + '</div>';
}
/* ================= ЭКРАН 3 — черновик анализа ================= */
var curA = 0;
function analysisText(i) {
var o = S.analysis[i];
if (o && typeof o === "string") return o;
return CO[i].analysis;
}
function aStatus(i) { return (S.analysis[i] && S.analysis[i].status) || CO[i].aStatus || 0; }
function viewAnalysis() {
var list = CO.map(function (c, i) {
var st = aStatus(i);
var dot = st === 1 ? 'dot-ok' : (st === 2 ? 'dot-err' : 'dot-gen');
return '<div class="kt-ai-row" data-ana="' + i + '"' + (i === curA ? ' data-active="true"' : "") + '>' +
'<span class="f-ic" style="width:26px;height:26px">' + ic("file") + '</span>' +
'<div style="min-width:0;flex:1"><span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block">' + esc(c.name) + (S.analysis[i] && S.analysis[i].edited ? ' <span class="kt-ai-chip" data-tone="blue" style="margin-left:6px">правка</span>' : "") + '</span></div>' +
'<span class="dot ' + dot + '" title=""></span></div>';
}).join("");
var words = CO.reduce(function (a, c, i) { return a + analysisText(i).split(/\s+/).filter(Boolean).length; }, 0);
var ready = CO.filter(function (c, i) { return aStatus(i) === 1; }).length;
var memoKpi = kpi("Компаний", CO.length, "по одному выводу") +
kpi("Принято", ready, "из " + CO.length) +
kpi("Объём записки", fmt(words) + " слов", "≈ " + (words > 640 ? "2" : "1") + " стр.") +
kpi("Готовность", ready === CO.length ? "100%" : Math.round(ready / CO.length * 100) + "%", "к формированию записки");
return pageHead("Черновик анализа", "ИИ сгенерировал выводы по каждой компании · отредактируйте при необходимости",
'', '<button class="kt-ai-btn" data-act="accept-all">' + ic("check-circle") + ' Принять черновик</button>' +
'<button class="kt-ai-btn" data-variant="primary" data-act="make-memo">' + ic("send") + ' Сформировать записку</button>') +
stepHtml("analysis") +
'<div class="kt-ai-kpi-strip">' + memoKpi + '</div>' +
'<div class="analysis-body">' +
'<div class="analysis-list">' + list + '</div>' +
'<div><div class="ed-head"><span class="cap">Черновик · ' + esc(CO[curA].name) + '</span><span class="ed-stats">' + words + ' слов</span><span style="flex:1"></span>' +
'<button class="kt-ai-btn" data-size="sm" data-act="a-prev">' + ic("chevronLeft") + '</button>' +
'<button class="kt-ai-btn" data-size="sm" data-act="a-next">' + ic("chevronRight") + '</button></div>' +
'<div class="ed-wrap"><textarea class="kt-ai-analysis" id="anaText" spellcheck="false"></textarea></div>' +
'<div style="display:flex;gap:var(--kt-ai-space-4);align-items:center;margin-top:var(--kt-ai-space-4);flex-wrap:wrap">' +
'<button class="kt-ai-btn" data-variant="primary" data-size="sm" data-act="a-accept">' + ic("check") + ' Принять выводы</button>' +
'<span class="ed-stats">Статус: ' + (aStatus(curA) === 1 ? "принято" : aStatus(curA) === 2 ? "есть пометка о данных" : "черновик") + '</span>' +
(aStatus(curA) === 2 ? '<span class="kt-ai-chip" data-tone="orange">проверить дебиторку</span>' : "") +
'</div></div>' +
'</div>';
}
/* ================= ЭКРАН 4 — итоговый результат ================= */
function viewResult() {
var tv = totalRow();
var dV = dev({}, tv[2], tv[1]), dE = dev({}, tv[4], tv[3]);
var memoRows = CO.filter(function (c) { return c.maxDev > 5 || c.name === "ООО Б"; }).map(function (c) {
return "<p>• <b>" + esc(c.name) + ".</b> " + analysisTextShort(c) + "</p>";
}).join("");
var status = S.sent ? ['data-status="ok"', "Отправлено директору 09:58", ""]
: S.done.analysis ? ['data-status="ok"', "Готово к отправке", "дедлайн: пн 15:00"]
: ['data-status="warn"', "Ожидает подтверждения анализа", "дедлайн: пн 15:00"];
var xTable = '<table class="kt-ai-table" data-density="compact"><thead><tr><th>Организация</th><th class="num">Выручка, млн тг</th><th class="num">EBITDA, млн тг</th><th class="num">CAPEX, млн тг</th><th class="num">Дебит. / Кредит., млн тг</th></tr></thead><tbody>' +
CO.map(function (c) { return '<tr><td>' + esc(c.name) + '</td><td class="num cell-num">' + fmt(c.vf) + '</td><td class="num cell-num">' + fmt(c.ef) + '</td><td class="num cell-num">' + fmt(c.caf) + '</td><td class="num cell-num">' + fmt(c.df) + ' / ' + fmt(c.cf) + '</td></tr>'; }).join("") +
'</tbody></table>';
return pageHead("Итоговый результат", "Финальная проверка пакета перед передачей финансовому директору",
'', '<button class="kt-ai-btn" data-act="dl-xlsx">' + ic("download") + ' Скачать Excel</button>' +
'<button class="kt-ai-btn" data-act="dl-memo">' + ic("download") + ' Скачать записку</button>' +
'<button class="kt-ai-btn" data-variant="primary" data-act="send-pkg" ' + (S.sent ? "disabled" : "") + '>' + (S.sent ? ic("check-circle") + " Отправлено" : ic("send") + " Отправить пакет директору") + '</button>') +
stepHtml("result") +
'<div class="kt-ai-banner" ' + status[0] + ' style="margin-bottom:var(--kt-ai-space-5)">' +
'<b>' + status[1] + '</b> · ' + status[2] + (S.sent ? ' · адресат: финансовый директор' : "") + '</div>' +
'<div class="kt-ai-kpi-strip">' +
kpi("Сводный файл", "1 итог + 17 листов", "сводный_файл_июнь.xlsx") +
kpi("Записка", "2 страницы", "выводы по компаниям") +
kpi("Сверка", "17 / 17", "единицы согласованы") +
kpi("Отклонения", dV > 0 ? "−" + pctSign(dV) + "%" : pctSign(dV) + "%", "выручка к плану · EBITDA " + pctSign(dE) + "%") +
'</div>' +
'<div class="grid-2">' +
'<div class="kt-ai-card block"><div class="block-head"><h2>Сводный Excel</h2><span class="cap">sheet 1 из 18: Итог, затем по организации</span></div>' +
'<div class="table-wrap" style="max-height:430px">' + xTable + '</div></div>' +
'<div class="kt-ai-card block"><div class="block-head"><h2>Записка к отчёту</h2><span class="kt-ai-chip" data-tone="gray">2 стр.</span></div>' +
'<div class="memo"><div class="memo-meta">На: финансовый директор · От: аналитик · Пн, 15:00</div>' +
'<h3>Свод по дочерним компаниям, июнь</h3>' +
'<p>По всем 17 компаниям факт к плану по выручке ' + (dV > 0 ? "−" + pctSign(dV) : pctSign(dV)) + "%, по EBITDA — " + pctSign(dE) + "% (млн тг). Единицы измерения 1 компании (ООО Б) исправлены и пересчитаны.</p>" +
memoRows +
'<p>Рекомендации: разобрать дебиторку ООО В (рост 96% к плану) и CAPEX ООО Д (110% к программе).</p>' +
'</div></div>' +
'</div>';
}
function analysisTextShort(c) {
var t = c.analysis;
return t.length > 200 ? t.slice(0, 197).replace(/\s+\S*$/, "") + "…" : t;
}
/* ================= действия ================= */
function setUnitFix(i, fixed) {
var idx = S.unitFixed.indexOf(1);
if (fixed && idx === -1) { S.unitFixed.push(1); }
if (!fixed) S.unitFixed = S.unitFixed.filter(function (x) { return x !== 1; });
save();
if (S.screen === "check") renderSumTable(); updateUnitBanner();
toast(fixed ? "Единицы ООО Б исправлены: ×1/1000 применён" : "Единицы ООО Б возвращены в исходные", fixed ? "ok" : "warn");
}
function updateUnitBanner() {
if (S.screen !== "check") return;
var banner = document.querySelector("#view .kt-ai-banner:not([data-tone='info']):not([data-tone='warn'])");
if (!banner) return;
var fixed = S.unitFixed.indexOf(1) !== -1;
banner.setAttribute("data-tone", fixed ? "ok" : "risk");
banner.innerHTML = fixed
? '<b>Единицы ООО Б исправлены:</b> выручка переведена из тысяч в миллионы тенге.'
: '<b>Расхождение единиц: ООО Б.</b> В файле выручка в тысячах тенге, в шаблоне — в миллионах. Клик по ячейке выручки — исправить вручную.';
var k = document.querySelectorAll(".kt-ai-kpi");
if (k[2]) k[2].querySelector(".value").textContent = fixed ? "исправлено" : "1 / 17";
}
function acceptAllAnalysis() {
CO.forEach(function (c, i) { S.analysis[i] = { status: 1 }; });
S.done.analysis = true; save();
toast("Черновик принят по всем 17 компаниям", "ok");
showScreen("analysis");
}
function makeMemo() {
if (!S.done.analysis) { toast("Сначала примите черновик анализа по всем компаниям", "warn"); return; }
showScreen("result");
toast("Записка сформирована", "ok");
}
function download(text, filename) {
try {
var b = new Blob([text], { type: "text/plain;charset=utf-8" });
var a = document.createElement("a");
a.href = URL.createObjectURL(b);
a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
toast("Скачивание: " + filename, "ok");
} catch (e) { toast("Не удалось скачать файл", "warn"); }
}
function xlsxText() {
return "Сводный файл, июнь 2026, млн тг (дебиторка/кредиторка/численность — свои единицы)\n\nКомпания;Выручка (план);Выручка (факт);EBITDA (план);EBITDA (факт);CAPEX (план);CAPEX (факт);Дебиторка (план);Дебиторка (факт);Кредиторка (план);Кредиторка (факт);Численность (план);Численность (факт)\n" +
CO.map(function (c) { return [c.name, c.vp, c.vf, c.ep, c.ef, c.cap, c.caf, c.dp, c.df, c.cp, c.cf, c.hp, c.hf].join(";"); }).join("\n") +
"\n\n" + CO.map(function (c) { return "== " + c.name + " ==\n" + analysisText(CO.indexOf(c)); }).join("\n\n");
}
function memoText() {
var tv = totalRow(); var dV = dev({}, tv[2], tv[1]), dE = dev({}, tv[4], tv[3]);
return "ЗАПИСКА — свод по дочерним компаниям, июнь 2026\nНа: финансовый директор\n\nПо всем 17 компаниям факт к плану: выручка " + pctSign(dV) + "%, EBITDA " + pctSign(dE) + "% (млн тг).\nЕдиницы измерения ООО Б исправлены (тысячи → миллионы тенге).\n\n" + CO.filter(function (c) { return c.maxDev > 5 || c.name === "ООО Б"; }).map(function (c) { return "• " + c.name + ". " + analysisTextShort(c); }).join("\n") +
"\n\nРекомендации: разобрать дебиторку ООО В (рост 96% к плану) и CAPEX ООО Д (110% к программе).\n";
}
/* ---------- bind ---------- */
function bind(screen) {
var v = $("view");
v.querySelectorAll("[data-goto]").forEach(function (b) {
b.addEventListener("click", function () { showScreen(b.getAttribute("data-goto")); });
});
// загрузка
if (screen === "upload") {
v.querySelectorAll("[data-act='outlook']").forEach(function (b) { b.addEventListener("click", function () { toast("Обнаружено 17 вложений в общем ящике · загрузка завершена", "ok"); }); });
v.querySelectorAll("[data-act='recognize']").forEach(function (b) {
b.addEventListener("click", function () {
if (S.aiAccepted) { if (S.done.recognize) showScreen("check"); return; }
S.aiAccepted = true; S.done.recognize = true; save();
toast("Сопоставление завершено: 17/17 файлов, 1 расхождение единиц", "ok");
showScreen("upload");
});
});
v.querySelectorAll("[data-act='manual']").forEach(function (b) { b.addEventListener("click", function () {
S.fileManual = true; save(); toast("Открыт ручной режим для 1 файла. Проверьте столбцы и верните автоматический режим.", "warn"); showScreen("upload");
}); });
v.querySelectorAll("[data-file]").forEach(function (r) { r.addEventListener("click", function () { toast(esc(CO[+r.getAttribute("data-file")].name) + " · формат распознан, 6/6 столбцов", "ok"); }); });
}
// проверка
if (screen === "check") {
renderSumTable();
var fc = $("fCompany");
if (fc) { fc.value = chk.company; fc.addEventListener("change", function () { chk.company = fc.value; renderSumTable(); }); }
var fo = $("fOnly");
if (fo) fo.addEventListener("click", function (e) {
var b = e.target.closest(".seg"); if (!b) return;
chk.only = b.getAttribute("data-v");
fo.querySelectorAll(".seg").forEach(function (x) { x.setAttribute("data-active", x === b ? "true" : "false"); });
renderSumTable();
});
var box = $("sumTable");
if (box) box.addEventListener("click", function (e) {
var cell = e.target.closest("[data-unit]");
if (cell) { setUnitFix(1, S.unitFixed.indexOf(1) === -1); return; }
var tr = e.target.closest("tr[data-oi]");
if (tr) {
var i = +tr.getAttribute("data-oi");
toast(CO[i].name + ": " + (CO[i].maxDev > 2 ? "макс. отклонение " + CO[i].maxDev + "%" : "в пределах нормы") + (CO[i].name === "ООО Б" ? " · расхождение единиц: ×1/1000" : ""), CO[i].maxDev > 5 ? "warn" : "ok");
}
});
v.querySelectorAll("[data-act='to-check']").forEach(function (b) { b.addEventListener("click", function () {
if (S.unitFixed.indexOf(1) === -1) { toast("Сначала исправьте единицы ООО Б (клик по ячейке выручки)", "warn"); return; }
if (!S.done.check) { S.done.check = true; save(); }
showScreen("analysis");
}); });
}
// анализ
if (screen === "analysis") {
var ta = $("anaText"); if (ta) ta.value = analysisText(curA);
v.querySelectorAll("[data-ana]").forEach(function (r) { r.addEventListener("click", function () {
curA = +r.getAttribute("data-ana"); save(); showScreen("analysis");
}); });
if (ta) ta.addEventListener("input", function () {
var cur = S.analysis[curA] || {};
S.analysis[curA] = Object.assign(cur, { text: ta.value, edited: true });
save();
var st = ta.closest(".analysis-body").querySelector(".ed-stats");
if (st) st.textContent = ta.value.split(/\s+/).filter(Boolean).length + " слов";
});
v.querySelectorAll("[data-act='a-prev'],[data-act='a-next']").forEach(function (b) {
b.addEventListener("click", function () {
curA = (curA + (b.getAttribute("data-act") === "a-next" ? 1 : CO.length - 1)) % CO.length;
save(); showScreen("analysis");
});
});
v.querySelectorAll("[data-act='a-accept']").forEach(function (b) { b.addEventListener("click", function () {
var st = S.analysis[curA] || {}; st.status = 1; S.analysis[curA] = st; save();
toast(CO[curA].name + ": выводы приняты", "ok"); showScreen("analysis");
}); });
v.querySelectorAll("[data-act='accept-all']").forEach(function (b) { b.addEventListener("click", acceptAllAnalysis); });
v.querySelectorAll("[data-act='make-memo']").forEach(function (b) { b.addEventListener("click", makeMemo); });
}
// итог
if (screen === "result") {
v.querySelectorAll("[data-act='dl-xlsx']").forEach(function (b) { b.addEventListener("click", function () { download(xlsxText(), "Сводный_файл_июнь_2026.txt"); }); });
v.querySelectorAll("[data-act='dl-memo']").forEach(function (b) { b.addEventListener("click", function () { download(memoText(), "Записка_июнь_2026.txt"); }); });
var sb2 = v.querySelector("[data-act='send-pkg']");
if (sb2 && !S.sent) sb2.addEventListener("click", function () {
S.sent = true; S.done.result = true; save();
toast("Пакет отправлен финансовому директору · 09:58", "ok");
showScreen("result");
});
}
}
/* ---------- инициализация ---------- */
function init() {
var html = document.documentElement;
T = S.theme || "light";
html.setAttribute("data-theme", T);
var ti = $("themeIcon"); if (ti) ti.setAttribute("href", T === "light" ? "#moon" : "#sun");
$("themeToggle").addEventListener("click", function () {
T = T === "light" ? "dark" : "light";
S.theme = T; save();
html.setAttribute("data-theme", T);
if (ti) ti.setAttribute("href", T === "light" ? "#moon" : "#sun");
});
$("burger").hidden = window.innerWidth >= 832;
$("burger").addEventListener("click", function () {
var sb = $("sidebar");
var open = sb.hasAttribute("data-open");
if (open) closeMobile();
else { sb.setAttribute("data-open", "true"); $("scrim").classList.add("on"); }
$("burger").hidden = window.innerWidth >= 832;
});
$("scrim").addEventListener("click", closeMobile);
window.addEventListener("resize", function () { $("burger").hidden = window.innerWidth >= 832; });
document.querySelector(".kt-ai-shell").addEventListener("click", function (e) {
var n = e.target.closest(".kt-ai-nav-item[data-screen]");
if (n) showScreen(n.getAttribute("data-screen"));
});
if (S.resetFlag) { delete S.resetFlag; save(); toast("Предыдущее состояние не восстановилось · начат новый цикл", "warn"); }
renderSidebar();
showScreen(S.screen || "upload");
}
/* ================= ЭКРАН 5 — о решении ================= */
function viewAbout() {
return pageHead("О решении", "Сводный финансовый анализ дочерних компаний", "",
'') +
'<div class="pass-grid">' +
card("Паспорт решения", dl([
["Наименование", "Сводный финансовый анализ дочерних компаний"],
["Исполнитель", "владелец процесса — сборка на платформе Vibe AI"],
["Пользователи", "аналитик, финансовый директор"],
["Частота", "еженедельно, вложения по пятницам"],
["Дедлайн", "понедельник, 15:00"]
])) +
card("Предпосылки", '<ul><li>Ручная обработка данных 17 дочерних компаний занимает 3–4 часа в неделю</li><li>Разные форматы Excel приводят к путанице в столбцах и потере данных</li></ul>') +
card("Входные данные", '<ul><li>Выручка, EBITDA, CAPEX, дебиторка, кредиторка, численность — план и факт</li><li>Вложения в общем ящике Outlook, приходят по пятницам</li><li>У каждой компании свой шаблон листов и названий столбцов</li></ul>') +
card("Что делает ИИ", '<ol><li>Распознаёт формат файла и сопоставляет столбцы</li><li>Переносит данные в единый сводный файл</li><li>Подсвечивает отклонения от плана</li><li>Генерирует черновик анализа по каждой компании</li></ol>') +
card("Что решает человек", '<ul><li>Аналитик проверяет единицы измерения и расхождения</li><li>Аналитик редактирует и утверждает черновик анализа</li><li>Руководитель выборочно просматривает итоговый результат</li></ul>') +
card("Результат и эффект", '<ul><li>Сводный Excel: итоговый лист + листы по организациям</li><li>Записка на 1–2 страницы с выводами</li><li>Экономия 3–4 часов в неделю</li></ul>') +
card("Риски и меры", '<ul><li>Нестандартный формат файла, путаница тысячи/миллионы тенге</li><li>Обязательная проверка единиц и расхождений аналитиком</li><li>Выборочный контроль результата руководителем</li><li>Журнал действий ИИ и ручной режим при ошибке</li></ul>') +
'</div>';
}
function card(title, body) { return '<div class="kt-ai-card block pass-card" style="margin:0"><div class="block-head" style="margin-bottom:var(--kt-ai-space-4)"><h2>' + title + '</h2></div>' + body + '</div>'; }
function dl(rows) { return '<dl class="kv">' + rows.map(function (r) { return '<dt>' + r[0] + '</dt><dd>' + r[1] + '</dd>'; }).join("") + '</dl>'; }
document.addEventListener("DOMContentLoaded", init);
})();