/* Свод отчётности — статичный макет. Данные: localStorage с seed-набором. */ (function () { "use strict"; var KEY = "ii-swot-state-v1"; var WEEK = ["08.09", "15.09", "22.09", "29.09", "06.10", "13.10", "20.10", "27.10"]; var SEED = { orgs: [ { id: "o1", name: "ДО «Север»", files: 5, kpi: 14, volume: 128.4, date: "16.09.2026", status: "ready" }, { id: "o2", name: "ДО «Восток»", files: 4, kpi: 11, volume: 86.2, date: "16.09.2026", status: "ready" }, { id: "o3", name: "ДО «Юг-Транс»",files: 5, kpi: 13, volume: 112.7, date: "17.09.2026", status: "mismatch" }, { id: "o4", name: "ДО «Запад»", files: 3, kpi: 9, volume: 64.1, date: "17.09.2026", status: "ready" }, { id: "o5", name: "ДО «Центр»", files: 5, kpi: 14, volume: 143.9, date: "18.09.2026", status: "checked" }, { id: "o6", name: "ДО «Приозерье»",files: 4, kpi: 10, volume: 71.5, date: "18.09.2026", status: "ready" }, { id: "o7", name: "ДО «Степь»", files: 4, kpi: 12, volume: 95.3, date: "19.09.2026", status: "mismatch" }, { id: "o8", name: "ДО «Речной»", files: 3, kpi: 8, volume: 58.6, date: "19.09.2026", status: "pending" } ], week: [612.0, 648.5, 631.2, 670.8, 655.4, 689.1, 672.6, 760.7], weekDates: ["10.02.2026", "16.03.2026", "22.04.2026", "29.05.2026", "26.06.2026", "24.07.2026", "21.08.2026", "27.09.2026"], insights: [ { org: "ДО «Центр»", tone: "ok", text: "Самый стабильный показатель за период: отклонение от среднего −3,1%." }, { org: "ДО «Север»", tone: "ok", text: "Объём вырос на 4,2% к предыдущей неделе — выше среднего по своду." }, { org: "ДО «Речной»", tone: "warn", text: "Файлы получены в 18:42, свод не завершён — организация в статусе «ожидаемо»." }, { org: "ДО «Юг-Транс»", tone: "warn", text: "Расхождение: показатель за период в файле № 3 не сходится с итоговым на 2,3%." }, { org: "ДО «Степь»", tone: "warn", text: "Расхождение: в выгрузке отсутствует столбец «итого», требуется уточнение у организации." }, { org: "ДО «Восток»", tone: "info", text: "Динамика в пределах нормы, изменений нет." } ] }; var MAIL_SUBJECTS = ["Отчёт за неделю №37", "Выгрузка показателей", "Еженедельный отчёт", "Письмо со вложениями"]; var MAIL_TIMES = ["08:12", "09:47", "11:03", "14:26", "16:58", "18:42"]; var state = load(); var view = { page: "passport", mailFilter: "all", mailSearch: "", mailSel: {}, sumFilter: "all", insightFilter: "all" }; function load() { try { var raw = localStorage.getItem(KEY); if (raw) { var s = JSON.parse(raw); if (s && s.orgs) return s; } } catch (e) { /* повреждённые данные — пересоздаём */ } return JSON.parse(JSON.stringify(SEED)); } function save() { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch (e) { /* квота — просто не сохраняем */ } } function mailFor(org) { if (org.mail) return org.mail; var i = org.id.charCodeAt(1) % MAIL_TIMES.length; org.mail = { time: MAIL_TIMES[i], subject: MAIL_SUBJECTS[org.id.charCodeAt(1) % MAIL_SUBJECTS.length], size: (org.files * 1.4 + 0.3).toFixed(1) }; for (var f = 1; f < org.files; f++) { /* имена вложения в drawer */ } return org.mail; } var STATUS = { ready: { label: "свёл", tone: "green" }, checked: { label: "проверено", tone: "blue" }, mismatch: { label: "расхождение", tone: "orange" }, pending: { label: "ожидаемо", tone: "gray" } }; var STATUS_ORDER = ["ready", "checked", "mismatch", "pending"]; function $(id) { return document.getElementById(id); } function el(tag, cls, html) { var e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; } function fmt(n) { return n.toLocaleString("ru-RU", { minimumFractionDigits: 1, maximumFractionDigits: 1 }); } function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); } /* ---------- навигация ---------- */ function setPage(p) { view.page = p; document.querySelectorAll("#appNav button").forEach(function (b) { b.setAttribute("data-active", b.getAttribute("data-page") === p ? "true" : "false"); }); document.querySelectorAll(".page").forEach(function (s) { s.hidden = s.id !== "page-" + p; }); if (p === "inbox") renderInbox(); if (p === "summary") renderSummary(); if (p === "analysis") renderAnalysis(); window.scrollTo(0, 0); } /* ---------- тосты ---------- */ function toast(text, tone, action, cb) { var t = el("div", "kt-ai-toast", esc(text)); if (tone) t.dataset.tone = tone; if (action) { var b = el("button", "kt-ai-btn", action); b.dataset.size = "sm"; b.style.marginLeft = "var(--kt-ai-space-3)"; b.onclick = function () { cb && cb(); t.remove(); }; t.appendChild(b); } $("toasts").appendChild(t); setTimeout(function () { t.style.transition = "opacity .3s"; t.style.opacity = "0"; setTimeout(function () { t.remove(); }, 320); }, 4200); } /* ---------- модалка ---------- */ function modal(title, bodyHtml, actions) { var box = $("modalBox"); box.innerHTML = '" + bodyHtml + '"; $("modalOvl").style.display = "flex"; box.querySelectorAll("button[data-close]").forEach(function (b) { b.onclick = closeModal; }); } function closeModal() { $("modalOvl").style.display = "none"; } $("modalOvl").addEventListener("click", function (e) { if (e.target === this) closeModal(); }); document.addEventListener("keydown", function (e) { if (e.key === "Escape") { closeModal(); closeDrawer(); } }); /* ---------- drawer ---------- */ function openDrawer(html) { $("drawer").innerHTML = html; $("ovl").style.display = "block"; } function closeDrawer() { $("ovl").style.display = "none"; } $("ovlBg").addEventListener("click", closeDrawer); function orgDrawer(org) { var st = STATUS[org.status]; var files = ""; for (var i = 1; i <= org.files; i++) { files += '
' + '
' + esc(org.name) + " — файл " + i + ".xlsx" + '' + i + ".xlsx · " + (0.4 + i * 0.3).toFixed(1) + " МБ
" + '
'; } var issues = org.status === "mismatch" ? '
Нужна сверка
ИИ обнаружил расхождение показателей между файлами. Строки подсвечены в сводном файле, пока статус не станет «свёл».
' : ""; openDrawer( '
' + '

' + esc(org.name) + "

" + '' + st.label + "
" + '
Файлов получено' + org.files + "
" + '
Показателей' + org.kpi + "
" + '
Объём' + fmt(org.volume) + " млн
" + '

Файлы из письма

' + '
' + files + "
" + issues + '
' + (org.status === "pending" ? '' : "") + '
' ); } /* ================= ВХОДЯЩИЕ ================= */ function filteredMails() { var q = view.mailSearch.toLowerCase(); return state.orgs.filter(function (o) { if (view.mailFilter === "new" && o.status !== "pending" && o.status !== "ready") return false; if (view.mailFilter === "attention" && o.status !== "mismatch" && o.status !== "pending") return false; if (view.mailFilter === "done" && o.status !== "checked") return false; if (q && o.name.toLowerCase().indexOf(q) === -1 && mailFor(o).subject.toLowerCase().indexOf(q) === -1) return false; return true; }); } function renderInbox() { var orgs = filteredMails(); var totalFiles = state.orgs.reduce(function (a, o) { return a + o.files; }, 0); var filesIn = orgs.reduce(function (a, o) { return a + o.files; }, 0); var pending = state.orgs.filter(function (o) { return o.status === "pending"; }).length; var mismatch = state.orgs.filter(function (o) { return o.status === "mismatch"; }).length; var checked = state.orgs.filter(function (o) { return o.status === "checked"; }).length; $("kpiInbox").innerHTML = kpi(fmtCount(state.orgs.length), "организаций", "пришлют отчёты за неделю") + kpi(totalFiles, "файлов", "из писем за период") + kpi(pending + mismatch, "требуют внимания", "ожидаемо или с расхождением") + kpi(checked, "проверено", "человеком, готово в отчёт"); var chips = [ ["all", "Все", state.orgs.length], ["new", "Новые", state.orgs.filter(function (o) { return o.status === "pending" || o.status === "ready"; }).length], ["attention", "Требуют внимания", pending + mismatch], ["done", "Завершено", checked] ]; $("mailFilters").innerHTML = chips.map(function (c) { return '"; }).join(""); document.querySelectorAll("#mailFilters .kt-ai-filter-chip").forEach(function (b) { b.onclick = function () { view.mailFilter = b.dataset.f; renderInbox(); }; }); var rows = orgs.map(function (o) { var m = mailFor(o), st = STATUS[o.status]; var sel = !!view.mailSel[o.id]; return '' + '' + '' + (o.status === "pending" ? '' : '') + "" + '
' + esc(o.name) + "
" + '' + esc(m.subject) + "" + '' + o.files + "" + '' + esc(o.date) + " " + m.time + (o.status === "pending" ? ' · недавно' : "") + "" + '' + st.label + ""; }).join(""); $("mailRows").innerHTML = rows; $("mailEmpty").hidden = orgs.length > 0; $("mailCount").textContent = "Показано " + orgs.length + " из " + state.orgs.length + " · файлов: " + filesIn; $("ctInbox").textContent = state.orgs.length; document.querySelectorAll("#mailTable tr.row-click").forEach(function (tr) { tr.onclick = function (e) { if (e.target.closest("button")) return; if (e.target.tagName === "INPUT") { var id = tr.dataset.id; view.mailSel[id] = e.target.checked; renderInbox(); return; } orgDrawer(byId(tr.dataset.id)); }; }); } function kpi(v, l, h) { return '
' + v + "" + l + '' + h + "
"; } function fmtCount(n) { return String(n); } function byId(id) { return state.orgs.filter(function (o) { return o.id === id; })[0]; } function processMails() { var pending = state.orgs.filter(function (o) { return o.status === "pending"; }); var mismatch = state.orgs.filter(function (o) { return o.status === "mismatch"; }); if (!pending.length && !mismatch.length) { toast("Все письма уже обработаны", "ok"); return; } var names = pending.concat(mismatch).map(byId).map(function (o) { return o.name; }); modal("Свести файлы", "

ИИ разберёт файлы из " + names.length + " писем, обновит сводные показатели и отметит расхождения:

" + "
" + names.map(function (n) { return '
' + esc(n) + "
"; }).join("") + "
", ''); $("btnConfirmProcess").onclick = function () { state.orgs.forEach(function (o) { if (o.status === "pending" || o.status === "mismatch") o.status = "ready"; }); save(); closeModal(); toast("Файлы сведены. Показатели обновлены", "ok", null, null); renderInbox(); refreshKpiSummary(); setPage("summary"); }; } /* ================= СВОД ================= */ function renderSummary() { var orgs = state.orgs.filter(function (o) { return view.sumFilter === "all" || o.status === view.sumFilter; }); var tot = orgs.reduce(function (a, o) { return { f: a.f + o.files, k: a.k + o.kpi, v: a.v + o.volume }; }, { f: 0, k: 0, v: 0 }); var mismatch = orgs.filter(function (o) { return o.status === "mismatch"; }).length; var checked = orgs.filter(function (o) { return o.status === "checked"; }).length; $("kpiSum").innerHTML = kpi(state.orgs.length, "организаций в своде", "отчётная неделя 14–20.09") + kpi(fmt(tot.v), "млн, всего", "сумма показателей") + kpi(mismatch, "с расхождениями", "нужны перед проверкой") + kpi(checked, "проверено", "подтверждены человеком"); var filters = [ ["all", "Все", state.orgs.length], ["ready", "Сведёно", state.orgs.filter(function (o) { return o.status === "ready"; }).length], ["checked", "Проверено", checked], ["mismatch", "Расхождение", mismatch], ["pending", "Ожидаемо", state.orgs.filter(function (o) { return o.status === "pending"; }).length] ]; $("sumFilters").innerHTML = filters.map(function (c) { return '"; }).join(""); document.querySelectorAll("#sumFilters .kt-ai-filter-chip").forEach(function (b) { b.onclick = function () { view.sumFilter = b.dataset.f; renderSummary(); }; }); var rows = orgs.map(function (o) { var st = STATUS[o.status]; var hl = o.status === "mismatch" ? ' ' + "проверить" + "" : ""; return '' + esc(o.name) + "" + '' + o.files + "" + '' + o.kpi + "" + '' + fmt(o.volume) + hl + "" + '' + esc(o.date) + "" + '' + st.label + ""; }).join(""); rows += "Всего" + '' + tot.f + "" + '' + tot.k + "" + '' + fmt(tot.v) + "" + ""; $("sumRows").innerHTML = rows; $("sumEmpty").hidden = orgs.length > 0; document.querySelectorAll("#sumTable tr.row-click").forEach(function (tr) { tr.onclick = function () { orgDrawer(byId(tr.dataset.id)); }; }); } function refreshKpiSummary() { if (view.page === "summary") renderSummary(); } /* ================= АНАЛИЗ ================= */ function renderAnalysis() { var w = state.week; var last = w[w.length - 1], prev = w[w.length - 2]; var max = Math.max.apply(null, w), maxI = w.indexOf(max); var avg = w.reduce(function (a, b) { return a + b; }, 0) / w.length; var delta = (last - prev) / prev * 100; $("kpiAna").innerHTML = kpi(fmt(last), "млн, текущая неделя", state.weekDates[w.length - 1]) + kpi(fmt(avg), "млн, среднее", "за 8 недель") + kpi((delta >= 0 ? "+" : "") + fmt(delta) + "%", "к предыдущей", "неделя vs неделя") + kpi(state.orgs.length, "организаций", "в сводном файле"); drawChart(w, max); $("chMaxV").textContent = fmt(max); $("chMaxD").textContent = state.weekDates[maxI]; $("chAvgV").textContent = fmt(avg); $("chDeltaV").textContent = (delta >= 0 ? "+" : "") + fmt(delta) + "%"; var ins = state.insights.filter(function (i) { if (view.insightFilter === "attention") return i.tone === "warn"; if (view.insightFilter === "ok") return i.tone === "ok"; return true; }); $("insFilter").innerHTML = [ ["all", "Все", state.insights.length], ["attention", "Требуют внимания", state.insights.filter(function (i) { return i.tone === "warn"; }).length], ["ok", "В норме", state.insights.filter(function (i) { return i.tone === "ok"; }).length] ].map(function (c) { return '"; }).join(""); document.querySelectorAll("#insFilter .kt-ai-filter-chip").forEach(function (b) { b.onclick = function () { view.insightFilter = b.dataset.f; renderAnalysis(); }; }); $("insightsList").innerHTML = ins.map(function (i) { var icon = i.tone === "warn" ? "warning" : i.tone === "ok" ? "check-circle" : "info"; return '
' + '
' + esc(i.org) + "
" + esc(i.text) + "
"; }).join(""); } function drawChart(vals, max) { var svg = $("chartSvg"); var W = 640, H = 210, padL = 8, padB = 24, padT = 18; var n = vals.length; var cw = (W - padL * 2) / n; var barW = 26; var out = []; vals.forEach(function (v, i) { var h = (v / max) * (H - padB - padT); var x = padL + i * cw + (cw - barW) / 2; var y = H - padB - h; var label = WEEK[i]; var tip = state.weekDates[i] + ": " + fmt(v) + " млн"; out.push(''); out.push('' + label + ""); out.push('' + Math.round(v) + ""); }); svg.innerHTML = out.join(""); svg.querySelectorAll(".cc-bar").forEach(function (r) { r.addEventListener("mouseenter", function () { r.setAttribute("data-hover", "true"); }); r.addEventListener("mouseleave", function () { r.removeAttribute("data-hover"); }); r.addEventListener("focus", function () { r.setAttribute("data-hover", "true"); }); r.addEventListener("blur", function () { r.removeAttribute("data-hover"); }); }); } /* ================= действия ================= */ function downloadSelected() { var sel = state.orgs.filter(function (o) { return view.mailSel[o.id]; }); if (!sel.length) { toast("Сначала выберите письма в таблице (клик по строке)", "warn"); return; } toast("Подготовка " + sel.length + " файлов…", "info"); setTimeout(function () { toast("Готово: " + sel.reduce(function (a, o) { return a + o.files; }, 0) + " файлов в одну папку", "ok"); }, 900); } function exportSwat() { toast("Формируем единый Excel SWAT…", "info"); setTimeout(function () { modal("Excel SWAT готов", "
" + "
SWAT_дочки_14-20.09.2026.xlsx" + '' + state.orgs.length + " организаций · " + state.orgs.reduce(function (a, o) { return a + o.kpi; }, 0) + " показателей
" + "

Свод по всем дочерним организациям с анализом. Остаток: " + state.orgs.filter(function (o) { return o.status === "mismatch" || o.status === "pending"; }).length + " позиций требуют внимания.

", ''); var dl = $("modalBox").querySelector("[data-act=dl]"); if (dl) dl.onclick = function () { closeModal(); toast("Файл сохранён в загрузки", "ok"); }; }, 1100); } function refreshMail() { state.orgs.filter(function (o) { return o.status === "pending"; }).forEach(function (o) { o.status = "ready"; }); save(); toast("Почта проверена: новые письма не найдены", "ok"); renderInbox(); } /* ---------- init ---------- */ document.querySelectorAll("#appNav button").forEach(function (b) { b.onclick = function () { setPage(b.getAttribute("data-page")); }; }); $("btnProcess").onclick = processMails; $("btnDownload").onclick = downloadSelected; $("btnRefresh").onclick = refreshMail; $("btnExport").onclick = exportSwat; $("mailSearch").addEventListener("input", function (e) { view.mailSearch = e.target.value; renderInbox(); }); window.App = { goSummary: function (id) { closeDrawer(); view.sumFilter = "all"; renderSummary(); setPage("summary"); orgDrawer(byId(id)); }, resetMailFilters: function () { view.mailFilter = "all"; view.mailSearch = ""; $("mailSearch").value = ""; renderInbox(); } }; setPage("passport"); })();