999 lines
46 KiB
JavaScript
999 lines
46 KiB
JavaScript
/* Свод отчётов дочерних компаний — статичный прототип.
|
||
Состояние и правки держим в 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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
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();
|