svod-otchetov-dochernih-komp/script.js

999 lines
46 KiB
JavaScript
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.

/* Свод отчётов дочерних компаний — статичный прототип.
Состояние и правки держим в localStorage; данные — правдоподобные примеры. */
"use strict";
/* ── Справочник компаний (нейтральные названия) ───────────── */
var COMPANIES = [
"ООО «Альфа»", "ООО «Бета»", "ООО «Вега»", "ООО «Гамма»", "ООО «Дельта»",
"ООО «Эпсилон»", "ООО «Зета»", "ООО «Эта»", "ООО «Тета»", "ООО «Йота»",
"ООО «Каппа»", "ООО «Лямбда»", "ООО «Мю»", "ООО «Ню»", "ООО «Омикрон»"
];
var METRIC_TYPES = ["Выручка", "EBITDA", "CAPEX", "Дебиторка"];
var UNIT_NOTE = {
"Вега_CAPEX": "CAPEX: значение в файле в тыс. тг — пересчитано ИИ",
"Зета_Дебиторка": "Дебиторка: пустая ячейка в файле — значение не извлечено",
"Каппа_Выручка": "Выручка: в файле без разбивки по периодам — взято поле «Итого»"
};
/* План и факт по каждому показателю каждой компании: { plan, fact | null } */
var BASE = {
0: { rev: [145200, 152800], ebitda: [41200, 39600], capex: [28400, 30100], deb: [98600, 104300] },
1: { rev: [88300, 86100], ebitda: [21700, 19840], capex: [14200, 15900], deb: [52300, 58900] },
2: { rev: [210400, 206700], ebitda: [58900, 45100], capex: [33600, 34800], deb: [122400, 99800] },
3: { rev: [64800, 71200], ebitda: [15300, 10800], capex: [9800, 9200], deb: [40100, 37600] },
4: { rev: [305600, 312400], ebitda: [82400, 84900], capex: [41200, 39800], deb: [158700, 163200] },
5: { rev: [47900, 46300], ebitda: [12600, 11700], capex: [6400, 7100], deb: [28900, 31200] },
6: { rev: [127500, 124100], ebitda: [33800, 32100], capex: [18700, 17900], deb: [76400, 81900] },
7: { rev: [193200, 190800], ebitda: [51200, 12500], capex: [21400, 22300], deb: [108500, 112700] },
8: { rev: [72600, 75900], ebitda: [18900, 19400], capex: [11300, 12800], deb: [44800, 47100] },
9: { rev: [158700, 155200], ebitda: [42100, 40300], capex: [25600, 24900], deb: [89300, 92600] },
10: { rev: [112400, 119800], ebitda: [29700, 31800], capex: [16800, 17400], deb: [67200, 63400] },
11: { rev: [88400, 82400], ebitda: [22800, 12100], capex: [13100, 12600], deb: [51700, 55900] },
12: { rev: [171300, 176500], ebitda: [47600, 48900], capex: [27200, 28800], deb: [94100, 98700] },
13: { rev: [96800, 93500], ebitda: [25400, 24200], capex: [15300, 14900], deb: [58600, 61200] },
14: { rev: [132900, 138600], ebitda: [36800, 38200], capex: [22100, 23400], deb: [81500, 84300] }
};
/* Журнал действий ИИ (время — минуты от начала сессии) */
var LOG = [
{ t: "08:51", m: "Подключение к общему ящику — получено 9 писем с вложениями" },
{ t: "08:52", m: "Скачаны 9 файлов Excel, дубликаты не обнаружены", tag: "09/09" },
{ t: "08:53", m: "Файл «Альфа»: шаблон А1, показатели найдены, перенесены в свод" },
{ t: "08:53", m: "Файл «Бета»: шаблон А1, показатели найдены, перенесены в свод" },
{ t: "08:54", m: "Файл «Гамма»: шаблон А1, показатели найдены, перенесены в свод" },
{ t: "08:54", m: "Файл «Дельта»: шаблон А1, показатели найдены, перенесены в свод" },
{ t: "08:55", m: "Файл «Эпсилон»: шаблон А1, показатели найдены, перенесены в свод" },
{ t: "08:55", m: "Файл «Зета»: шаблон А1, 1 ячейка не распознана — требуется ручной режим" },
{ t: "08:56", m: "Файл «Эта»: шаблон Б2, нестандартная структура — требуется ручной режим" },
{ t: "08:56", m: "Файл «Каппа»: шаблон Б2, поле «Итого» взято как выручка" },
{ t: "08:59", m: "Ожидаются файлы: «Вега», «Тета», «Йота», «Лямбда», «Мю», «Ню», «Омикрон»" }
];
var FILE_TPL = {
ok: { status: "ok", label: "Успешно", date: "06.05.2026, 08:53" },
error: { status: "risk", label: "Ошибка формата", date: "06.05.2026, 08:55" },
pending: { status: "muted", label: "Ожидается", date: "" }
};
var FILE_SIZES = [
"1.2 МБ", "0.8 МБ", "1.6 МБ", "0.6 МБ", "2.1 МБ",
"0.9 МБ", "1.1 МБ", "1.7 МБ", "0.7 МБ", "1.4 МБ",
"1.3 МБ", "0.9 МБ", "1.5 МБ", "0.8 МБ", "1.9 МБ"
];
var DEFAULT_NOTE = "Уважаемый финансовый директор!\n\nГотов свод отчётов 15 дочерних компаний за апрель 2026 года. Файл «Свод отчётов — апрель 2026.xlsx» прилагается.\n\nКлючевые отклонения:\n" +
"1. ООО «Зета» — выручка выше плана на 18,6%.\n" +
"2. ООО «Вега» — EBITDA ниже плана на 23,4%.\n" +
"3. ООО «Эта» — EBITDA ниже плана на 75,6%.\n\n" +
"Просьба ознакомиться. По вопросам — на связи.\n\nС уважением,\nСводящий";
/* ── Состояние ───────────────────────────────────────────── */
var LS_KEY = "svod-otchetov-v1";
function pad2(n) { return String(n).length < 2 ? "0" + String(n) : String(n); }
function fmtTime(minutes) {
var h = Math.floor(minutes / 60);
var m = minutes % 60;
return pad2(h) + ":" + pad2(m);
}
/* 15 компаний × 4 показателя: план и факт. Факт null — ячейка не извлечена. */
function buildMetrics() {
var out = [];
var keys = ["rev", "ebitda", "capex", "deb"];
for (var i = 0; i < 15; i++) {
for (var j = 0; j < 4; j++) {
var row = BASE[i][keys[j]];
var plan = row[0];
var fact = row[1];
if (i === 6 && j === 3) fact = null; // Зета, дебиторка — пустая ячейка
out.push({
id: "C" + i + "_M" + j,
c: i,
m: j,
company: COMPANIES[i],
type: METRIC_TYPES[j],
plan: plan,
fact: fact
});
}
}
return out;
}
function defaultState() {
var files = [];
for (var i = 0; i < 15; i++) {
var tpl = FILE_TPL.pending;
if (i === 2 || i === 7) tpl = FILE_TPL.error;
else if (i < 9) tpl = FILE_TPL.ok;
var short = COMPANIES[i].replace("ООО «", "").replace("»", "");
files.push({
name: "Отчёт " + short + " — апрель 2026.xlsx",
company: COMPANIES[i],
status: tpl.status,
statusLabel: tpl.label,
date: tpl.date
});
}
return {
screen: "upload",
files: files,
log: LOG,
processed: 7,
metrics: buildMetrics(),
notesResolved: {},
manual: {},
approved: false,
noteText: DEFAULT_NOTE,
sent: false,
sentAt: null,
period: "апрель 2026"
};
}
function load() {
try {
var raw = localStorage.getItem(LS_KEY);
if (raw) {
var s = JSON.parse(raw);
if (s && s.metrics && s.metrics.length) return s;
}
} catch (e) { /* ignore */ }
return defaultState();
}
function save() {
try { localStorage.setItem(LS_KEY, JSON.stringify(state)); } catch (e) { /* ignore */ }
}
var state = load();
/* ── Утилиты разметки ────────────────────────────────────── */
function esc(s) {
var str = String(s == null ? "" : s);
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtNum(v) {
if (v === null || v === undefined) return "—";
return Number(v).toLocaleString("ru-RU");
}
function fmtDev(dev) {
if (dev === null || dev === undefined) return "—";
var sign = dev > 0 ? "+" : "";
return sign + dev.toFixed(1).replace(".", ",") + "%";
}
function icon(name) {
return '<svg class="kt-ai-icon" aria-hidden="true" focusable="false"><use href="#' + name + '"></use></svg>';
}
function pill(status, label) {
return '<span class="kt-ai-status-pill" data-status="' + status + '">' + esc(label) + "</span>";
}
function metricOf(id) {
for (var i = 0; i < state.metrics.length; i++) {
if (state.metrics[i].id === id) return state.metrics[i];
}
return null;
}
function companyShort(name) {
return name.replace("ООО «", "").replace("»", "");
}
/* ── Журнал: добавление записи ───────────────────────────── */
var clock = 9 * 60 + 19;
function addLog(msg) {
clock += 1;
state.log.unshift({ t: fmtTime(clock), m: msg });
}
/* ── Тосты ───────────────────────────────────────────────── */
function toast(text, tone) {
var stack = document.getElementById("toasts");
if (!stack) return;
var el = document.createElement("div");
el.className = "kt-ai-toast";
el.setAttribute("data-tone", tone || "info");
el.innerHTML = "<span>" + esc(text) + "</span>";
stack.appendChild(el);
setTimeout(function () {
if (el.parentNode) el.parentNode.removeChild(el);
}, 4200);
}
/* ── Навигация ───────────────────────────────────────────── */
function show(id) {
state.screen = id;
save();
renderAll();
window.scrollTo(0, 0);
}
(function initNav() {
var nav = document.getElementById("app-nav");
if (!nav) return;
nav.addEventListener("click", function (e) {
var item = e.target.closest(".kt-ai-nav-item");
if (!item) return;
var scr = item.getAttribute("data-nav");
if (scr) show(scr);
});
})();
/* ── Мини-KPI ────────────────────────────────────────────── */
function kpis(cards) {
var h = '<div class="kt-ai-kpis">';
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
h += '<div class="kt-ai-kpi-card">';
h += '<div class="lbl">' + esc(c.label) + "</div>";
h += '<div class="row"><span class="val">' + esc(c.val) + "</span>";
if (c.status && c.badge) {
h += '<span class="badge kt-ai-status-pill" data-status="' + esc(c.status) + '">' + esc(c.badge) + "</span>";
}
h += "</div>";
if (c.hint) h += '<div class="hint">' + esc(c.hint) + "</div>";
h += "</div>";
}
return h + "</div>";
}
function logHtml(entries) {
var h = '<ul class="log">';
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
h += "<li>";
h += '<span class="time">' + esc(e.t) + "</span>";
h += "<span>" + esc(e.m) + "</span>";
if (e.tag) h += '<span class="tag">' + esc(e.tag) + "</span>";
h += "</li>";
}
return h + "</ul>";
}
function warningsHtml() {
var items = [];
var w1 = { key: "Вега_CAPEX", text: "«Вега»: CAPEX в файле в тыс. тг. ИИ пересчитал в млн, сверьте ячейку CAPEX." };
var w2 = { key: "Зета_Дебиторка", text: "«Зета»: дебиторка не извлечена — пустая ячейка в файле. Укажите значение вручную." };
var w3 = { key: "Каппа_Выручка", text: "«Каппа»: в файле нет разбивки выручки — взято поле «Итого». Проверьте период." };
items.push(w1); items.push(w2); items.push(w3);
var h = '<ul class="warn-list">';
for (var i = 0; i < items.length; i++) {
var it = items[i];
var resolved = !!(state.notesResolved[it.key]);
h += '<li class="' + (resolved ? "resolved" : "") + '">';
h += icon(resolved ? "check-circle" : "warning");
h += "<span>" + esc(it.text) + "</span>";
if (!resolved) {
h += '<span class="fix"><button class="kt-ai-btn" data-variant="ghost" data-size="sm" data-act="fix-note" data-key="' + esc(it.key) + '">' + icon("check-circle") + "<span>Исправлено</span></button></span>";
}
h += "</li>";
}
return h + "</ul>";
}
function renderUpload() {
var files = state.files;
var okCount = 0, errCount = 0, pendCount = 0;
for (var i = 0; i < files.length; i++) {
if (files[i].status === "ok") okCount++;
else if (files[i].status === "risk") errCount++;
else pendCount++;
}
var html = "";
html += '<div class="kt-ai-page">';
html += '<div class="kt-ai-screen-head">';
html += '<div class="kt-ai-title-row">';
html += '<h1 class="kt-ai-h1">Загрузка и распознавание</h1>';
html += '<span class="kt-ai-text-xs kt-ai-mute">апрель 2026 · 15 дочерних</span>';
html += "</div>";
html += '<p class="kt-ai-subtitle kt-ai-mute">Файлы с общего ящика, обработка разными шаблонами и контроль качества извлечения показателей.</p>';
html += '</div>';
html += '<div class="kt-ai-screen-body kt-ai-stack-page">';
html += '<div class="kt-ai-tile-row">';
html += '<button class="kt-ai-btn kt-ai-tile-btn" data-act="upload-files">' + icon("upload") + "<span>Загрузить файлы с ящика</span></button>";
html += '<button class="kt-ai-btn kt-ai-btn-primary kt-ai-tile-btn" data-act="run-process">' + icon("sparkles") + "<span>Запустить распознавание</span></button>";
html += "</div>";
html += kpis([
{ label: "Файлов с ящика", val: String(files.length), hint: "апрель 2026, 15 дочерних" },
{ label: "Успешно обработано", val: String(okCount), status: "ok", badge: "в своре", hint: "показатели перенесены" },
{ label: "Ошибка формата", val: String(errCount), status: "risk", badge: errCount > 0 ? "ручной режим" : "нет", hint: "требуется сверка" },
{ label: "Ожидаются", val: String(pendCount), status: "muted", badge: pendCount > 0 ? "в пути" : "все пришли", hint: "писем с ящика" }
]);
html += '<section class="kt-ai-card kt-ai-section-card">';
html += '<div class="kt-ai-card-head">';
html += '<h2 class="kt-ai-h2">Файлы отчётов</h2>';
html += "</div>";
html += '<div class="kt-ai-table-wrap">';
html += '<table class="kt-ai-table">';
html += "<thead><tr><th>Файл</th><th>Компания</th><th>Статус</th><th>Дата загрузки</th><th></th></tr></thead>";
html += "<tbody>";
for (var f = 0; f < files.length; f++) {
var it = files[f];
var acts = "";
if (it.status === "risk") acts = '<button class="kt-ai-btn kt-ai-btn-ghost kt-ai-btn-sm" data-act="manual" data-f="' + f + '">Ручной режим</button>';
html += "<tr>";
html += "<td>" + esc(it.name) + "</td>";
html += "<td>" + esc(it.company) + "</td>";
html += "<td>" + pill(it.status, it.statusLabel) + "</td>";
html += '<td class="kt-ai-mono kd-nowrap" style="white-space:nowrap">' + esc(it.date || "—") + "</td>";
html += "<td>" + acts + "</td>";
html += "</tr>";
}
html += "</tbody></table></div>";
html += "</section>";
html += '<div class="kt-ai-grid-2">';
html += '<section class="kt-ai-card kt-ai-section-card">';
html += '<div class="kt-ai-card-head"><h2 class="kt-ai-h2">Журнал действий ИИ</h2></div>';
html += logHtml(state.log.slice(0, 10));
html += "</section>";
html += '<section class="kt-ai-card kt-ai-section-card">';
html += '<div class="kt-ai-card-head"><h2 class="kt-ai-h2">Предупреждения</h2></div>';
html += warningsHtml();
html += "</section>";
html += "</div>";
html += '<div class="kt-ai-actions-row">';
html += '<button class="kt-ai-btn kt-ai-btn-primary" data-act="go-check" disabled="' + (state.processed < 15 ? "true" : "false") + '">' + icon("check-circle") + "<span>Перейти к проверке данных</span></button>";
html += "</div>";
html += "</div>";
html += "</div>";
return html;
}
/* ── Экран 2: Проверка данных и отклонений ───────────────── */
function cellInner(mt, mIdx) {
var key = COMPANIES[mt.c] + "_" + METRIC_TYPES[mIdx];
var isManual = !!(state.manual[mt.id]);
var devVal = null;
if (mt.plan !== null && mt.fact !== null && mt.plan !== 0) {
devVal = Math.round(((mt.fact - mt.plan) / mt.plan) * 1000) / 10;
}
var extra = "";
if (devVal === null) extra = ' style="opacity:.55"';
var valText = (mt.fact === null) ? "—" : fmtNum(mt.fact);
var noteBadge = "";
if (UNIT_NOTE[key] !== undefined && !state.notesResolved[key]) {
if (mt.fact === null) noteBadge = '<span class="kt-ai-status-pill kt-ai-status-pill-mono" data-status="muted">не извлечён</span>';
else if (key === "Зета_Дебиторка") noteBadge = '<span class="kt-ai-status-pill kt-ai-status-pill-mono" data-status="muted">нет значения</span>';
else noteBadge = '<span class="kt-ai-status-pill kt-ai-status-pill-mono" data-status="warn">⚠ единицы</span>';
}
if (isManual) noteBadge += ' <span class="kt-ai-status-pill kt-ai-status-pill-mono" data-status="muted">ручная</span>';
var tipRows = (devVal !== null ? [{ label: "Отклонение", value: fmtDev(devVal) }] : []);
var tipJson = JSON.stringify({ title: "План: " + fmtNum(mt.plan) + " тг", rows: tipRows });
var inner = '<input class="cell-num kt-ai-mono" data-mid="' + mt.id + '" value="' + esc(valText) + '" type="text" inputmode="numeric" aria-label="' + esc(METRIC_TYPES[mIdx]) + '"' + (isManual ? " readonly" : "") + ">";
if (noteBadge) inner += noteBadge;
return '<td class="num"' + extra + ' data-kt-tip="' + tipJson + '">' + inner + "</td>";
}
function renderCheck() {
var big = 0, small = 0, noFact = 0, notes = 0;
for (var i = 0; i < state.metrics.length; i++) {
var mt = state.metrics[i];
var key = mt.company + "_" + mt.type;
if (UNIT_NOTE[key] !== undefined && !state.notesResolved[key]) notes++;
if (mt.plan === null || mt.fact === null || mt.plan === 0) { noFact++; continue; }
var dv = Math.abs(((mt.fact - mt.plan) / mt.plan) * 1000) / 10;
if (dv > 10) big++; else small++;
}
var html = "";
html += '<div class="kt-ai-page">';
html += '<div class="kt-ai-screen-head">';
html += '<div class="kt-ai-title-row">';
html += '<h1 class="kt-ai-h1">Проверка данных и отклонений</h1>';
html += '<span class="kt-ai-text-xs kt-ai-mute">шаг 2 из 3</span>';
html += "</div>";
html += '<p class="kt-ai-subtitle kt-ai-mute">Факт против плана по 15 компаниям. Кликните по значению, чтобы поправить вручную. Ячейки с отклонением больше 10% подсвечены.</p>';
html += "</div>";
html += '<div class="kt-ai-screen-body kt-ai-stack-page">';
if (state.approved) {
html += '<div class="kt-ai-banner" data-tone="ok">Данные утверждены и переданы в формирование результата.</div>';
}
html += kpis([
{ label: "Показателей в своре", val: String(state.metrics.length), hint: "15 компаний × 4" },
{ label: "Отклонение больше 10%", val: String(big), status: "risk", badge: "сверьте", hint: "подсвечены в таблице" },
{ label: "В пределах 10%", val: String(small), status: "ok", badge: "без риска", hint: "в допуске" },
{ label: "Пометки ИИ", val: String(notes + noFact), status: "muted", badge: "единицы, пропуски", hint: "на ячейке" }
]);
html += '<section class="kt-ai-card kt-ai-section-card">';
html += '<div class="kt-ai-card-head kt-ai-card-head-row">';
html += '<h2 class="kt-ai-h2">Свод показателей</h2>';
html += '</div>';
html += '<div class="kt-ai-toolbar kt-ai-toolbar-row">';
html += '<div class="kt-ai-toolbar-inner">';
html += '<div class="kt-ai-select-wrap"><select id="f-co" class="kt-ai-select" aria-label="Компания"><option value="all">Все компании</option>';
for (var c = 0; c < COMPANIES.length; c++) {
html += '<option value="' + c + '">' + esc(COMPANIES[c]) + "</option>";
}
html += "</select></div>";
html += '<div class="kt-ai-select-wrap"><select id="f-mt" class="kt-ai-select" aria-label="Показатель"><option value="all">Все показатели</option>';
for (var m = 0; m < METRIC_TYPES.length; m++) html += '<option value="' + esc(METRIC_TYPES[m]) + '">' + esc(METRIC_TYPES[m]) + "</option>";
html += "</select></div>";
html += '<div class="kt-ai-select-wrap"><select id="f-dev" class="kt-ai-select" aria-label="Отклонение"><option value="all">Все отклонения</option><option value="over10">Больше 10%</option><option value="under10">До 10%</option></select></div>';
html += '<input id="f-search" class="kt-ai-input" type="search" placeholder="Поиск по компании…" style="max-width:260px">';
html += '<span class="kt-ai-mute kt-ai-text-xs only-wide" id="f-count"></span>';
html += "</div>";
html += "</div>";
html += '<div class="kt-ai-table-wrap" data-framed="true">';
html += '<table class="kt-ai-table" id="check-table">';
html += "<thead><tr><th>Компания</th>";
html += "<th class='num'>Выручка</th><th class='num'>EBITDA</th><th class='num'>CAPEX</th><th class='num'>Дебиторка</th></tr></thead>";
html += "<tbody></tbody>";
html += "</table></div>";
html += "</section>";
html += '<div class="kt-ai-actions-row">';
html += '<button class="kt-ai-btn kt-ai-btn-primary" data-act="go-result" disabled="' + (state.approved ? "false" : "true") + '">' + icon("file") + "<span>Сформировать результат</span></button>";
html += '<button class="kt-ai-btn" data-act="approve" ' + (state.approved ? 'disabled="true"' : "") + ">" + icon("check-circle") + "<span>Утвердить данные</span></button>";
html += '<button class="kt-ai-btn kt-ai-btn-ghost" data-act="back-upload">' + icon("home") + "<span>К загрузке</span></button>";
html += "</div>";
html += "</div>";
html += "</div>";
return html;
}
function renderCheckTable() {
var tb = document.querySelector("#check-table tbody");
if (!tb) return;
var fCo = document.getElementById("f-co");
var fMt = document.getElementById("f-mt");
var fDev = document.getElementById("f-dev");
var fSearch = document.getElementById("f-search");
var co = fCo ? fCo.value : "all";
var mtSel = fMt ? fMt.value : "all";
var devSel = fDev ? fDev.value : "all";
var q = fSearch ? fSearch.value.toLowerCase().trim() : "";
var n = 0;
var trHtml = "";
for (var c = 0; c < COMPANIES.length; c++) {
if (co !== "all" && String(c) !== co) continue;
if (q && COMPANIES[c].toLowerCase().indexOf(q) === -1) continue;
n++;
var cells = "";
for (var mIdx = 0; mIdx < 4; mIdx++) {
var mt = metricOf("C" + c + "_M" + mIdx);
if (!mt) continue;
if (mtSel !== "all" && METRIC_TYPES[mIdx] !== mtSel) {
var devVal = (mt.plan !== null && mt.fact !== null && mt.plan !== 0) ? Math.round(((mt.fact - mt.plan) / mt.plan) * 1000) / 10 : null;
var val2 = (mt.fact === null) ? "—" : fmtNum(mt.fact);
cells += '<td class="num" style="opacity:.25">' + esc(val2) + "</td>";
continue;
}
if (devSel === "over10") {
var dv = (mt.plan !== null && mt.fact !== null && mt.plan !== 0) ? Math.round(((mt.fact - mt.plan) / mt.plan) * 1000) / 10 : null;
if (dv === null || Math.abs(dv) <= 10) {
cells += '<td class="num" style="opacity:.25">' + ((mt.fact === null) ? "—" : fmtNum(mt.fact)) + "</td>";
continue;
}
}
if (devSel === "under10") {
var dv2 = (mt.plan !== null && mt.fact !== null && mt.plan !== 0) ? Math.round(((mt.fact - mt.plan) / mt.plan) * 1000) / 10 : null;
if (dv2 !== null && Math.abs(dv2) > 10) {
cells += '<td class="num" style="opacity:.25">' + ((mt.fact === null) ? "—" : fmtNum(mt.fact)) + "</td>";
continue;
}
}
cells += cellInner(mt, mIdx);
}
trHtml += '<tr data-c="' + c + '"><td>' + esc(COMPANIES[c]) + "</td>" + cells + "</tr>";
}
tb.innerHTML = trHtml;
var fc = document.getElementById("f-count");
if (fc) fc.textContent = "Показано компаний: " + n + " из " + COMPANIES.length;
}
/* ── Экран 3: Формирование результата ────────────────────── */
function topDeviations() {
var list = [];
for (var i = 0; i < state.metrics.length; i++) {
var mt = state.metrics[i];
if (mt.plan === null || mt.fact === null || mt.plan === 0) continue;
var dev = Math.round(((mt.fact - mt.plan) / mt.plan) * 1000) / 10;
if (Math.abs(dev) > 10) {
list.push({ company: mt.company, type: mt.type, dev: dev });
}
}
list.sort(function (a, b) { return Math.abs(b.dev) - Math.abs(a.dev); });
return list;
}
function renderResult() {
var devs = topDeviations();
var approvedCount = 0;
for (var i = 0; i < state.metrics.length; i++) {
if (state.metrics[i].fact !== null) approvedCount++;
}
var ready = state.approved && !state.sent;
var html = "";
html += '<div class="kt-ai-page">';
html += '<div class="kt-ai-screen-head">';
html += '<div class="kt-ai-title-row">';
html += '<h1 class="kt-ai-h1">Формирование результата</h1>';
html += '<span class="kt-ai-text-xs kt-ai-mute">шаг 3 из 3</span>';
html += "</div>";
html += '<p class="kt-ai-subtitle kt-ai-mute">Сводный файл и записка финансовому директору. Отправьте, когда всё проверите.</p>';
html += "</div>";
html += '<div class="kt-ai-screen-body kt-ai-stack-page">';
if (!state.approved) {
html += '<div class="kt-ai-banner" data-tone="warn">Для формирования результата сначала утвердите данные на шаге «Проверка данных и отклонений».</div>';
} else if (state.sent) {
html += '<div class="kt-ai-banner" data-tone="ok">Результат отправлен финансовому директору ' + esc(state.sentAt || "") + ". Файл и записка доступны в архиве переписки.</div>";
} else {
html += '<div class="kt-ai-banner" data-tone="info">Готово к отправке: ' + approvedCount + " показателей, ключевые отклонения " + devs.length + " шт.</div>";
}
/* Предпросмотр сводного файла */
html += '<section class="kt-ai-card">';
html += '<div class="kt-ai-card-head"><h2 class="kt-ai-h2">Предпросмотр «Свод отчётов — апрель 2026.xlsx»</h2></div>';
html += '<div class="kt-ai-table-wrap" data-framed="true">';
html += '<table class="kt-ai-table report-table" id="report-table">';
html += "<thead><tr><th>Компания</th><th class='num'>Выручка</th><th class='num'>EBITDA</th><th class='num'>CAPEX</th><th class='num'>Дебиторка</th></tr></thead>";
html += "<tbody>";
for (var c = 0; c < 15; c++) {
html += '<tr><td>' + esc(COMPANIES[c]) + "</td>";
for (var m = 0; m < 4; m++) {
var mt = metricOf("C" + c + "_M" + m);
var val = (mt && mt.fact !== null) ? fmtNum(mt.fact) : "—";
html += '<td class="num">' + esc(val) + "</td>";
}
html += "</tr>";
}
html += "</tbody></table></div>";
html += "</section>";
html += '<div class="kt-ai-grid-2">';
/* Записка */
html += '<section class="kt-ai-card kt-ai-section-card">';
html += '<div class="kt-ai-card-head"><h2 class="kt-ai-h2">Записка финансовому директору</h2></div>';
html += '<div class="note-box">';
html += '<textarea id="note-text" class="kt-ai-textarea" rows="12" placeholder="Текст записки…">' + esc(state.noteText) + "</textarea>";
html += '<div class="kt-ai-filter-row">';
html += '<button class="kt-ai-btn kt-ai-btn-ghost kt-ai-btn-sm" data-act="note-reset">' + icon("refresh") + "<span>Сбросить на шаблон</span></button>";
html += '<span class="kt-ai-mute kt-ai-text-xs">Смена текста не влияет на файл свода.</span>';
html += "</div>";
html += "</div>";
html += "</section>";
/* Ключевые отклонения */
html += '<section class="kt-ai-card kt-ai-section-card">';
html += '<div class="kt-ai-card-head"><h2 class="kt-ai-h2">Ключевые отклонения для записки</h2></div>';
if (devs.length === 0) {
html += '<div class="kt-ai-empty">Отклонений больше 10% нет</div>';
} else {
html += '<ul class="dev-list">';
for (var d = 0; d < devs.length; d++) {
var it = devs[d];
var up = it.dev > 0;
html += "<li>";
html += "<span>" + esc(it.company) + " — " + esc(it.type) + "</span>";
html += '<span class="dev-list val ' + (up ? "up" : "down") + '">' + (up ? "+" : "") + it.dev.toFixed(1).replace(".", ",") + "%</span>";
html += "</li>";
}
html += "</ul>";
}
html += "</section>";
html += "</div>";
html += '<div class="kt-ai-actions-row">';
html += '<button class="kt-ai-btn kt-ai-btn-primary" ' + (state.sent ? 'disabled="true"' : "") + ' data-act="send">' + icon("send") + "<span>Отправить финансовому директору</span></button>";
if (state.sent) {
html += '<button class="kt-ai-btn" data-act="session-end">' + icon("logout") + "<span>Завершить сессию</span></button>";
}
html += "</div>";
html += "</div>";
html += "</div>";
return html;
}
/* ── Экран 4: О решении ──────────────────────────────────── */
function renderAbout() {
var items = [
{
title: "Предпосылки",
icon: "info",
items: [
"Свод отчётов 15 дочерних компаний выполняется вручную раз в месяц",
"Шаблон у каждой компании разный",
"Процесс долгий и содержит ошибки"
]
},
{
title: "Входные данные",
icon: "file",
kv: [
["Источники", "Файлы Excel с выручкой, EBITDA, CAPEX, дебиторкой, планом и фактом"],
["Канал передачи", "Письма на общий ящик"],
["Периодичность", "Ежемесячный"]
]
},
{
title: "Роль ИИ",
icon: "sparkles",
items: [
"Скачивает файлы с общего ящика",
"Распознаёт разные форматы",
"Переносит показатели в единый свод",
"Подсвечивает отклонения от плана больше 10%"
]
},
{
title: "Роль человека",
icon: "user",
items: [
"Проверяет корректность извлечения данных из нестандартных файлов",
"Контролирует единицы измерения и ошибки ИИ",
"Утверждает итоговый свод перед запиской",
"Решает, когда и как отправить результат финансовому директору"
]
},
{
title: "Результат",
icon: "check-circle",
items: [
"Сводный Excel",
"Записка финансовому директору к 15:00 понедельника"
]
},
{
title: "Ожидаемый эффект",
icon: "trophy",
items: [
"Ускорение процесса",
"Снижение количества ошибок",
"Ускорение принятия решений"
]
},
{
title: "Экономический эффект",
icon: "chart",
kv: [
["Расход времени сейчас", "3–4 часа в неделю"],
["Итого", "около 12–16 часов в месяц"]
]
},
{
title: "Риски и меры",
icon: "alert-circle",
items: [
"Ошибка ИИ на нестандартном файле — контроль осуществляет сводящий",
"Ошибка ИИ в единицах измерения — предупреждения в каждом файле",
"Журнал действий ИИ и возможность включить ручной режим"
]
}
];
var html = "";
html += '<div class="kt-ai-page">';
html += '<div class="kt-ai-screen-head">';
html += '<div class="kt-ai-title-row">';
html += '<h1 class="kt-ai-h1">О решении</h1>';
html += "</div>";
html += '<p class="kt-ai-subtitle kt-ai-mute">Свод отчётов дочерних компаний: автоматизация сбора и сверки 15 файлов с контролем отклонений от плана.</p>';
html += "</div>";
html += '<div class="kt-ai-screen-body kt-ai-stack-page">';
html += '<section class="kt-ai-card kt-ai-section-card">';
html += '<div class="kt-ai-card-head"><h2 class="kt-ai-h2">Паспорт решения</h2></div>';
html += '<div class="passport-kv">';
html += '<div class="kv-row"><span class="k">Наименование</span><span class="v">Свод отчётов дочерних компаний</span></div>';
html += '<div class="kv-row"><span class="k">Исполнитель</span><span class="v">Владелец процесса, сборка на платформе Vibe AI</span></div>';
html += '<div class="kv-row"><span class="k">Пользователь</span><span class="v">Сводящий, финансовый директор</span></div>';
html += "</div>";
html += "</section>";
html += '<div class="passport">';
for (var i = 0; i < items.length; i++) {
var it = items[i];
html += '<div class="kt-ai-card">';
html += '<h3 class="passport-title">' + icon(it.icon) + "<span>" + esc(it.title) + "</span></h3>";
if (it.items) {
html += '<ul class="passport-list">';
for (var j = 0; j < it.items.length; j++) html += "<li>" + esc(it.items[j]) + "</li>";
html += "</ul>";
}
if (it.kv) {
html += '<div class="passport-kv">';
for (var k2 = 0; k2 < it.kv.length; k2++) {
html += '<div class="kv-row"><span class="k">' + esc(it.kv[k2][0]) + '</span><span class="v">' + esc(it.kv[k2][1]) + "</span></div>";
}
html += "</div>";
}
html += "</div>";
}
html += "</div>";
html += "</div>";
html += "</div>";
return html;
}
/* ── Главный рендер ──────────────────────────────────────── */
function renderAll() {
var screen = document.getElementById("screen");
if (!screen) return;
var nav = document.getElementById("app-nav");
var html = "";
switch (state.screen) {
case "check":
html = renderCheck();
break;
case "result":
html = renderResult();
break;
case "about":
html = renderAbout();
break;
case "upload":
default:
html = renderUpload();
}
screen.innerHTML = html;
if (nav) {
var items = nav.querySelectorAll(".kt-ai-nav-item");
for (var i = 0; i < items.length; i++) {
if (items[i].getAttribute("data-nav") === state.screen) items[i].setAttribute("data-active", "true");
else items[i].removeAttribute("data-active");
}
}
if (state.screen === "check") renderCheckTable();
}
/* ── Скачивание сводного файла (реальный .csv) ───────────── */
function downloadCsv() {
var rows = [["Компания", "Выручка, тг", "EBITDA, тг", "CAPEX, тг", "Дебиторка, тг"]];
for (var c = 0; c < 15; c++) {
var row = [COMPANIES[c]];
for (var m = 0; m < 4; m++) {
var mt = metricOf("C" + c + "_M" + m);
row.push(mt && mt.fact !== null ? mt.fact : "");
}
rows.push(row);
}
var csv = rows.map(function (r) {
return r.map(function (cell) {
var s = String(cell);
if (s.indexOf(",") !== -1 || s.indexOf(";") !== -1) s = '"' + s.replace(/"/g, '""') + '"';
if (s.indexOf(";") !== -1) s = s.replace(/;/g, ",");
return s;
}).join(";");
}).join("\n");
var blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = "Свод отчётов — апрель 2026.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
}
/* ── Обработчики действий ────────────────────────────────── */
function onAction(e) {
var btn = e.target.closest("[data-act]");
if (!btn) return;
var act = btn.getAttribute("data-act");
switch (act) {
case "go-check":
if (state.processed < 15 || state.files.some(function (f) { return f.status !== "ok"; })) {
toast("Сначала дождитесь окончания обработки всех файлов", "warn");
return;
}
show("check");
break;
case "back-upload": show("upload"); break;
case "go-result": show("result"); break;
case "approve": doApprove(); break;
case "download": doDownload(); break;
case "send": doSend(); break;
case "note-reset":
state.noteText = DEFAULT_NOTE;
save();
renderAll();
toast("Текст записки сброшен на шаблон");
break;
case "session-end": doSessionEnd(); break;
case "upload-files": doUploadFiles(); break;
case "run-process": doRunProcess(); break;
case "manual": doManual(parseInt(btn.getAttribute("data-f"), 10)); break;
case "fix-note": doFixNote(btn.getAttribute("data-key")); break;
}
}
/* ── Действия процесса ───────────────────────────────────── */
function doApprove() {
var unfilled = 0;
for (var i = 0; i < state.metrics.length; i++) if (state.metrics[i].fact === null) unfilled++;
if (unfilled > 0 && !confirm("В своре осталось значений не заполнено: " + unfilled + ". Утвердить без их заполнения?")) return;
state.approved = true;
addLog("Сводящий утвердил итоговый свод — 15 компаний, 4 показателя");
save();
renderAll();
toast("Данные утверждены. Можно формировать результат.", "ok");
setTimeout(function () { show("result"); }, 700);
}
function doDownload() {
if (!state.approved) { toast("Сначала утвердите данные", "warn"); return; }
downloadCsv();
addLog("Сформирован сводный файл «Свод отчётов — апрель 2026.xlsx»");
save();
renderAll();
toast("Файл сформирован и скачан", "ok");
}
function doSend() {
if (!state.approved) { toast("Сначала утвердите данные", "warn"); return; }
if (state.sent) { toast("Результат уже отправлен", "info"); return; }
var ta = document.getElementById("note-text");
if (ta) state.noteText = ta.value;
var d = new Date();
state.sentAt = d.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
state.sent = true;
addLog("Результат (файл + записка) отправлен финансовому директору");
save();
renderAll();
toast("Результат отправлен финансовому директору", "ok");
}
function doUploadFiles() {
if (state.sent) { toast("Результат уже отправлен — сессия закрыта", "warn"); return; }
var pending = 0;
for (var i = 0; i < state.files.length; i++) if (state.files[i].status === "muted") pending++;
if (pending === 0) { toast("Новых файлов с общего ящика не найдено", "info"); return; }
for (var j = 0; j < state.files.length; j++) {
if (state.files[j].status === "muted") {
state.files[j].status = "ok";
state.files[j].statusLabel = "Успешно";
state.files[j].date = "06.05.2026, " + fmtTime(clock);
}
}
state.processed += pending;
addLog("Скачаны новые файлы с общего ящика: " + pending + " шт.");
save();
renderAll();
toast("Загружено файлов: " + pending, "ok");
}
function doRunProcess() {
var riskIdxs = [];
var unprocessed = 0;
for (var i = 0; i < state.files.length; i++) {
if (state.files[i].status === "muted") unprocessed++;
else if (state.files[i].status === "risk") riskIdxs.push(i);
}
if (unprocessed > 0) {
toast("Сначала загрузите файлы с общего ящика — их ещё " + unprocessed, "warn");
return;
}
state.processed = 15;
for (var r = 0; r < riskIdxs.length; r++) {
addLog("Файл «" + companyShort(state.files[riskIdxs[r]].company) + "»: ручной режим активирован, показатели перенесены сводящим");
}
addLog("Обработка завершена: 15 из 15 файлов, " + riskIdxs.length + " — в ручном режиме");
save();
renderAll();
toast("Распознавание завершено, готовы к проверке", "ok");
setTimeout(function () { show("check"); }, 700);
}
function doManual(i) {
if (i < 0 || i >= state.files.length) return;
var f = state.files[i];
if (f.status !== "risk") return;
if (!confirm("Открыть файл «" + f.name + "» в ручном режиме? Показатели будут перенесены вручную.")) return;
f.status = "ok";
f.statusLabel = "Успешно";
addLog("Файл «" + companyShort(f.company) + "»: ручной режим, показатели перенесены сводящим");
var okCount = 0, errCount = 0;
for (var l = 0; l < state.files.length; l++) {
if (state.files[l].status === "ok") okCount++;
if (state.files[l].status === "risk") errCount++;
}
state.processed = okCount + errCount;
save();
renderAll();
toast("Файл перенесён в ручном режиме", "ok");
}
function doFixNote(key) {
state.notesResolved[key] = true;
addLog("Пометка «" + key + "» снята — значение сверено сводящим");
save();
renderAll();
toast("Пометка снята, значение сверено", "ok");
}
function doSessionEnd() {
if (!confirm("Завершить сессию? Все данные этого отчёта будут сброшены.")) return;
try { localStorage.removeItem(LS_KEY); } catch (e) { /* ignore */ }
state = defaultState();
clock = 9 * 60 + 19;
save();
show("upload");
toast("Сессия завершена, можно начать новую", "ok");
}
/* ── Обработчики таблицы и фильтров ──────────────────────── */
/* Обработчики табличной зоны — делегированы на <body> (объявлены ниже),
чтобы их не надо было перебиндить при каждом рендере таблицы. */
/* ── Глобальные обработчики (делегирование на document) ──── */
document.addEventListener("click", function (e) {
if (e.target.closest && e.target.closest("[data-act]")) {
onAction(e);
return;
}
var tb = e.target.closest ? e.target.closest("#check-table tbody") : null;
var inp = e.target.closest ? e.target.closest(".cell-num") : null;
if (tb && inp) {
var mid = inp.getAttribute("data-mid");
var mt2 = metricOf(mid);
if (!mt2) return;
if (!state.manual[mid]) {
state.manual[mid] = true;
save();
renderCheckTable();
var nb = document.querySelector(".cell-num[data-mid='" + mid + "']");
if (nb) nb.focus();
}
}
});
document.addEventListener("change", function (e) {
if (!e.target || !e.target.closest) return;
var t = e.target;
if (!t) return;
if (t.id === "note-text") {
state.noteText = t.value;
save();
}
if (t.classList && t.classList.contains("cell-num")) {
var mid3 = t.getAttribute("data-mid");
var mt3 = metricOf(mid3);
if (!mt3 || !state.manual[mid3]) return;
var raw = String(t.value || "").replace(/\s/g, "").replace(/;/g, ",").replace(/\./g, "").replace(/,/g, ".");
if (raw === "" || raw === "-") mt3.fact = null;
else {
var n = parseFloat(raw);
mt3.fact = isNaN(n) ? null : n;
}
save();
renderCheckTable();
}
if (t.id === "f-co" || t.id === "f-mt" || t.id === "f-dev") renderCheckTable();
});
document.addEventListener("input", function (e) {
var t = e.target;
if (!t) return;
if (t.id === "f-search") renderCheckTable();
});
/* Старт */
renderAll();