(() => {
"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 => `
`).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();
})();