502 lines
26 KiB
JavaScript
502 lines
26 KiB
JavaScript
(() => {
|
||
"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 `<div class="kpi"><div class="label">${label}</div><div class="value">${value}</div><div class="hint">${hint || ""}</div></div>`;
|
||
}
|
||
function chip(status, text) {
|
||
return `<span class="kt-ai-chip" data-tone="${VERDICT_CHIP[status] || "info"}">${text || VERDICT_LABEL[status]}</span>`;
|
||
}
|
||
function confBadge(v) {
|
||
const cls = v >= 90 ? "hi" : v >= 75 ? "mid" : "lo";
|
||
return `<span class="conf ${cls}">${v}%</span>`;
|
||
}
|
||
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 => `
|
||
<button class="step ${db.view === s.id ? "active" : ""}" data-view="${s.id}">
|
||
<span class="num">${s.id === "passport" ? "·" : ""}</span>
|
||
<span class="name">${s.name}</span>
|
||
${s.id !== "passport" ? `<span class="cnt">${s.cnt ? s.cnt + "" : ""}</span>` : ""}
|
||
</button>`).join("") +
|
||
`<button class="step ${db.view === "passport" ? "active" : ""}" data-view="passport">
|
||
<span class="num">·</span><span class="name">Паспорт</span>
|
||
</button>`;
|
||
[...$("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 =>
|
||
`<button class="fchip ${f === k ? "on" : ""}" data-f="${k}">${k === "all" ? "Все" : k === "new" ? "Непрочитанные" : "Прочитанные"} <span class="n">${counts[k]}</span></button>`
|
||
).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 `<div class="doc" data-doc="${d.id}">
|
||
<span class="fic">${d.ext}</span>
|
||
<div style="min-width:0">
|
||
<div class="d1">${esc(d.name)}</div>
|
||
<div class="d2">${esc(d.from)} · ${extra}</div>
|
||
</div>
|
||
<span class="when">${d.date}</span>
|
||
<span class="acts">
|
||
<button class="kt-ai-btn" data-a="view" data-id="${d.id}">Открыть</button>
|
||
${d.inboxStatus === "new" ? `<button class="kt-ai-btn" data-variant="primary" data-a="scan" data-id="${d.id}">Распознать</button>` : ""}
|
||
</span>
|
||
</div>`;
|
||
}).join("") || emptyState();
|
||
bindDocs();
|
||
}
|
||
function emptyState() {
|
||
return `<div class="kt-ai-empty">
|
||
<div class="icon-spot"><svg style="width:20px;height:20px" aria-hidden="true"><use href="#i-inbox"/></svg></div>
|
||
<div class="title">Здесь пусто</div>
|
||
<div>Документы с такими параметрами не найдены</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ---------- экран 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]) => `<button class="fchip ${f === k ? "on" : ""}" data-f="${k}">${l} <span class="n">${counts[k]}</span></button>`).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 = `<span class="kt-ai-chip" data-tone="info">распознаётся…</span>`;
|
||
act = `<button class="kt-ai-btn" disabled>Подождите</button>`;
|
||
} else if (d.scanStatus === "failed") {
|
||
status = `<span class="kt-ai-chip" data-tone="risk">ошибка</span>`;
|
||
act = `<button class="kt-ai-btn" data-variant="primary" data-a="rescan" data-id="${d.id}">Повторить</button>`;
|
||
} else if (d.scanStatus === "idle") {
|
||
status = `<span class="kt-ai-chip" data-tone="info">ожидает</span>`;
|
||
act = `<button class="kt-ai-btn" data-variant="primary" data-a="scan" data-id="${d.id}">Распознать</button>`;
|
||
} else {
|
||
const probs = lowProblems(d).length;
|
||
status = probs ? `<span class="kt-ai-chip" data-tone="warn">полей: ${d.fields.length} · проблемных: ${probs}</span>`
|
||
: `<span class="kt-ai-chip" data-tone="ok">полей: ${d.fields.length}</span>`;
|
||
act = `<button class="kt-ai-btn" data-variant="primary" data-a="openverify" data-id="${d.id}">К проверке</button>
|
||
<button class="kt-ai-btn" data-a="load" data-id="${d.id}">В систему</button>`;
|
||
}
|
||
return `<div class="doc" data-doc="${d.id}">
|
||
<span class="fic">${d.ext}</span>
|
||
<div style="min-width:0">
|
||
<div class="d1">${esc(d.name)}</div>
|
||
<div class="d2">${esc(d.from)} · ${d.date}</div>
|
||
</div>
|
||
<span class="when">${status}</span>
|
||
<span class="acts">${act}</span>
|
||
</div>`;
|
||
}).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 =>
|
||
`<button class="fchip ${db._vDoc === d.id ? "on" : ""}" data-did="${d.id}">${esc(d.name.split(".")[0])}</button>`
|
||
).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 = `<tr><td colspan="5" style="text-align:center;color:var(--kt-ai-fg-faint);padding:var(--kt-ai-space-8)">Нет распознанных документов</td></tr>`;
|
||
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" ? `<span class="val bad">${esc(fld.value)}</span>` : `<span class="val">${esc(fld.value)}</span>`;
|
||
let action = "";
|
||
if (fld.verdict === "confirmed") action = `<button class="kt-ai-btn" data-a="vopen" data-field="${i}">Показать</button>`;
|
||
else if (fld.verdict === "pending") action = `<button class="kt-ai-btn" data-variant="primary" data-a="vconfirm" data-field="${i}">Подтвердить</button><button class="kt-ai-btn" data-a="vproblem" data-field="${i}">Проблема</button>`;
|
||
else action = `<button class="kt-ai-btn" data-variant="danger" data-a="vfix" data-field="${i}">Исправить</button><button class="kt-ai-btn" data-a="vopen" data-field="${i}">Показать</button>`;
|
||
return `<tr data-d="${vdoc.id}">
|
||
<td><span class="fld">${esc(fld.name)}</span></td>
|
||
<td>${val}</td>
|
||
<td>${confBadge(fld.conf)}</td>
|
||
<td>${chip(fld.verdict)}</td>
|
||
<td class="num">${action}</td>
|
||
</tr>`;
|
||
}).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 => `
|
||
<div class="bar ${d.n > 0 ? "done" : ""}" data-tip="${d.label}: ${d.n}">
|
||
<div class="col" style="height:${Math.round(d.n / max * 150)}px"></div>
|
||
<span class="lbl">${d.label}</span>
|
||
</div>`).join("");
|
||
[...$("bars").querySelectorAll(".bar")].forEach(b => {
|
||
b.onmouseenter = () => { $("chart-tip").textContent = b.dataset.tip; };
|
||
b.onmouseleave = () => { $("chart-tip").textContent = "наведите на столбец"; };
|
||
});
|
||
|
||
$("loaded-summary").innerHTML = `
|
||
<dt>Документов</dt><dd>${loaded.length}</dd>
|
||
<dt>Полей</dt><dd>${fields}</dd>
|
||
<dt>Сегодня</dt><dd>${days[6].n}</dd>
|
||
<dt>Просрочено</dt><dd>0</dd>`;
|
||
|
||
const ready = pending;
|
||
const list = [...loaded, ...ready];
|
||
$("list-loaded").innerHTML = list.map(d => {
|
||
const isL = d.loadStatus === "loaded";
|
||
const status = isL ? `<span class="kt-ai-chip" data-tone="ok">загружено</span>` : `<span class="kt-ai-chip" data-tone="info">готово к загрузке</span>`;
|
||
const act = isL ? `<button class="kt-ai-btn" data-a="view" data-id="${d.id}">Открыть</button>`
|
||
: `<button class="kt-ai-btn" data-variant="primary" data-a="load" data-id="${d.id}">Загрузить</button>`;
|
||
return `<div class="doc" data-doc="${d.id}">
|
||
<span class="fic">${d.ext}</span>
|
||
<div style="min-width:0">
|
||
<div class="d1">${esc(d.name)}</div>
|
||
<div class="d2">${esc(d.from)} · полей: ${d.fields.length}</div>
|
||
</div>
|
||
<span class="when">${d.date}</span>
|
||
<span class="acts">${act}</span>
|
||
</div>`;
|
||
}).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(' <span class="hl">низкая уверенность</span>');
|
||
$("dw-fields").innerHTML = d.fields.map((f, i) =>
|
||
`<dt>${esc(f.name)}</dt><dd>${confBadge(f.conf)} <span style="color:var(--kt-ai-fg-muted);font-size:var(--kt-ai-text-xs);margin-left:6px">${VERDICT_LABEL[f.verdict]}</span><div class="val" style="margin-top:2px">${esc(f.value)}</div></dd>`
|
||
).join("");
|
||
let foot = `<button class="kt-ai-btn" data-a="close">Закрыть</button>`;
|
||
if (d.scanStatus !== "done") {
|
||
foot = `<button class="kt-ai-btn" data-variant="primary" data-a="rescan">Повторить распознавание</button>` + foot;
|
||
} else if (needsCheck(d)) {
|
||
foot = `<button class="kt-ai-btn" data-variant="primary" data-a="gotoverify">К проверке</button>` + foot;
|
||
} else if (d.loadStatus !== "loaded") {
|
||
foot = `<button class="kt-ai-btn" data-variant="primary" data-a="load">Загрузить в систему</button>` + 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();
|
||
})();
|