soglasovanie-zayavok-na-zaku/app.js

837 lines
47 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Рабочее решение: данные в server.js (node:http + data.json), UI на fetch. */
"use strict";
// ---------- состояние в localStorage ----------
// только UI-состояние (экран, роль, выбор), данные приходят с сервера
var LS_KEY = "sgl-zakupki-ui-v1";
var SOURCES = ["1С", "Почта"];
var STATUS = {
new: { label: "Новая", tone: "blue" },
checking: { label: "На проверке", tone: "orange" },
toLegal: { label: "На согласовании", tone: "orange" },
toSign: { label: "На подписании", tone: "purple" },
sent: { label: "Отправлена в 1С", tone: "green" },
rejected: { label: "Возвращена", tone: "red" },
disputed: { label: "Спорный случай", tone: "red" }
};
function statusChip(st) {
var s = STATUS[st] || STATUS.checking;
return '<span class="kt-ai-chip" data-tone="' + s.tone + '">' + s.label + "</span>";
}
var S;
function loadState() {
try {
var raw = localStorage.getItem(LS_KEY);
if (raw) { var st = JSON.parse(raw); if (st && st.v === 1) return st; }
} catch (e) {}
return { v: 1, screen: "work", approverRole: "legal", selected: "", regStatus: "all", regSrc: "all", regQuery: "", regRole: "legal" };
}
function saveState() {
try { localStorage.setItem(LS_KEY, JSON.stringify(S)); } catch (e) {}
}
// ---------- API ----------
function apiGet(url) {
return fetch(url).then(function (r) {
if (!r.ok) throw new Error("Сервер: " + r.status);
return r.json();
});
}
function apiPost(url, payload) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (r) {
if (!r.ok) throw new Error("Сервер: " + r.status);
return r.json();
});
}
function apiPatch(url, payload) {
return fetch(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}).then(function (r) {
if (!r.ok) throw new Error("Сервер: " + r.status);
return r.json();
});
}
// ---------- утилиты ----------
function fmtSum(x) {
return x.toLocaleString("ru-RU") + " ₸";
}
function esc(s) {
var v = String(s == null ? "" : s);
v = v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return v.replace(/"/g, "&#34;");
}
function toast(msg, tone) {
var stack = document.getElementById("toasts");
if (!stack) return;
var el = document.createElement("div");
el.className = "kt-ai-toast";
el.setAttribute("data-tone", tone || "");
el.textContent = msg;
stack.appendChild(el);
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 4600);
}
function modalOpen(title, body) {
var root = document.getElementById("modalRoot");
if (!root) return;
root.innerHTML = '<div class="kt-ai-modal-overlay"><div class="kt-ai-modal">' +
'<div class="modal-title">' + esc(title) + "</div>" + esc(body) +
'<div class="modal-actions"><button class="kt-ai-btn" id="mClose">Отмена</button></div></div></div>';
var btn = document.getElementById("mClose");
if (btn) btn.addEventListener("click", modalClose);
var ov = root.querySelector(".kt-ai-modal-overlay");
if (ov) ov.addEventListener("click", function (e) { if (e.target === ov) modalClose(); });
}
function modalClose() {
var root = document.getElementById("modalRoot");
if (root) root.innerHTML = "";
}
function nextIn(pool, skipId) {
var q = pool.filter(function (r) { return r.id !== skipId; });
q.sort(function (a, b) { return a.in < b.in ? 1 : -1; });
return q.length ? q[0].id : "";
}
// ================= РАБОЧИЙ СТОЛ ЗАКУПЩИКА =================
function renderWork() {
var list = window.__allRequests || [];
var queue = list.filter(function (r) { return r.st === "new" || r.st === "checking"; });
queue.sort(function (a, b) {
if ((a.st === "new") !== (b.st === "new")) return a.st === "new" ? -1 : 1;
return a.in < b.in ? 1 : -1;
});
var sel = null;
for (var i = 0; i < queue.length; i++) { if (queue[i].id === S.selected) { sel = queue[i]; break; } }
if (!sel && queue.length) { sel = queue[0]; S.selected = sel.id; }
var rows = queue.map(function (r) {
var selAttr = r.id === S.selected ? ' data-selected="true"' : "";
var srcIc = r.src === "1С" ? "spreadsheet" : "mail";
return '<tr data-id="' + r.id + '"' + selAttr + ">" +
"<td class=\"kt-ai-t-medium\">" + r.id + "</td>" +
"<td>" + esc(r.init) + "</td>" +
'<td class="num kt-ai-num">' + fmtSum(r.sum) + "</td>" +
"<td>" + statusChip(r.st) + "</td>" +
'<td><span class="src-cell"><svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#' + srcIc + '"></use></svg>' + r.src + "</span></td>" +
"</tr>";
}).join("");
var k1 = queue.length;
var k2 = queue.filter(function (r) { return r.st === "new"; }).length;
var k3 = queue.filter(function (r) { return r.st === "checking" && !r.codeTru; }).length;
var k4 = list.filter(function (r) { return r.st === "toSign" || r.st === "toLegal"; }).length;
var k5 = list.filter(function (r) { return r.st === "sent"; }).length;
var html = "<h1 class=\"kt-ai-h1\">Рабочий стол</h1>" +
'<p class="kt-ai-t-md" style="color:var(--kt-ai-fg-muted)">Проверка поступивших заявок на закупку: код ТРУ, план, сумма.</p>' +
'<div class="kt-ai-kpi-strip">' +
kpi(k1, "В очереди проверки", "новых заявок: " + k2) +
kpi(k3, "Без подтверждённого кода", "из ожидает проверки") +
kpi(k4, "На согласовании", "у юриста и руководителя") +
kpi(k5, "Отправлено в 1С", "с начала месяца") +
"</div>" +
'<div class="kt-ai-toolbar" style="margin-bottom:var(--kt-ai-space-4)">' +
'<button class="kt-ai-btn" data-variant="primary" id="btnNew"><svg class="kt-icon" aria-hidden="true"><use href="#plus"></use></svg>Новая заявка</button>' +
"</div>" +
'<div class="wd-grid"><div class="kt-ai-v" data-gap="tight">' +
'<div class="kt-ai-table-wrap" data-framed="true"><div class="kt-ai-table-toolbar">' +
'<span class="kt-ai-t-md kt-ai-t-semibold">Очередь</span><span class="kt-ai-meta">обновлено ИИ: ' + new Date().toLocaleDateString("ru-RU") + "</span>" +
'</div><table class="kt-ai-table"><thead><tr><th>Номер</th><th>Инициатор</th><th class="num">Сумма</th><th>Статус</th><th>Источник</th></tr></thead>' +
"<tbody>" + (rows || emptyRow("Очередь пуста", "Все поступившие заявки проверены.")) + "</tbody></table></div>" +
"</div>" +
workDetail(sel) +
"</div></div>";
document.getElementById("view").innerHTML = html;
var btnNew = document.getElementById("btnNew");
if (btnNew) btnNew.addEventListener("click", function () { openNewForm(); });
bindWork();
}
function kpi(v, label, hint) {
return '<div class="kt-ai-kpi"><span class="value">' + v + '</span><span class="label">' + esc(label) + "</span>" +
(hint ? '<span class="hint">' + esc(hint) + "</span>" : "") + "</div>";
}
function emptyRow(title, sub) {
return '<tr><td colspan="5"><div class="kt-ai-empty"><div class="title">' + esc(title) + "</div>" +
'<div class="sub" style="font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted)">' + esc(sub) + "</div></div></td></tr>";
}
function workDetail(r) {
if (!r) {
return '<aside class="wd-card"><div class="kt-ai-empty"><div class="title">В очереди нет заявок</div></div></aside>';
}
var hint = r.hints;
var codeErr = !r.codeTru;
var sumMismatch = r.id === "ЗК-0919";
var planIssue = r.plan !== "Да";
var isErr = codeErr || sumMismatch || planIssue;
var errReason = codeErr ? (r.id === "ЗК-0919" ? "Сумма не сходится с ценой в плане" : (r.dispute ? "Код несовпадает с оборудованием в заявке" : "ИИ не подтвердил код")) : (sumMismatch ? "Сумма не сходится с ценой в плане" : "Нет обоснования сверх плана");
var checks =
checkItem(planIssue ? "warn" : "ok", "В плане закупок", planIssue ? "В плане отсутствует, нужно обоснование" : "Строка плана найдена") +
checkItem(codeErr ? "err" : "ok", "Код ТРУ", codeErr ? errReason : "Подтверждён по справочнику") +
checkItem(sumMismatch ? "err" : "ok", "Сумма в пределах лимита", sumMismatch ? "Не сходится с ценой в плане" : (r.src === "1С" ? "Сверено со строкой 1С" : "В письмо от инициатора"));
var okCount = isErr ? 2 : 3;
return '<aside class="kt-ai-v" data-gap="block">' +
'<div class="wd-card"><div class="wd-card-h"><strong class="kt-ai-t-lg kt-ai-t-semibold">' + r.id + "</strong>" +
'<span class="sub">' + esc(r.subj) + "</span></div>" +
'<div class="wd-card-kpis">' +
kpiCell("Инициатор", esc(r.init)) + kpiCell("Сумма", fmtSum(r.sum)) +
kpiCell("План", r.plan === "Да" ? "В плане" : "Сверх плана") +
"</div>" +
'<div class="kt-ai-v" data-gap="tight">' +
'<span class="kt-ai-label">Поля заявки</span>' +
'<div class="drv"><span class="k">Код ТРУ в заявке</span><span class="v mono">' + r.trr + "</span></div>" +
'<div class="drv"><span class="k">Наличие в плане</span><span class="v">' + (r.plan === "Да" ? "Да, в плане закупок" : "Нет, сверх плана") + "</span></div>" +
'<div class="drv"><span class="k">Дата поступления</span><span class="v">' + r.in + " · " + r.src + "</span></div>" +
"</div>" +
'<div class="kt-ai-v" data-gap="tight">' +
"<span class=\"kt-ai-label\">Индикатор проверки</span>" +
'<div class="ai-hint" style="padding:0">' +
(isErr
? '<div class="kt-ai-banner" data-tone="risk" style="border:none;border-radius:var(--kt-ai-radius-xl)"><svg class="kt-icon" aria-hidden="true"><use href="#warning"></use></svg><div>Проверка не пройдена: ' + (okCount) + " из 3 пунктов. " + errReason + ". Верните заявку инициатору либо передайте в спорные случаи.</div></div>"
: '<div class="kt-ai-banner" data-tone="ok" style="border:none;border-radius:var(--kt-ai-radius-xl)"><svg class="kt-icon" aria-hidden="true"><use href="#check"></use></svg><div>Проверка пройдена: 3 из 3 пунктов. Заявка готова к передаче на согласование.</div></div>') +
"</div>" +
"</div>" +
'<div class="kt-ai-v" data-gap="tight">' +
'<span class="kt-ai-label">Проверки</span>' +
'<div class="check-list">' + checks + "</div>" +
"</div>" +
"</div>" +
aiPanel(r) +
'<div class="actions-row">' +
'<button class="kt-ai-btn" data-act="legal" ' + (isErr ? "disabled" : "") + '><svg class="kt-icon" aria-hidden="true"><use href="#send"></use></svg>Передать юристу</button>' +
'<button class="kt-ai-btn" data-act="return"><svg class="kt-icon" aria-hidden="true"><use href="#arrowLeft"></use></svg>Вернуть инициатору</button>' +
'<button class="kt-ai-btn" data-variant="ghost" data-act="dispute"><svg class="kt-icon" aria-hidden="true"><use href="#warning"></use></svg>В спорные</button>' +
"</div>" +
"</aside>";
}
function kpiCell(k, v) {
return '<div class="wd-kpi"><span class="v">' + v + '</span><span class="k">' + k + "</span></div>";
}
function checkItem(tone, title, why) {
var ic = tone === "ok" ? "check" : (tone === "err" ? "x" : "warning");
return '<div class="check-item ' + tone + '"><svg class="kt-icon" aria-hidden="true"><use href="#' + ic + '"></use></svg>' +
"<div>" + esc(title) + (why ? '<div class="why">' + esc(why) + "</div>" : "") + "</div></div>";
}
function aiPanel(r) {
var hint = r.hints;
var head = '<div class="ai-hint-t"><svg class="kt-icon" aria-hidden="true"><use href="#sparkles"></use></svg>Подсказка ИИ · код ТРУ</div>';
if (r.codeTru) {
return '<div class="ai-hint">' + head +
'<div class="ai-done"><svg class="kt-icon" aria-hidden="true"><use href="#check"></use></svg><div>Код <b>' + r.trr + "</b> подтверждён " +
(r.confirmedAt ? new Date(r.confirmedAt).toLocaleDateString("ru-RU") : "(ранее)") + ". Реестр обновлён автоматически.</div></div></div>";
}
if (!hint || !hint.length) return '<div class="ai-hint"><div class="kt-ai-empty"><div class="title">Подсказка не найдена</div></div></div>';
var best = hint[0];
var cands = hint.map(function (h, idx) {
return '<div class="cand-row' + (idx === 0 ? " sel" : "") + ' data-cand="' + idx + '"><div><div class="code">' + h.c + "</div>" +
'<div class="nm">' + esc(h.n) + "</div></div>" +
'<span class="kt-ai-chip" data-tone="' + (h.conf >= 80 ? "green" : "orange") + '">' + h.conf + "%</span></div>";
}).join("");
return '<div class="ai-hint">' + head +
'<div class="ai-conf"><span>Уверенность по первой подсказке</span><span class="kt-ai-progress"><div style="width:' + best.conf + '%"></div></span><span class="kt-ai-num">' + best.conf + "%</span></div>" +
'<div class="kt-ai-v" data-gap="tight">' + cands + "</div>" +
'<div class="ai-actions"><button class="kt-ai-btn" data-variant="primary" data-act="confirm"><svg class="kt-icon" aria-hidden="true"><use href="#check"></use></svg>Подтвердить код</button>' +
'<button class="kt-ai-btn" data-variant="ghost" data-act="other">Другой код</button></div></div>';
}
function bindWork() {
var trs = document.querySelectorAll("#view .kt-ai-table tbody tr[data-id]");
for (var i = 0; i < trs.length; i++) {
trs[i].addEventListener("click", function () {
S.selected = this.getAttribute("data-id");
saveState(); renderWork();
});
}
var btns = document.querySelectorAll('#view [data-act]');
for (var j = 0; j < btns.length; j++) {
btns[j].addEventListener("click", onWorkAction);
}
var cands = document.querySelectorAll("#view .cand-row[data-cand]");
for (var c = 0; c < cands.length; c++) {
cands[c].addEventListener("click", function () {
var rows = document.querySelectorAll("#view .cand-row[data-cand]");
for (var k = 0; k < rows.length; k++) rows[k].classList.remove("sel");
this.classList.add("sel");
});
}
}
function onWorkAction(ev) {
var act = ev.currentTarget.getAttribute("data-act");
var r = (window.__allRequests || []).find(function (x) { return x.id === S.selected; });
if (!r) return;
if (act === "confirm") {
var code = r.trr;
if (r.hints && r.hints.length) {
var selCand = document.querySelector("#view .cand-row.sel");
var ci = 0;
if (selCand) {
var ciRaw = parseInt(selCand.getAttribute("data-cand"), 10);
ci = !isNaN(ciRaw) ? ciRaw : 0;
if (ci >= r.hints.length) ci = 0;
}
var picked = r.hints[ci];
if (picked) code = picked.c;
}
apiPatch("/api/requests", { id: r.id, codeTru: true, trr: code })
.then(function () {
toast("Код " + code + " подтверждён, реестр обновлён", "ok");
return refresh();
})
.catch(function (e) { toast("Ошибка: " + e.message, "err"); });
} else if (act === "return") {
apiPatch("/api/requests", { id: r.id, st: "rejected", done: true, who: "закупщик" })
.then(function () {
S.selected = nextIn((window.__allRequests || []).filter(function (x) { return x.st === "new" || x.st === "checking"; }), r.id);
saveState();
toast(r.id + " возвращена инициатору", "warn");
return refresh();
})
.catch(function (e) { toast("Ошибка: " + e.message, "err"); });
} else if (act === "dispute") {
modalOpen("Спорный случай",
'<div style="font-size:var(--kt-ai-text-sm);line-height:1.6">Заявка ' + r.id + " будет передана руководителю для разбора: ИИ предложил код, который не совпадает с версией в заявке. Решение по ней примет руководитель.</div>" +
'<button class="kt-ai-btn" data-variant="primary" id="dOk" style="width:100%;margin-top:var(--kt-ai-space-5)">Передать руководителю</button>');
var ok = document.getElementById("dOk");
if (ok) ok.addEventListener("click", function () {
modalClose();
apiPatch("/api/requests", { id: r.id, st: "disputed", who: "закупщик" })
.then(function () {
S.selected = nextIn((window.__allRequests || []).filter(function (x) { return x.st === "new" || x.st === "checking"; }), r.id);
saveState();
toast(r.id + " передана руководителю на разбор", "warn");
return refresh();
})
.catch(function (e) { toast("Ошибка: " + e.message, "err"); });
});
return;
} else if (act === "other") {
toast("Подскажи код — выберите из списка или верните заявку инициатору", "warn");
return;
} else if (act === "legal") {
apiPatch("/api/requests", { id: r.id, st: "toLegal", done: true, who: "закупщик" })
.then(function () {
S.selected = nextIn((window.__allRequests || []).filter(function (x) { return x.st === "new" || x.st === "checking"; }), r.id);
saveState();
toast(r.id + " передана юристу на согласование", "ok");
return refresh();
})
.catch(function (e) { toast("Ошибка: " + e.message, "err"); });
}
}
// ================= НОВАЯ ЗАЯВКА (форма) =================
function openNewForm() {
modalOpen("Новая заявка",
'<form id="fNew" style="display:flex;flex-direction:column;gap:var(--kt-ai-space-4)">' +
fRow("Инициатор", '<input class="kt-ai-input" name="init" placeholder="Иванов И. И." required style="width:100%">') +
fRow("Сумма (₸)", '<input class="kt-ai-input" name="sum" type="number" min="1" step="1" placeholder="1000000" required style="width:100%">') +
fRow("Код ТРУ", '<input class="kt-ai-input" name="trr" placeholder="ТРУ-123.45" required style="width:100%">') +
fRow("Предмет", '<input class="kt-ai-input" name="subj" placeholder="Наименование поставки" required style="width:100%">') +
fRow("Источник", '<div class="kt-ai-v" data-gap="tight" style="width:100%"><label><input type="radio" name="src" value="1С" checked style="width:auto;margin-right:8px">1С</label><label><input type="radio" name="src" value="Почта" style="width:auto;margin-right:8px">Почта</label></div>') +
fRow("План", '<div class="kt-ai-v" data-gap="tight" style="width:100%"><label><input type="radio" name="plan" value="Да" checked style="width:auto;margin-right:8px">В плане</label><label><input type="radio" name="plan" value="Нет" style="width:auto;margin-right:8px">Сверх плана</label></div>') +
fRow("Обоснование", '<textarea class="kt-ai-textarea" name="justif" rows="3" placeholder="Причина закупки" style="width:100%"></textarea>') +
'<div class="kt-ai-v" data-gap="tight" style="width:100%"><button type="submit" class="kt-ai-btn" data-variant="primary" style="width:100%"><svg class="kt-icon" aria-hidden="true"><use href="#check"></use></svg>Создать заявку</button></div>' +
"</form>");
document.getElementById("fNew").addEventListener("submit", function (e) {
e.preventDefault();
var fd = new FormData(this);
var payload = {
init: fd.get("init"),
sum: parseInt(fd.get("sum"), 10) || 0,
trr: fd.get("trr"),
subj: fd.get("subj"),
src: fd.get("src"),
plan: fd.get("plan"),
justif: fd.get("justif")
};
this.querySelector('[type="submit"]').disabled = true;
apiPost("/api/requests/new", payload)
.then(function (res) {
modalClose();
toast("Заявка " + res.id + " создана", "ok");
S.selected = res.id; saveState();
return refresh();
})
.catch(function (err) {
toast("Ошибка создания: " + err.message, "err");
this.querySelector('[type="submit"]').disabled = false;
}.bind(this));
});
}
function fRow(label, inner) {
return '<div class="kt-ai-v" data-gap="tight"><span class="kt-ai-label">' + label + "</span>" + inner + "</div>";
}
// ================= РЕЕСТР ЗАЯВОК =================
var MONTHS = [["04.09", 7, "ok"], ["05.09", 9, "ok"], ["08.09", 8, "ok"], ["09.09", 11, "ok"], ["10.09", 8, "ok"],
["11.09", 13, "orange"], ["14.09", 7, "ok"], ["15.09", 10, "ok"], ["16.09", 9, "ok"],
["17.09", 12, "orange"], ["18.09", 8, "ok"], ["19.09", 11, "ok"]];
function daysInDays(r) {
if (!r.in) return 0;
var inD = new Date(r.in.split(".").reverse().join("-") + "T00:00:00");
var doneD = r.done ? new Date(r.done.split(".").reverse().join("-") + "T00:00:00") : new Date();
var diff = Math.round((doneD - inD) / 86400000);
return diff < 0 ? 0 : diff;
}
function renderRegistry() {
var list = window.__allRequests || [];
var st = S.regStatus, src = S.regSrc, q = (S.regQuery || "").toLowerCase();
var filtered = list.filter(function (r) {
if (st !== "all" && r.st !== st) return false;
if (src !== "all" && r.src !== src) return false;
if (q && (r.id.toLowerCase().indexOf(q) < 0) && (r.init.toLowerCase().indexOf(q) < 0) && (r.subj.toLowerCase().indexOf(q) < 0)) return false;
return true;
});
filtered.sort(function (a, b) { return a.in < b.in ? 1 : -1; });
var inWork = list.filter(function (r) { return ["new", "checking", "toLegal", "toSign", "disputed"].indexOf(r.st) >= 0; }).length;
var done = list.filter(function (r) { return r.st === "sent"; }).length;
var rej = list.filter(function (r) { return r.st === "rejected"; }).length;
var sumWork = 0;
list.forEach(function (r) { if (["new", "checking"].indexOf(r.st) >= 0) sumWork += r.sum; });
var rows = filtered.map(function (r) {
var srcIc = r.src === "1С" ? "spreadsheet" : "mail";
var disp = r.done ? r.done : "—";
var di = r.days != null ? r.days : daysInDays(r);
return '<tr data-id="' + r.id + '"><td class="kt-ai-t-medium">' + r.id + "</td>" +
"<td>" + esc(r.init) + "</td>" +
'<td class="num kt-ai-num">' + fmtSum(r.sum) + "</td>" +
"<td>" + statusChip(r.st) + "</td>" +
'<td class="num kt-ai-num' + (di > 2 ? " sl-amber" : "") + '">' + di + "</td>" +
"<td class=\"kt-ai-num\">" + r.in + "</td>" +
'<td class="kt-ai-num">' + disp + "</td>" +
'<td><span class="src-cell"><svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#' + srcIc + '"></use></svg>' + r.src + "</span></td></tr>";
}).join("");
var bars = MONTHS.map(function (m) {
var max = 13, h = Math.round((m[1] / max) * 100);
return '<div class="cc-bar" data-tone="' + m[2] + '" tabindex="0" style="height:' + h + '%" ' +
'data-kt-tip=\'{"title":"' + m[0] + '","rows":[{"label":"Заявок","value":"' + m[1] + '"},{"label":"Возвращено","value":"' + (m[2] === "orange" ? 2 : 1) + '"}]}\'></div>';
}).join("");
var html = "<h1 class=\"kt-ai-h1\">Реестр заявок</h1>" +
'<p class="kt-ai-t-md" style="color:var(--kt-ai-fg-muted)">Все заявки филиала с текущим статусом; реестр ведёт ИИ, статусы меняет человек.</p>' +
'<div class="kt-ai-kpi-strip">' +
kpi(inWork, "В работе", "новые + проверка + согласование") +
kpi(done, "Обработано", "отправлено в 1С в сентябре") +
kpi(rej, "Возвращено", "инициатору на доработку") +
kpi(Math.round(sumWork / 1000000), "млн ₸ в очереди", "сумма новых и проверяемых") +
"</div>" +
'<div class="kt-ai-v" data-gap="block">' +
'<div class="kt-ai-toolbar"><div class="kt-ai-filterbar">' +
inputSearch() +
'<span class="kt-ai-spacer"></span>' +
selStatus() +
selSrc() +
'<button class="kt-ai-btn" data-act="csv"><svg class="kt-icon" aria-hidden="true"><use href="#download"></use></svg>CSV</button>' +
'<button class="kt-ai-btn" data-variant="primary" data-act="xlsx"><svg class="kt-icon" aria-hidden="true"><use href="#download"></use></svg>Excel</button>' +
"</div></div>" +
'<div class="kt-ai-table-wrap"><table class="kt-ai-table"><thead><tr>' +
"<th>Номер</th><th>Инициатор</th><th class=\"num\">Сумма</th><th>Статус</th><th class=\"num\">Срок, дней</th><th>Поступила</th><th>Обработана</th><th>Источник</th>" +
"</tr></thead><tbody>" + (rows || emptyRow("Ничего не найдено", "Снимите фильтры — реестр за месяц заполнен.")) + "</tbody></table></div>" +
'<div class="chart-card"><div class="kt-ai-h3">Заявки по неделям сентября</div>' +
'<div class="cc-bars">' + bars + "</div>" +
'<div class="chart-foot" style="display:flex;gap:var(--kt-ai-space-5);margin-top:var(--kt-ai-space-3)">' +
'<span><span class="dot" style="background:var(--kt-ai-chart-blue)"></span>входящие</span>' +
'<span><span class="dot" style="background:var(--kt-ai-chart-orange)"></span>высокая нагрузка</span></div></div>' +
"<div class=\"kt-ai-conclusion\"><span class=\"title\">Что показывает реестр</span>" +
"<span class=\"text\">Пик нагрузки — 2–7 сентября: 10–13 заявок в день совпали с закрытием планового цикла. Возвратов в сентябре немного, большинство возвратов — дубли уже приобретённых услуг.</span></div>" +
"</div></div>";
document.getElementById("view").innerHTML = html;
bindRegistry();
}
function inputSearch() {
return '<input class="kt-ai-input reg-q" type="text" placeholder="Номер, инициатор, предмет…" value="' + esc(S.regQuery || "") + '" style="max-width:240px;height:32px">';
}
function selStatus() {
var opts = "";
for (var k in STATUS) {
if (!Object.prototype.hasOwnProperty.call(STATUS, k)) continue;
opts += '<option value="' + k + '"' + (S.regStatus === k ? " selected" : "") + ">" + STATUS[k].label + "</option>";
}
return '<div class="kt-ai-select-wrap" style="width:auto"><select class="kt-ai-select reg-status" style="height:32px">' +
'<option value="all"' + (S.regStatus === "all" ? " selected" : "") + ">Все статусы</option>" +
opts +
"</select></div>";
}
function selSrc() {
return '<div class="kt-ai-select-wrap" style="width:auto"><select class="kt-ai-select reg-src" style="height:32px">' +
'<option value="all"' + (S.regSrc === "all" ? " selected" : "") + ">Все источники</option>" +
'<option value="1С"' + (S.regSrc === "1С" ? " selected" : "") + ">1С</option>" +
'<option value="Почта"' + (S.regSrc === "Почта" ? " selected" : "") + ">Почта</option></select></div>";
}
function bindRegistry() {
var trs = document.querySelectorAll("#view .kt-ai-table tbody tr[data-id]");
for (var i = 0; i < trs.length; i++) {
trs[i].addEventListener("click", function () {
S.selected = this.getAttribute("data-id");
S.screen = "approve"; S.regRole = "legal";
saveState(); render();
});
}
var q = document.querySelector(".reg-q");
if (q) q.addEventListener("input", function () { S.regQuery = this.value; saveState(); renderRegistry(); });
var status = document.querySelector(".reg-status");
if (status) status.addEventListener("change", function () { S.regStatus = this.value; saveState(); renderRegistry(); });
var src = document.querySelector(".reg-src");
if (src) src.addEventListener("change", function () { S.regSrc = this.value; saveState(); renderRegistry(); });
var exp = document.querySelector('#view [data-act="csv"]');
if (exp) exp.addEventListener("click", function () { exportFile("/api/export.csv", "CSV"); });
var expX = document.querySelector('#view [data-act="xlsx"]');
if (expX) expX.addEventListener("click", function () { exportFile("/api/export.xlsx", "Excel"); });
}
function exportFile(url, label) {
window.location.href = url;
toast("Реестр выгружен в " + label, "ok");
}
// ================= СОГЛАСОВАНИЕ =================
function renderApprove() {
var role = S.regRole || "legal";
var list = window.__allRequests || [];
var pool = list.filter(function (r) { return role === "legal" ? r.st === "toLegal" : r.st === "toSign"; });
pool.sort(function (a, b) { return a.in < b.in ? 1 : -1; });
var sel = null;
for (var i = 0; i < pool.length; i++) { if (pool[i].id === S.selected) { sel = pool[i]; break; } }
var isDisputed = role === "boss" && sel && sel.st === "disputed";
if (!sel && pool.length) { sel = pool[0]; }
var rows = pool.map(function (r) {
var sAttr = r.id === S.selected ? ' data-selected="true"' : "";
return '<tr data-id="' + r.id + '"' + sAttr + '><td class="kt-ai-t-medium">' + r.id + "</td>" +
"<td>" + esc(r.init) + "</td>" +
'<td class="num kt-ai-num">' + fmtSum(r.sum) + "</td>" +
"<td class=\"kt-ai-num\">" + r.in + "</td>" +
"<td>" + statusChip(role === "legal" ? "toLegal" : "toSign") + "</td></tr>";
}).join("");
var dis = list.filter(function (r) { return r.st === "disputed"; });
var disRows = dis.map(function (r) {
var sAttr = isDisputed && r.id === S.selected ? ' data-selected="true"' : "";
return '<tr data-id="' + r.id + '" data-dispute="1"' + sAttr + '><td class="kt-ai-t-medium">' + r.id + "</td>" +
"<td>" + esc(r.init) + "</td>" +
'<td class="num kt-ai-num">' + fmtSum(r.sum) + "</td>" +
"<td class=\"kt-ai-num\">" + r.in + "</td>" +
"<td>" + statusChip("disputed") + "</td></tr>";
}).join("");
var roleTitle = role === "legal" ? "Согласование юриста" : "Подписание руководителя";
var poolLabel = role === "legal" ? "ждут согласования" : "ждут подписания";
var nToAct = pool.length + dis.length;
var k1 = pool.length, k2 = dis.length,
k3 = list.filter(function (r) { return r.st === "sent"; }).length,
k4 = list.filter(function (r) { return r.st === "rejected"; }).length;
var html = "<h1 class=\"kt-ai-h1\">" + roleTitle + "</h1>" +
'<p class="kt-ai-t-md" style="color:var(--kt-ai-fg-muted)">Заявки, ' + poolLabel + " после проверки закупщиком.</p>" +
'<div class="kt-ai-kpi-strip">' +
kpi(k1, "Ждут моего действия", "в очереди сейчас") +
kpi(k2, "Спорные случаи", "ошибки ИИ по кодам ТРУ") +
kpi(k3, "Отправлено в 1С", "сентябрь") +
kpi(k4, "Возвращено", "на доработку") +
"</div>" +
'<div class="kt-ai-toolbar"><div class="kt-ai-filterbar">' +
'<span class="kt-ai-label">Роль</span>' +
'<div class="kt-ai-select-wrap" style="width:auto"><select class="kt-ai-select sel-role" style="height:32px">' +
'<option value="boss"' + (role === "boss" ? " selected" : "") + ">Руководитель</option>" +
'<option value="legal"' + (role === "legal" ? " selected" : "") + ">Юрист</option></select></div>" +
'<span class="kt-ai-spacer"></span><span class="kt-ai-meta">на рассмотрении: ' + nToAct + "</span></div></div>" +
'<div class="appr-grid" style="margin-top:var(--kt-ai-space-6)"><div class="kt-ai-v" data-gap="tight">' +
'<div class="kt-ai-table-wrap"' + (disRows ? ' data-framed="true"' : "") + '><table class="kt-ai-table"><thead><tr>' +
"<th>Номер</th><th>Инициатор</th><th class=\"num\">Сумма</th><th>Поступила</th><th>Статус</th>" +
"</tr></thead><tbody>" + (rows || emptyRow("Очередь пуста", "Все заявки переданы на следующий этап.")) + disRows + "</tbody></table></div>" +
"</div>" +
apprDetail(sel, role, isDisputed) +
"</div></div>";
document.getElementById("view").innerHTML = html;
bindApprove(role);
}
function apprDetail(r, role, isDisputed) {
if (!r) {
return '<aside class="wd-card"><div class="kt-ai-empty"><div class="title">Выберите заявку из очереди</div></div></aside>';
}
var trrNote = r.dispute
? 'ИИ предложил «' + r.trr + '», но версия оборудования в заявке не сходится — на разборе.'
: "Подтверждён закупщиком.";
var subj = isDisputed ? "Разбор спорного случая" : "Детали заявки";
return '<aside class="kt-ai-v" data-gap="block">' +
'<div class="wd-card"><div class="wd-card-h"><strong class="kt-ai-t-lg kt-ai-t-semibold">' + r.id + "</strong>" +
'<span class="sub">' + esc(r.subj) + "</span></div>" +
'<div style="margin-top:var(--kt-ai-space-2)">' + statusChip(isDisputed ? "disputed" : (role === "legal" ? "toLegal" : "toSign")) + "</div>" +
'<div class="wd-card-kpis">' +
kpiCell("Инициатор", esc(r.init)) + kpiCell("Сумма", fmtSum(r.sum)) +
kpiCell("План", r.plan === "Да" ? "В плане" : "Сверх плана") +
"</div>" +
'<div class="kt-ai-v" data-gap="tight">' +
'<span class="kt-ai-label">Код ТРУ</span>' +
'<div class="drv"><span class="k">Подтверждённый код</span><span class="v mono">' + r.trr + "</span></div>" +
'<div class="check-item ' + (r.dispute ? "warn" : "ok") + '" style="margin-top:var(--kt-ai-space-2)"><svg class="kt-icon" aria-hidden="true"><use href="#' + (r.dispute ? "warning" : "check") + '"></use></svg><div class="why">' + trrNote + "</div></div>" +
"</div>" +
'<div class="kt-ai-v" data-gap="tight">' +
'<span class="kt-ai-label">Обоснование</span>' +
'<div class="justif"><span class="k">' + esc(r.init) + "</span><span>" + esc(r.justif) + "</span></div>" +
"</div>" +
"</div>" +
(isDisputed
? '<div class="actions-row">' +
'<button class="kt-ai-btn" data-variant="primary" data-act="approve"><svg class="kt-icon" aria-hidden="true"><use href="#check"></use></svg>Подтвердить решение</button>' +
'<button class="kt-ai-btn" data-variant="danger" data-act="reject"><svg class="kt-icon" aria-hidden="true"><use href="#x"></use></svg>Вернуть инициатору</button>' +
"</div>"
: '<div class="actions-row">' +
'<button class="kt-ai-btn" data-variant="primary" data-act="approve"><svg class="kt-icon" aria-hidden="true"><use href="#check"></use></svg>' + (role === "legal" ? "Согласовать" : "Подписать") + "</button>" +
'<button class="kt-ai-btn" data-variant="danger" data-act="reject-draft"><svg class="kt-icon" aria-hidden="true"><use href="#x"></use></svg>Не согласовать</button>' +
"</div>") +
"</aside>";
}
function bindApprove(role) {
var trs = document.querySelectorAll("#view .kt-ai-table tbody tr[data-id]");
for (var i = 0; i < trs.length; i++) {
trs[i].addEventListener("click", function () {
S.selected = this.getAttribute("data-id");
saveState(); renderApprove();
});
}
var roleSel = document.querySelector("#view .sel-role");
if (roleSel) roleSel.addEventListener("change", function () {
S.regRole = this.value; S.selected = ""; saveState(); renderApprove();
});
var acts = document.querySelectorAll("#view [data-act]");
for (var a = 0; a < acts.length; a++) acts[a].addEventListener("click", onApproveAction);
}
function onApproveAction(ev) {
var act = ev.currentTarget.getAttribute("data-act");
var r = (window.__allRequests || []).find(function (x) { return x.id === S.selected; });
if (!r) return;
var role = S.regRole;
var who = role === "legal" ? "юрист" : "руководитель";
var isDisputed = r.st === "disputed";
if (act === "approve") {
var payload = { id: r.id, who: who };
if (role === "legal") payload.st = "toSign";
else { payload.st = "sent"; payload.done = true; if (isDisputed) payload.dispute = false; }
apiPatch("/api/requests", payload)
.then(function () {
var msg = isDisputed ? r.id + ": спорный случай разобран, отправлена в 1С" : (role === "legal" ? r.id + " согласована и передана руководителю" : r.id + " подписана и отправлена в 1С");
toast(msg, "ok");
S.selected = ""; saveState();
return refresh();
})
.catch(function (e) { toast("Ошибка: " + e.message, "err"); });
} else if (act === "reject" || act === "reject-draft") {
var payload2 = { id: r.id, st: "rejected", done: true, who: who };
if (act === "reject") payload2.dispute = false;
apiPatch("/api/requests", payload2)
.then(function () {
toast(r.id + " возвращена инициатору", "warn");
S.selected = ""; saveState();
return refresh();
})
.catch(function (e) { toast("Ошибка: " + e.message, "err"); });
}
}
// ================= О РЕШЕНИИ =================
function renderAbout() {
var html = "<h1 class=\"kt-ai-h1\">О решении</h1>" +
'<p class="kt-ai-t-md" style="color:var(--kt-ai-fg-muted)">Согласование заявок на закупку филиала — паспорт ИИ-решения.</p>' +
'<div class="kt-ai-v" data-gap="block">' +
'<div class="kt-ai-card" style="padding:var(--kt-ai-space-6)">' +
'<table class="pas-table"><tbody>' +
tr("Наименование", "Согласование заявок на закупку филиала") +
tr("Исполнитель", "Владелец процесса — сборка на платформе Vibe AI") +
tr("Пользователи", "Закупщик · Юрист · Руководитель") +
tr("Дата внедрения", "—") +
"</tbody></table></div>" +
'<div class="kt-ai-card" style="padding:var(--kt-ai-space-6)"><div class="kt-ai-h3" style="margin-bottom:var(--kt-ai-space-4)">Предпосылки</div>' +
'<div class="kt-ai-v" data-gap="tight">' +
p("Заявки на закупку поступают из 1С и письмами, около 150 в месяц.") +
p("Процесс включает ручную проверку данных (код ТРУ, план, сумма) и последовательную передачу между участниками.") +
"</div></div>" +
'<div class="kt-ai-card" style="padding:var(--kt-ai-space-6)"><div class="kt-ai-h3" style="margin-bottom:var(--kt-ai-space-4)">Входные данные и источники</div>' +
'<div class="kt-ai-v" data-gap="tight">' +
p("Заявки из 1С и электронной почты.") +
p("Реестр в Excel: номер, инициатор, сумма, статус, даты.") +
"</div></div>" +
'<div class="kt-ai-card" style="padding:var(--kt-ai-space-6)"><div class="kt-ai-h3" style="margin-bottom:var(--kt-ai-space-4)">Процессы для внедрения ИИ</div>' +
'<div class="kt-ai-v" data-gap="tight">' +
p("Проверка по чек-листу.") +
p("Подсказка кода ТРУ.") +
p("Ведение реестра и напоминания.") +
"</div></div>" +
'<div class="kt-ai-card" style="padding:var(--kt-ai-space-6)"><div class="kt-ai-h3" style="margin-bottom:var(--kt-ai-space-4)">Шаги процесса</div>' +
steps() + "</div>" +
passSteps() +
risks() +
"</div></div>";
document.getElementById("view").innerHTML = html;
}
function tr(k, v) { return "<tr><td>" + esc(k) + "</td><td>" + esc(v) + "</td></tr>"; }
function p(t) { return '<p class="kt-ai-t-md" style="color:var(--kt-ai-fg)">' + esc(t) + "</p>"; }
function steps() {
var steps = [
"Поступление заявки из 1С или по электронной почте",
"Закупщик проверяет код ТРУ, наличие в плане и сумму",
"ИИ подсказывает код ТРУ и ведёт реестр",
"Закупщик возвращает заявку инициатору при ошибке",
"Передача заявки юристу для согласования",
"Руководитель подписывает заявку",
"Закупщик отправляет заявку в 1С и уведомляет инициатора"
];
return '<div class="kt-ai-v" data-gap="tight">' + steps.map(function (s, i) {
return '<div class="pas-step"><span class="kt-ai-num-badge" data-state="' + (i === 1 ? "current" : "done") + '">' + (i + 1) + '</span><div class="b">' + esc(s) + "</div></div>";
}).join("") + "</div>";
}
function passSteps() {
var ai = ["ИИ подсказывает код ТРУ", "ИИ ведёт реестр", "ИИ обновляет статусы и даты"];
var hum = ["Закупщик проверяет код ТРУ, план и сумму", "Закупщик решает, вернуть ли заявку", "Юрист согласовывает", "Руководитель подписывает", "Руководитель разбирает спорные случаи"];
return '<div class="kt-ai-card" style="padding:var(--kt-ai-space-6)"><div class="kt-ai-h3" style="margin-bottom:var(--kt-ai-space-4)">Кто делает что</div>' +
'<div class="wd-grid">' +
'<div class="kt-ai-v" data-gap="tight">' +
'<div class="kt-ai-chip" data-tone="blue">ИИ</div>' +
ai.map(function (s) { return '<div class="pas-step"><span class="kt-ai-num-badge" data-tone="warn">·</span><div class="b">' + esc(s) + "</div></div>"; }).join("") +
"</div>" +
'<div class="kt-ai-v" data-gap="tight">' +
'<div class="kt-ai-chip" data-tone="green">Человек</div>' +
hum.map(function (s) { return '<div class="pas-step"><span class="kt-ai-num-badge">·</span><div class="b">' + esc(s) + "</div></div>"; }).join("") +
"</div></div></div>";
}
function risks() {
return '<div class="kt-ai-card" style="padding:var(--kt-ai-space-6)"><div class="kt-ai-h3" style="margin-bottom:var(--kt-ai-space-4)">Контроль и риски</div>' +
'<div class="risk-grid">' +
riskItem("Ошибка ИИ в коде ТРУ", "ИИ может ошибаться в коде ТРУ — все коды подтверждает закупщик.") +
riskItem("Дубли", "ИИ может пропустить дубли — закупщик проверяет дубли перед отправкой в 1С.") +
riskItem("Спорные случаи", "Спорные случаи разбирает руководитель, решение фиксируется в реестре.") +
"</div>" +
'<div class="kt-ai-v" data-gap="tight" style="margin-top:var(--kt-ai-space-5)">' +
'<div class="kt-ai-kpi-strip" data-cards="true" style="margin-top:var(--kt-ai-space-5);padding:0">' +
'<div class="kt-ai-kpi"><span class="value" data-long="true">~3 000 ч</span><span class="label">экономия в месяц</span><span class="hint">150 заявок × 20 ч — оценка владельца процесса</span></div>' +
'<div class="kt-ai-kpi"><span class="value" data-long="true">~150</span><span class="label">заявок в месяц</span><span class="hint">из 1С и по почте</span></div>' +
'<div class="kt-ai-kpi"><span class="value" data-long="true">2 роли</span><span class="label">юрист и руководитель</span><span class="hint">проверяют после закупщика</span></div>' +
"</div>" +
'<div class="kt-ai-v" data-gap="tight" style="margin-top:var(--kt-ai-space-2)">' +
'<span class="kt-ai-label">Результат</span>' +
p("Реестр и статус заявки для закупщика и руководителя: меньше времени на обработку и меньше ошибок в кодах ТРУ.") +
'<span class="kt-ai-label">Ожидаемый эффект</span>' +
p("Быстрая обработка заявок, меньше ошибок в кодах ТРУ.") +
'<span class="kt-ai-label">Экономический эффект</span>' +
p("Около 3 000 ч в месяц (150 заявок × 20 ч) — оценка владельца процесса.") +
'<span class="kt-ai-label">Формат реализации</span>' +
p("Веб-интерфейс на Vibe AI: сначала статичный макет, после согласования — рабочее решение.") +
"</div></div>";
}
function riskItem(t, d) {
return '<div class="risk-item"><div class="t"><svg class="kt-icon" aria-hidden="true"><use href="#warning"></use></svg>' + esc(t) + "</div><p>" + esc(d) + "</p></div>";
}
// ================= НАВИГАЦИЯ + ЦИКЛ ЗАГРУЗКИ =================
var CRUMBS = { work: "Филиал · Закупки · Рабочий стол", registry: "Филиал · Закупки · Реестр", approve: "Филиал · Закупки · Согласование", about: "Филиал · Закупки · О решении" };
function render() {
if (S.screen === "work") renderWork();
else if (S.screen === "registry") renderRegistry();
else if (S.screen === "approve") renderApprove();
else renderAbout();
var items = document.querySelectorAll(".kt-ai-nav-item");
for (var i = 0; i < items.length; i++) {
items[i].setAttribute("data-active", items[i].getAttribute("data-screen") === S.screen ? "true" : "false");
}
var list = window.__allRequests || [];
setCount("work", list.filter(function (r) { return r.st === "new" || r.st === "checking"; }).length);
setCount("approve", list.filter(function (r) { return r.st === "toLegal" || r.st === "toSign" || r.st === "disputed"; }).length);
var crumb = document.getElementById("crumb");
if (crumb) crumb.textContent = CRUMBS[S.screen] || CRUMBS.work;
}
function setCount(id, n) {
document.querySelectorAll('[data-count="' + id + '"]').forEach(function (el) {
el.textContent = n;
});
}
var _loading = false;
function refresh() {
if (_loading) return Promise.resolve();
_loading = true;
return apiGet("/api/requests")
.then(function (data) { window.__allRequests = data; render(); })
.catch(function (e) { toast("Ошибка загрузки: " + e.message, "err"); })
.then(function () { _loading = false; });
}
document.addEventListener("DOMContentLoaded", function () {
S = loadState();
var items = document.querySelectorAll(".kt-ai-nav-item");
for (var i = 0; i < items.length; i++) {
(function (a) {
a.addEventListener("click", function (e) {
e.preventDefault();
S.screen = a.getAttribute("data-screen");
S.selected = "";
saveState(); render();
});
})(items[i]);
}
refresh();
});