/* Свод отчётов дочерних компаний — статичный прототип. Состояние и правки держим в 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, """); } 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 ''; } function pill(status, label) { return '' + esc(label) + ""; } 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 = "" + esc(text) + ""; 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 = '
'; for (var i = 0; i < cards.length; i++) { var c = cards[i]; h += '
'; h += '
' + esc(c.label) + "
"; h += '
' + esc(c.val) + ""; if (c.status && c.badge) { h += '' + esc(c.badge) + ""; } h += "
"; if (c.hint) h += '
' + esc(c.hint) + "
"; h += "
"; } return h + "
"; } function logHtml(entries) { var h = '"; } 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 = '"; } 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 += '
'; html += '
'; html += '
'; html += '

Загрузка и распознавание

'; html += 'апрель 2026 · 15 дочерних'; html += "
"; html += '

Файлы с общего ящика, обработка разными шаблонами и контроль качества извлечения показателей.

'; html += '
'; html += '
'; html += '
'; html += '"; html += '"; html += "
"; 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 += '
'; html += '
'; html += '

Файлы отчётов

'; html += "
"; html += '
'; html += ''; html += ""; html += ""; for (var f = 0; f < files.length; f++) { var it = files[f]; var acts = ""; if (it.status === "risk") acts = ''; html += ""; html += ""; html += ""; html += ""; html += '"; html += ""; html += ""; } html += "
ФайлКомпанияСтатусДата загрузки
" + esc(it.name) + "" + esc(it.company) + "" + pill(it.status, it.statusLabel) + "' + esc(it.date || "—") + "" + acts + "
"; html += "
"; html += '
'; html += '
'; html += '

Журнал действий ИИ

'; html += logHtml(state.log.slice(0, 10)); html += "
"; html += '
'; html += '

Предупреждения

'; html += warningsHtml(); html += "
"; html += "
"; html += '
'; html += '"; html += "
"; html += "
"; html += "
"; 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 = 'не извлечён'; else if (key === "Зета_Дебиторка") noteBadge = 'нет значения'; else noteBadge = '⚠ единицы'; } if (isManual) noteBadge += ' ручная'; var tipRows = (devVal !== null ? [{ label: "Отклонение", value: fmtDev(devVal) }] : []); var tipJson = JSON.stringify({ title: "План: " + fmtNum(mt.plan) + " тг", rows: tipRows }); var inner = '"; if (noteBadge) inner += noteBadge; return '' + inner + ""; } 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 += '
'; html += '
'; html += '
'; html += '

Проверка данных и отклонений

'; html += 'шаг 2 из 3'; html += "
"; html += '

Факт против плана по 15 компаниям. Кликните по значению, чтобы поправить вручную. Ячейки с отклонением больше 10% подсвечены.

'; html += "
"; html += '
'; if (state.approved) { html += '
Данные утверждены и переданы в формирование результата.
'; } 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 += '
'; html += '
'; html += '

Свод показателей

'; html += '
'; html += '
'; html += '
'; html += '
"; html += '
"; html += '
'; html += ''; html += ''; html += "
"; html += "
"; html += '
'; html += ''; html += ""; html += ""; html += ""; html += "
КомпанияВыручкаEBITDACAPEXДебиторка
"; html += "
"; html += '
'; html += '"; html += '"; html += '"; html += "
"; html += "
"; html += "
"; 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 += '' + esc(val2) + ""; 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 += '' + ((mt.fact === null) ? "—" : fmtNum(mt.fact)) + ""; 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 += '' + ((mt.fact === null) ? "—" : fmtNum(mt.fact)) + ""; continue; } } cells += cellInner(mt, mIdx); } trHtml += '' + esc(COMPANIES[c]) + "" + cells + ""; } 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 += '
'; html += '
'; html += '
'; html += '

Формирование результата

'; html += 'шаг 3 из 3'; html += "
"; html += '

Сводный файл и записка финансовому директору. Отправьте, когда всё проверите.

'; html += "
"; html += '
'; if (!state.approved) { html += '
Для формирования результата сначала утвердите данные на шаге «Проверка данных и отклонений».
'; } else if (state.sent) { html += '
Результат отправлен финансовому директору ' + esc(state.sentAt || "") + ". Файл и записка доступны в архиве переписки.
"; } else { html += '
Готово к отправке: ' + approvedCount + " показателей, ключевые отклонения " + devs.length + " шт.
"; } /* Предпросмотр сводного файла */ html += '
'; html += '

Предпросмотр «Свод отчётов — апрель 2026.xlsx»

'; html += '
'; html += ''; html += ""; html += ""; for (var c = 0; c < 15; c++) { html += '"; for (var m = 0; m < 4; m++) { var mt = metricOf("C" + c + "_M" + m); var val = (mt && mt.fact !== null) ? fmtNum(mt.fact) : "—"; html += '"; } html += ""; } html += "
КомпанияВыручкаEBITDACAPEXДебиторка
' + esc(COMPANIES[c]) + "' + esc(val) + "
"; html += "
"; html += '
'; /* Записка */ html += '
'; html += '

Записка финансовому директору

'; html += '
'; html += '"; html += '
'; html += '"; html += 'Смена текста не влияет на файл свода.'; html += "
"; html += "
"; html += "
"; /* Ключевые отклонения */ html += '
'; html += '

Ключевые отклонения для записки

'; if (devs.length === 0) { html += '
Отклонений больше 10% нет
'; } else { html += '
    '; for (var d = 0; d < devs.length; d++) { var it = devs[d]; var up = it.dev > 0; html += "
  • "; html += "" + esc(it.company) + " — " + esc(it.type) + ""; html += '' + (up ? "+" : "") + it.dev.toFixed(1).replace(".", ",") + "%"; html += "
  • "; } html += "
"; } html += "
"; html += "
"; html += '
'; html += '"; if (state.sent) { html += '"; } html += "
"; html += "
"; html += "
"; 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 += '
'; html += '
'; html += '
'; html += '

О решении

'; html += "
"; html += '

Свод отчётов дочерних компаний: автоматизация сбора и сверки 15 файлов с контролем отклонений от плана.

'; html += "
"; html += '
'; html += '
'; html += '

Паспорт решения

'; html += '
'; html += '
НаименованиеСвод отчётов дочерних компаний
'; html += '
ИсполнительВладелец процесса, сборка на платформе Vibe AI
'; html += '
ПользовательСводящий, финансовый директор
'; html += "
"; html += "
"; html += '
'; for (var i = 0; i < items.length; i++) { var it = items[i]; html += '
'; html += '

' + icon(it.icon) + "" + esc(it.title) + "

"; if (it.items) { html += '
    '; for (var j = 0; j < it.items.length; j++) html += "
  • " + esc(it.items[j]) + "
  • "; html += "
"; } if (it.kv) { html += '
'; for (var k2 = 0; k2 < it.kv.length; k2++) { html += '
' + esc(it.kv[k2][0]) + '' + esc(it.kv[k2][1]) + "
"; } html += "
"; } html += "
"; } html += "
"; html += "
"; html += "
"; 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"); } /* ── Обработчики таблицы и фильтров ──────────────────────── */ /* Обработчики табличной зоны — делегированы на (объявлены ниже), чтобы их не надо было перебиндить при каждом рендере таблицы. */ /* ── Глобальные обработчики (делегирование на 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();