(() => { "use strict"; const LS = "process-ai-v1"; // ---------- данные ---------- const DOC_DEF = [ { id: 1, name: "Договор аренды №247-09.pdf", ext: "scn", from: "Отдел аренды", date: "21.09.2026", fields: [ ["Контрагент", "ООО «ТрансЛогистик»", 98], ["Регистрационный номер", "210900041137", 97], ["Адрес объекта", "пр. Абая, 145", 91], ["Площадь, м²", "620", 88], ["Срок аренды", "12 месяцев", 95], ["Ответственное лицо", "К. Асетов", 84] ]}, { id: 2, name: "Акт сверки 2026-08.xlsx", ext: "xlsx", from: "Финансовый отдел", date: "20.09.2026", fields: [ ["Организация", "ТОО «СтройДом»", 99], ["Период", "август 2026", 100], ["Сальдо, тг", "1 240 500", 98], ["Расхождений", "нет", 96], ["Подпись", "Е. Тлеубаева", 76] ]}, { id: 3, name: "Счёт-фактура 5518.pdf", ext: "scn", from: "Поставщик материалов", date: "20.09.2026", fields: [ ["Номер", "5518", 99], ["Дата", "18.09.2026", 97], ["Сумма, тг", "384 900", 93], ["Срок оплаты", "28.09.2026", 90], ["Контрагент", "ТОО «МеталлБаза»", 81] ]}, { id: 4, name: "Заявка на доступ 1192.pdf", ext: "scn", from: "Служба безопасности", date: "19.09.2026", fields: [ ["Сотрудник", "А. Нурланов", 97], ["Должность", "инженер", 92], ["Объект", "ЦОД, 3 этаж", 89], ["Период", "до 31.12.2026", 94], ["Паспорт", "19 АБ 48210", 68] ]}, { id: 5, name: "Реестр платежей 12.xlsx", ext: "xlsx", from: "Финансовый отдел", date: "19.09.2026", fields: [ ["Период", "1–18.09.2026", 100], ["Платежей", "47", 99], ["Итого, тг", "5 812 300", 98], ["Просрочено", "0", 97], ["Проверено", "да", 95] ]}, { id: 6, name: "Копия лицензии №000214.docx", ext: "scn", from: "Юридический отдел", date: "18.09.2026", fields: [ ["Организация", "АО «СетьКом»", 98], ["Номер лицензии", "000214-005", 72], ["ОСН", "04112100", 80], ["Разрешение", "оптовая торговля", 93], ["Срок действия", "до 12.03.2027", 89] ]}, { id: 7, name: "Накладная ТТН 7731.pdf", ext: "scn", from: "Склад", date: "17.09.2026", fields: [ ["Номер ТТН", "7731", 99], ["Дата", "15.09.2026", 98], ["Наименование", "кабель, бухта 500 м", 91], ["Количество, шт", "24", 90], ["Получатель", "А. Нурланов", 86] ]}, { id: 8, name: "Служебная записка 302.pdf", ext: "scn", from: "Внутренний документ", date: "17.09.2026", fields: [ ["От", "отдел закупки", 96], ["Кому", "руководителю", 94], ["Дата", "16.09.2026", 97], ["Тема", "обновление ПО", 92], ["Основание", "протокол 11", 61] ]}, { id: 9, name: "Договор субаренды 518.pdf", ext: "scn", from: "Отдел аренды", date: "16.09.2026", fields: [ ["Субарендатор", "ТОО «ОфисЛайн»", 90], ["Рег. номер", "170930081211", 79], ["Объект", "ул. Толе би, 28, офис 4", 85], ["Площадь, м²", "85", 93], ["Плата за м²/мес", "4 200 тг", 88] ]}, { id: 10, name: "Приходный ордер №902.pdf", ext: "scn", from: "Финансовый отдел", date: "15.09.2026", fields: [ ["Номер", "902", 99], ["Дата", "14.09.2026", 98], ["Сумма, тг", "56 300", 86], ["Основание", "счёт 5490", 83], ["Касса", "№2", 71] ]} ]; const VERDICT = ["confirmed", "pending", "problem"]; const VERDICT_LABEL = { confirmed: "подтверждено", pending: "на проверке", problem: "проблемное" }; const VERDICT_CHIP = { confirmed: "ok", pending: "info", problem: "risk" }; const LOW = 85; // ниже — поле помечается // ---------- состояние ---------- let db = load(); function load() { try { const raw = localStorage.getItem(LS); if (raw) { const d = JSON.parse(raw); if (Array.isArray(d.docs) && d.docs.length) return d; } } catch (e) {} return seed(); } function seed() { const now = Date.now(); return { view: "inbox", filters: { inbox: "all", scan: "all", verify: "all" }, docs: DOC_DEF.map((d, i) => { const allHigh = d.fields.every(f => f[2] >= LOW); return Object.assign({}, d, { inboxStatus: i < 2 ? "new" : "read", scanStatus: i === 0 ? "scanning" : i <= 6 ? "done" : "failed", fields: d.fields.map(f => ({ name: f[0], value: f[1], conf: f[2], verdict: VERDICT[i === 0 ? 1 : 2] })), loadStatus: i <= 2 ? "loaded" : "idle", scanMs: 1800 + i * 340, ts: now - i * 3600e3 * 7 }); }) }; } function save() { try { localStorage.setItem(LS, JSON.stringify(db)); } catch (e) {} } // ---------- утилиты ---------- const $ = (id) => document.getElementById(id); const esc = (s) => String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); let toastTimer; function toast(msg) { const t = $("toast"); t.textContent = msg; t.classList.add("show"); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove("show"), 2200); } function kpi(label, value, hint) { return `
${label}
${value}
${hint || ""}
`; } function chip(status, text) { return `${text || VERDICT_LABEL[status]}`; } function confBadge(v) { const cls = v >= 90 ? "hi" : v >= 75 ? "mid" : "lo"; return `${v}%`; } function docInList(d) { const n = d.fields.length; const problems = d.fields.filter(f => f.verdict === "problem").length; let extra = ""; if (d.scanStatus === "scanning") extra = " · распознаётся…"; else if (d.scanStatus === "failed") extra = " · ошибка распознавания, повторить"; else if (d.scanStatus === "done") extra = ` · полей: ${n}` + (problems ? ` · проблемных: ${problems}` : ""); if (d.inboxStatus === "new") extra = (extra ? extra + " · " : "") + "(новое)"; if (d.loadStatus === "loaded") extra = " · загружено в систему"; return { n, extra }; } function lowProblems(d) { return d.fields.filter(f => f.verdict === "problem"); } function needsCheck(d) { return d.fields.some(f => f.verdict !== "confirmed"); } // ---------- шаги ---------- function renderSteps() { const defs = [ { id: "inbox", name: "Входящие", cnt: db.docs.filter(d => d.inboxStatus === "new").length }, { id: "scan", name: "Распознавание", cnt: db.docs.filter(d => d.scanStatus === "done" || d.scanStatus === "scanning" || d.scanStatus === "failed").length }, { id: "verify", name: "Проверка", cnt: db.docs.filter(d => d.scanStatus === "done" && needsCheck(d)).length }, { id: "loaded", name: "Загружено", cnt: db.docs.filter(d => d.loadStatus === "loaded").length } ]; $("steps").innerHTML = defs.map(s => ` `).join("") + ``; [...$("steps").querySelectorAll("[data-view]")].forEach(el => el.onclick = () => { db.view = el.dataset.view; save(); render(); }); } // ---------- экран 1: входящие ---------- function renderInbox() { const all = db.docs; const counts = { all: all.length, new: all.filter(d => d.inboxStatus === "new").length, read: all.filter(d => d.inboxStatus === "read").length }; const f = db.filters.inbox; $("f-inbox").innerHTML = Object.keys(counts).map(k => `` ).join(""); [...$("f-inbox").querySelectorAll("[data-f]")].forEach(el => el.onclick = () => { db.filters.inbox = el.dataset.f; save(); render(); }); const list = all.filter(d => f === "all" || (f === "new" ? d.inboxStatus === "new" : f === "read" ? d.inboxStatus === "read" : true)); const scanned = all.filter(d => d.scanStatus === "done").length; const failed = all.filter(d => d.scanStatus === "failed").length; const unstarted = all.filter(d => d.scanStatus === "idle").length; $("kpis-1").innerHTML = kpi("Всего документов", all.length, "за последние 5 дней") + kpi("Новых", counts.new, "ещё не открыты") + kpi("Ожидают распознавания", unstarted, "не запущены") + kpi("Требуют внимания", failed, "ошибка распознавания"); $("c-inbox").textContent = list.length + " из " + all.length; $("list-inbox").innerHTML = list.map(d => { const { extra } = docInList(d); return `
${d.ext}
${esc(d.name)}
${esc(d.from)} · ${extra}
${d.date} ${d.inboxStatus === "new" ? `` : ""}
`; }).join("") || emptyState(); bindDocs(); } function emptyState() { return `
Здесь пусто
Документы с такими параметрами не найдены
`; } // ---------- экран 2: распознавание ---------- function renderScan() { const all = db.docs; const counts = { all: all.length, scanning: all.filter(d => d.scanStatus === "scanning").length, done: all.filter(d => d.scanStatus === "done").length, failed: all.filter(d => d.scanStatus === "failed").length, idle: all.filter(d => d.scanStatus === "idle").length }; const f = db.filters.scan; $("f-scan").innerHTML = [ ["all", "Все"], ["scanning", "Распознаются"], ["done", "Готовы"], ["failed", "Ошибки"] ].map(([k, l]) => ``).join(""); [...$("f-scan").querySelectorAll("[data-f]")].forEach(el => el.onclick = () => { db.filters.scan = el.dataset.f; save(); render(); }); const list = all.filter(d => f === "all" || d.scanStatus === f); const sumMs = all.filter(d => d.scanStatus === "done").reduce((s, d) => s + d.scanMs, 0); const avg = counts.done ? Math.round(sumMs / counts.done / 100) / 10 : 0; const totalFields = all.filter(d => d.scanStatus === "done").reduce((s, d) => s + d.fields.length, 0); $("kpis-2").innerHTML = kpi("Готовы к проверке", counts.done, "из " + all.length) + kpi("Среднее время", counts.done ? avg + " с" : "—", "распознавание скана") + kpi("Извлечено полей", totalFields, "уверенность от 60%") + kpi("Ошибки", counts.failed, "повторить распознавание"); $("c-scan").textContent = list.length + " из " + all.length; $("list-scan").innerHTML = list.map(d => { let status, act; if (d.scanStatus === "scanning") { status = `распознаётся…`; act = ``; } else if (d.scanStatus === "failed") { status = `ошибка`; act = ``; } else if (d.scanStatus === "idle") { status = `ожидает`; act = ``; } else { const probs = lowProblems(d).length; status = probs ? `полей: ${d.fields.length} · проблемных: ${probs}` : `полей: ${d.fields.length}`; act = ` `; } return `
${d.ext}
${esc(d.name)}
${esc(d.from)} · ${d.date}
${status} ${act}
`; }).join("") || emptyState(); bindDocs(); } // ---------- экран 3: проверка ---------- function currentVerifyDoc() { const done = db.docs.filter(d => d.scanStatus === "done"); return done.find(d => needsCheck(d)) || done[0] || null; } function renderVerify() { $("f-verify").innerHTML = db.docs.filter(d => d.scanStatus === "done").map(d => `` ).join(""); [...$("f-verify").querySelectorAll("[data-did]")].forEach(el => el.onclick = () => { const d = byId(+el.dataset.did); if (d) { d._v = true; save(); render(); } }); // выбор документа: первый с неподтверждёнными полями let vdoc = null; for (const d of db.docs) if (d.scanStatus === "done" && d._v) { vdoc = d; break; } vdoc = vdoc || currentVerifyDoc(); db._vDoc = vdoc ? vdoc.id : null; save(); if (!vdoc) { $("kpis-3").innerHTML = kpi("Проверено", db.docs.filter(d => d.scanStatus === "done").length, "документов") + kpi("Поля", 0, "к проверке") + kpi("Проблемных", 0, "полей") + kpi("Готовность к загрузке", "—", ""); $("c-verify").textContent = "нет распознанных документов"; $("tb-verify").innerHTML = `Нет распознанных документов`; return; } const total = vdoc.fields.length; const ok = vdoc.fields.filter(x => x.verdict === "confirmed").length; const prob = vdoc.fields.filter(x => x.verdict === "problem").length; const avg = Math.round(vdoc.fields.reduce((s, x) => s + x.conf, 0) / total); $("kpis-3").innerHTML = kpi("Документ", esc(vdoc.name.split(".")[0]), vdoc.date) + kpi("Подтверждено", ok + " из " + total, "полей") + kpi("Проблемных", prob, "уверенность < " + LOW + "%") + kpi("Средняя уверенность", avg + "%", "по всем полям"); $("c-verify").textContent = `всего: ${db.docs.filter(d => d.scanStatus === "done").length}`; $("tb-verify").innerHTML = vdoc.fields.map((fld, i) => { const val = fld.verdict === "problem" ? `${esc(fld.value)}` : `${esc(fld.value)}`; let action = ""; if (fld.verdict === "confirmed") action = ``; else if (fld.verdict === "pending") action = ``; else action = ``; return ` ${esc(fld.name)} ${val} ${confBadge(fld.conf)} ${chip(fld.verdict)} ${action} `; }).join(""); [...$("tb-verify").querySelectorAll("[data-a]")].forEach(b => { b.onclick = () => handleVerifyAction(vdoc, b.dataset.a, +b.dataset.field); }); } function handleVerifyAction(doc, a, fi) { const fld = doc.fields[fi]; if (a === "vopen") { openDrawer(doc); return; } if (a === "vconfirm") { fld.verdict = "confirmed"; toast(`Поле «${fld.name}» подтверждено`); } if (a === "vproblem") { fld.verdict = "problem"; toast(`Поле «${fld.name}» помечено как проблемное`); } if (a === "vfix") { fld.verdict = "problem"; const val = prompt(`Исправить значение поля «${fld.name}»`, fld.value); if (val !== null) { fld.value = val; fld.conf = 100; fld.verdict = "confirmed"; toast("Значение исправлено и подтверждено"); } else return; } save(); render(); } function byId(id) { return db.docs.find(d => d.id === id); } // ---------- экран 4: загрузка ---------- function renderLoaded() { const all = db.docs; const loaded = all.filter(d => d.loadStatus === "loaded"); const pending = all.filter(d => d.scanStatus === "done" && !needsCheck(d) && d.loadStatus !== "loaded"); const fields = loaded.reduce((s, d) => s + d.fields.length, 0); const today = new Date().toISOString().slice(0, 10); $("kpis-4").innerHTML = kpi("Загружено в систему", loaded.length, "документов") + kpi("Полей передано", fields, "за текущий период") + kpi("Готово к загрузке", pending.length, "все поля подтверждены") + kpi("Осталось обработать", all.length - loaded.length, "документов"); // график: по дням (последние 7) const days = []; for (let i = 6; i >= 0; i--) { const dt = new Date(); dt.setDate(dt.getDate() - i); const key = dt.toISOString().slice(0, 10); const inDay = all.filter(d => new Date(d.ts).toISOString().slice(0, 10) === key).length; days.push({ label: dt.getDate().toString().padStart(2, "0") + "." + (dt.getMonth() + 1).toString().padStart(2, "0"), n: inDay, done: inDay, today: key === today }); } const max = Math.max(1, ...days.map(d => d.n)); $("bars").innerHTML = days.map(d => `
${d.label}
`).join(""); [...$("bars").querySelectorAll(".bar")].forEach(b => { b.onmouseenter = () => { $("chart-tip").textContent = b.dataset.tip; }; b.onmouseleave = () => { $("chart-tip").textContent = "наведите на столбец"; }; }); $("loaded-summary").innerHTML = `
Документов
${loaded.length}
Полей
${fields}
Сегодня
${days[6].n}
Просрочено
0
`; const ready = pending; const list = [...loaded, ...ready]; $("list-loaded").innerHTML = list.map(d => { const isL = d.loadStatus === "loaded"; const status = isL ? `загружено` : `готово к загрузке`; const act = isL ? `` : ``; return `
${d.ext}
${esc(d.name)}
${esc(d.from)} · полей: ${d.fields.length}
${d.date} ${act}
`; }).join("") || emptyState(); bindDocs(); } function doLoad(d) { if (needsCheck(d)) { toast("Сначала подтвердите все поля в проверке"); db.view = "verify"; render(); return; } d.loadStatus = "loaded"; toast(`«${d.name}» загружено в систему`); save(); render(); } // ---------- drawer ---------- function openDrawer(d) { $("dw-title").textContent = d.name; $("dw-sub").textContent = `${d.from} · ${d.date}`; // мини-скан: 2 строки "текста" с выделением проблемных значений let scan = "скан · " + d.name + "\n"; scan += d.fields.map(f => ` ${f.name}: ${f.value}` + (f.verdict === "problem" ? " [низкая уверенность]" : "")).join("\n"); $("dw-scan").innerHTML = esc(scan).split(" [низкая уверенность]").join(' низкая уверенность'); $("dw-fields").innerHTML = d.fields.map((f, i) => `
${esc(f.name)}
${confBadge(f.conf)} ${VERDICT_LABEL[f.verdict]}
${esc(f.value)}
` ).join(""); let foot = ``; if (d.scanStatus !== "done") { foot = `` + foot; } else if (needsCheck(d)) { foot = `` + foot; } else if (d.loadStatus !== "loaded") { foot = `` + foot; } $("dw-foot").innerHTML = foot; [...$("dw-foot").querySelectorAll("[data-a]")].forEach(b => b.onclick = () => { const a = b.dataset.a; if (a === "close") closeDrawer(); if (a === "gotoverify") { closeDrawer(); startVerify(d); } if (a === "load") { doLoad(d); closeDrawer(); } if (a === "rescan") { scanDoc(d, true); closeDrawer(); } }); $("drawer").classList.add("open"); $("drawer").setAttribute("aria-hidden", "false"); $("overlay").classList.add("open"); } function closeDrawer() { $("drawer").classList.remove("open"); $("drawer").setAttribute("aria-hidden", "true"); $("overlay").classList.remove("open"); } function startVerify(d) { d._v = true; db.view = "verify"; save(); render(); } // ---------- действия со списками ---------- function bindDocs() { document.querySelectorAll("[data-a]").forEach(b => { if (b.dataset.bound) return; b.dataset.bound = "1"; b.onclick = () => { const a = b.dataset.a, id = +b.dataset.id; const d = byId(id); if (!d) return; if (a === "view") openDrawer(d); if (a === "scan") scanDoc(d, true); if (a === "rescan") { scanDoc(d, true); } if (a === "openverify") startVerify(d); if (a === "load") doLoad(d); }; }); document.querySelectorAll(".doc[data-doc]").forEach(row => { if (row.dataset.bound) return; row.dataset.bound = "1"; row.onclick = (e) => { if (e.target.closest("[data-a]")) return; openDrawer(byId(+row.dataset.doc)); }; }); } function scanDoc(d, fromUi) { d.scanStatus = "scanning"; save(); render(); setTimeout(() => { let doc = byId(d.id); if (!doc) return; doc.scanStatus = Math.random() < 0.15 ? "failed" : "done"; doc.scanMs = 1800 + Math.round(Math.random() * 2400); doc.fields.forEach(f => { f.verdict = f.conf >= LOW ? "confirmed" : "problem"; }); save(); render(); if (fromUi) toast(doc.scanStatus === "done" ? `«${doc.name}» распознано` : `Ошибку распознавания «${doc.name}»`); }, 1600); } // ---------- общий рендер ---------- function render() { renderSteps(); document.querySelectorAll(".view").forEach(v => v.classList.remove("active")); const v = $("view-" + db.view); if (v) v.classList.add("active"); if (db.view === "inbox") renderInbox(); if (db.view === "scan") renderScan(); if (db.view === "verify") renderVerify(); if (db.view === "loaded") renderLoaded(); } // ---------- init ---------- document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDrawer(); }); $("overlay").onclick = closeDrawer; $("dw-close").onclick = closeDrawer; $("btn-reset").onclick = () => { if (!confirm("Сбросить все изменения и вернуть исходные данные?")) return; db = seed(); save(); render(); toast("Данные возвращены к исходным"); }; // завершаем документы, оставшиеся "в распознавании" после перезахода db.docs.filter(d => d.scanStatus === "scanning").forEach(d => { d.scanStatus = Math.random() < 0.15 ? "failed" : "done"; d.fields.forEach(f => { f.verdict = f.conf >= LOW ? "confirmed" : "problem"; }); }); save(); render(); })();