diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b3ff6e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data.json +.vibe42-run.log +.vibe42-run.pid diff --git a/app.js b/app.js new file mode 100644 index 0000000..0f7cdcc --- /dev/null +++ b/app.js @@ -0,0 +1,833 @@ +/* Рабочее решение: данные в 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 '' + s.label + ""; +} + +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, "&").replace(//g, ">"); + return v.replace(/"/g, """); +} + +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 = '
' + + '" + esc(body) + + '
'; + 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 '" + + "" + r.id + "" + + "" + esc(r.init) + "" + + '' + fmtSum(r.sum) + "" + + "" + statusChip(r.st) + "" + + '' + r.src + "" + + ""; + }).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 = "

Рабочий стол

" + + '

Проверка поступивших заявок на закупку: код ТРУ, план, сумма.

' + + '
' + + kpi(k1, "В очереди проверки", "новых заявок: " + k2) + + kpi(k3, "Без подтверждённого кода", "из ожидает проверки") + + kpi(k4, "На согласовании", "у юриста и руководителя") + + kpi(k5, "Отправлено в 1С", "с начала месяца") + + "
" + + '
' + + '' + + "
" + + '
' + + '
' + + 'Очередьобновлено ИИ: ' + new Date().toLocaleDateString("ru-RU") + "" + + '
' + + "" + (rows || emptyRow("Очередь пуста", "Все поступившие заявки проверены.")) + "
НомерИнициаторСуммаСтатусИсточник
" + + "
" + + workDetail(sel) + + "
"; + document.getElementById("view").innerHTML = html; + + var btnNew = document.getElementById("btnNew"); + if (btnNew) btnNew.addEventListener("click", function () { openNewForm(); }); + bindWork(); +} + +function kpi(v, label, hint) { + return '
' + v + '' + esc(label) + "" + + (hint ? '' + esc(hint) + "" : "") + "
"; +} + +function emptyRow(title, sub) { + return '
' + esc(title) + "
" + + '
' + esc(sub) + "
"; +} + +function workDetail(r) { + if (!r) { + return ''; + } + 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 '"; +} + +function kpiCell(k, v) { + return '
' + v + '' + k + "
"; +} + +function checkItem(tone, title, why) { + var ic = tone === "ok" ? "check" : (tone === "err" ? "x" : "warning"); + return '
' + + "
" + esc(title) + (why ? '
' + esc(why) + "
" : "") + "
"; +} + +function aiPanel(r) { + var hint = r.hints; + var head = '
Подсказка ИИ · код ТРУ
'; + if (r.codeTru) { + return '
' + head + + '
Код ' + r.trr + " подтверждён " + + (r.confirmedAt ? new Date(r.confirmedAt).toLocaleDateString("ru-RU") : "(ранее)") + ". Реестр обновлён автоматически.
"; + } + if (!hint || !hint.length) return '
Подсказка не найдена
'; + var best = hint[0]; + var cands = hint.map(function (h, idx) { + return '
' + h.c + "
" + + '
' + esc(h.n) + "
" + + '' + h.conf + "%
"; + }).join(""); + return '
' + head + + '
Уверенность по первой подсказке
' + best.conf + "%
" + + '
' + cands + "
" + + '
' + + '
'; +} + +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("Спорный случай", + '
Заявка ' + r.id + " будет передана руководителю для разбора: ИИ предложил код, который не совпадает с версией в заявке. Решение по ней примет руководитель.
" + + ''); + 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("Новая заявка", + '
' + + fRow("Инициатор", '') + + fRow("Сумма (₸)", '') + + fRow("Код ТРУ", '') + + fRow("Предмет", '') + + fRow("Источник", '
') + + fRow("План", '
') + + fRow("Обоснование", '') + + '
' + + "
"); + + 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 '
' + label + "" + inner + "
"; +} + +// ================= РЕЕСТР ЗАЯВОК ================= +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 '' + r.id + "" + + "" + esc(r.init) + "" + + '' + fmtSum(r.sum) + "" + + "" + statusChip(r.st) + "" + + '' + di + "" + + "" + r.in + "" + + '' + disp + "" + + '' + r.src + ""; + }).join(""); + + var bars = MONTHS.map(function (m) { + var max = 13, h = Math.round((m[1] / max) * 100); + return '
'; + }).join(""); + + var html = "

Реестр заявок

" + + '

Все заявки филиала с текущим статусом; реестр ведёт ИИ, статусы меняет человек.

' + + '
' + + kpi(inWork, "В работе", "новые + проверка + согласование") + + kpi(done, "Обработано", "отправлено в 1С в сентябре") + + kpi(rej, "Возвращено", "инициатору на доработку") + + kpi(Math.round(sumWork / 1000000), "млн ₸ в очереди", "сумма новых и проверяемых") + + "
" + + '
' + + '
' + + inputSearch() + + '' + + selStatus() + + selSrc() + + '' + + "
" + + '
' + + "" + + "" + (rows || emptyRow("Ничего не найдено", "Снимите фильтры — реестр за месяц заполнен.")) + "
НомерИнициаторСуммаСтатусСрок, днейПоступилаОбработанаИсточник
" + + '
Заявки по неделям сентября
' + + '
' + bars + "
" + + '
' + + 'входящие' + + 'высокая нагрузка
' + + "
Что показывает реестр" + + "Пик нагрузки — 2–7 сентября: 10–13 заявок в день совпали с закрытием планового цикла. Возвратов в сентябре немного, большинство возвратов — дубли уже приобретённых услуг.
" + + "
"; + document.getElementById("view").innerHTML = html; + bindRegistry(); +} + +function inputSearch() { + return ''; +} + +function selStatus() { + var opts = ""; + for (var k in STATUS) { + if (!Object.prototype.hasOwnProperty.call(STATUS, k)) continue; + opts += '"; + } + return '
"; +} + +function selSrc() { + return '
"; +} + +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", exportCsv); +} + +function exportCsv() { + window.location.href = "/api/export.csv"; + toast("Реестр выгружен в CSV", "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 '' + r.id + "" + + "" + esc(r.init) + "" + + '' + fmtSum(r.sum) + "" + + "" + r.in + "" + + "" + statusChip(role === "legal" ? "toLegal" : "toSign") + ""; + }).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 '' + r.id + "" + + "" + esc(r.init) + "" + + '' + fmtSum(r.sum) + "" + + "" + r.in + "" + + "" + statusChip("disputed") + ""; + }).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 = "

" + roleTitle + "

" + + '

Заявки, ' + poolLabel + " после проверки закупщиком.

" + + '
' + + kpi(k1, "Ждут моего действия", "в очереди сейчас") + + kpi(k2, "Спорные случаи", "ошибки ИИ по кодам ТРУ") + + kpi(k3, "Отправлено в 1С", "сентябрь") + + kpi(k4, "Возвращено", "на доработку") + + "
" + + '
' + + 'Роль' + + '
" + + 'на рассмотрении: ' + nToAct + "
" + + '
' + + '
' + + "" + + "" + (rows || emptyRow("Очередь пуста", "Все заявки переданы на следующий этап.")) + disRows + "
НомерИнициаторСуммаПоступилаСтатус
" + + "
" + + apprDetail(sel, role, isDisputed) + + "
"; + document.getElementById("view").innerHTML = html; + bindApprove(role); +} + +function apprDetail(r, role, isDisputed) { + if (!r) { + return ''; + } + var trrNote = r.dispute + ? 'ИИ предложил «' + r.trr + '», но версия оборудования в заявке не сходится — на разборе.' + : "Подтверждён закупщиком."; + var subj = isDisputed ? "Разбор спорного случая" : "Детали заявки"; + + return '"; +} + +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 = "

О решении

" + + '

Согласование заявок на закупку филиала — паспорт ИИ-решения.

' + + '
' + + + '
' + + '' + + tr("Наименование", "Согласование заявок на закупку филиала") + + tr("Исполнитель", "Владелец процесса — сборка на платформе Vibe AI") + + tr("Пользователи", "Закупщик · Юрист · Руководитель") + + tr("Дата внедрения", "—") + + "
" + + + '
Предпосылки
' + + '
' + + p("Заявки на закупку поступают из 1С и письмами, около 150 в месяц.") + + p("Процесс включает ручную проверку данных (код ТРУ, план, сумма) и последовательную передачу между участниками.") + + "
" + + + '
Входные данные и источники
' + + '
' + + p("Заявки из 1С и электронной почты.") + + p("Реестр в Excel: номер, инициатор, сумма, статус, даты.") + + "
" + + + '
Процессы для внедрения ИИ
' + + '
' + + p("Проверка по чек-листу.") + + p("Подсказка кода ТРУ.") + + p("Ведение реестра и напоминания.") + + "
" + + + '
Шаги процесса
' + + steps() + "
" + + + passSteps() + + + risks() + + + "
"; + document.getElementById("view").innerHTML = html; +} + +function tr(k, v) { return "" + esc(k) + "" + esc(v) + ""; } +function p(t) { return '

' + esc(t) + "

"; } + +function steps() { + var steps = [ + "Поступление заявки из 1С или по электронной почте", + "Закупщик проверяет код ТРУ, наличие в плане и сумму", + "ИИ подсказывает код ТРУ и ведёт реестр", + "Закупщик возвращает заявку инициатору при ошибке", + "Передача заявки юристу для согласования", + "Руководитель подписывает заявку", + "Закупщик отправляет заявку в 1С и уведомляет инициатора" + ]; + return '
' + steps.map(function (s, i) { + return '
' + (i + 1) + '
' + esc(s) + "
"; + }).join("") + "
"; +} + +function passSteps() { + var ai = ["ИИ подсказывает код ТРУ", "ИИ ведёт реестр", "ИИ обновляет статусы и даты"]; + var hum = ["Закупщик проверяет код ТРУ, план и сумму", "Закупщик решает, вернуть ли заявку", "Юрист согласовывает", "Руководитель подписывает", "Руководитель разбирает спорные случаи"]; + return '
Кто делает что
' + + '
' + + '
' + + '
ИИ
' + + ai.map(function (s) { return '
·
' + esc(s) + "
"; }).join("") + + "
" + + '
' + + '
Человек
' + + hum.map(function (s) { return '
·
' + esc(s) + "
"; }).join("") + + "
"; +} + +function risks() { + return '
Контроль и риски
' + + '
' + + riskItem("Ошибка ИИ в коде ТРУ", "ИИ может ошибаться в коде ТРУ — все коды подтверждает закупщик.") + + riskItem("Дубли", "ИИ может пропустить дубли — закупщик проверяет дубли перед отправкой в 1С.") + + riskItem("Спорные случаи", "Спорные случаи разбирает руководитель, решение фиксируется в реестре.") + + "
" + + '
' + + '
' + + '
~3 000 чэкономия в месяц150 заявок × 20 ч — оценка владельца процесса
' + + '
~150заявок в месяциз 1С и по почте
' + + '
2 ролиюрист и руководительпроверяют после закупщика
' + + "
" + + '
' + + 'Результат' + + p("Реестр и статус заявки для закупщика и руководителя: меньше времени на обработку и меньше ошибок в кодах ТРУ.") + + 'Ожидаемый эффект' + + p("Быстрая обработка заявок, меньше ошибок в кодах ТРУ.") + + 'Экономический эффект' + + p("Около 3 000 ч в месяц (150 заявок × 20 ч) — оценка владельца процесса.") + + 'Формат реализации' + + p("Веб-интерфейс на Vibe AI: сначала статичный макет, после согласования — рабочее решение.") + + "
"; +} + +function riskItem(t, d) { + return '
' + esc(t) + "

" + esc(d) + "

"; +} + +// ================= НАВИГАЦИЯ + ЦИКЛ ЗАГРУЗКИ ================= +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(); +}); diff --git a/index.html b/index.html index 82461fd..9a66e78 100644 --- a/index.html +++ b/index.html @@ -59,6 +59,6 @@ - + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8628466 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "soglasovanie-zakupki", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "soglasovanie-zakupki", + "version": "1.0.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..2b79e7c --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "soglasovanie-zakupki", + "version": "1.0.0", + "private": true, + "description": "Согласование заявок на закупку филиала — рабочее решение (Node.js + server.js)", + "main": "server.js", + "scripts": { + "start": "node server.js" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..63aeef4 --- /dev/null +++ b/server.js @@ -0,0 +1,262 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const PORT = process.env.PORT || 3000; +const DATA = path.join(__dirname, "data.json"); +const ROOT = __dirname; + +// ---------- справочники ---------- +const 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 '' + s.label + ""; +} + +// ИИ-подсказки по кодам ТРУ: для каждой заявки — 2 кандидата +const TRU_HINTS = { + "ЗК-0918": [ + { c: "ТРУ-442.01", n: "Оптические модули, передача данных", conf: 96 }, + { c: "ТРУ-442.05", n: "Кабель оптический, внешняя прокладка", conf: 54 } + ], + "ЗК-0919": [ + { c: "ТРУ-101.22", n: "Оборудование для обслуживания АТС", conf: 91 }, + { c: "ТРУ-101.27", n: "Монтажный инструмент для линий", conf: 47 } + ], + "ЗК-0920": [ + { c: "ТРУ-310.14", n: "Провод медный, ВВГнг 3х25", conf: 98 }, + { c: "ТРУ-310.11", n: "Провод медный, ВВГнг 3х16", conf: 61 } + ], + "ЗК-0912": [ + { c: "ТРУ-501.03", n: "Сухие строительные смеси, оптом", conf: 88 }, + { c: "ТРУ-501.07", n: "Краски и лаки, фасовка 10 кг", conf: 40 } + ], + "ЗК-0913": [ + { c: "ТРУ-071.19", n: "Услуги связи, прокат линии", conf: 94 }, + { c: "ТРУ-071.11", n: "Аренда коммутаторов", conf: 38 } + ], + "ЗК-0914": [ + { c: "ТРУ-442.01", n: "Оптические модули, передача данных", conf: 52 }, + { c: "ТРУ-440.02", n: "Сетевые коммутаторы", conf: 49 } + ], + "ЗК-0915": [ + { c: "ТРУ-610.05", n: "Хостинг вычислительных мощностей", conf: 93 }, + { c: "ТРУ-610.09", n: "Облачные услуги, хранилище", conf: 58 } + ] +}; + +// ---------- seed ---------- +const SEED = [ + { id: "ЗК-0918", init: "Ахметов С. Т.", sum: 2480000, src: "1С", in: "2026-09-22", done: "", st: "checking", trr: "ТРУ-440.12", plan: "Да", codeTru: false, dispute: false, + subj: "Оптические модули для узла связи", justif: "Замена изношенных модулей на магистральном узле, срок годности запаса истёк." }, + { id: "ЗК-0919", init: "Байжанова Ш. К.", sum: 1120000, src: "Почта", in: "2026-09-22", done: "", st: "checking", trr: "ТРУ-101.20", plan: "Да", codeTru: false, dispute: false, + subj: "Монтажный инструмент", justif: "Дополнение оснастки бригады по заявке на обслуживание линий." }, + { id: "ЗК-0920", init: "Дюсембаев Н. Б.", sum: 560000, src: "1С", in: "2026-09-21", done: "", st: "new", trr: "ТРУ-310.09", plan: "Да", codeTru: false, dispute: false, + subj: "Провод ВВГнг 3х25, 1 200 м", justif: "Стройка внутреннего контура в техническом помещении." }, + { id: "ЗК-0915", init: "Ермек А. Д.", sum: 1490000, src: "1С", in: "2026-09-20", done: "2026-09-21", st: "toSign", trr: "ТРУ-610.05", plan: "Да", codeTru: true, dispute: false, + subj: "Хостинг дополнительных мощностей", justif: "Временное расширение хранилища до завершения строительства стойки." }, + { id: "ЗК-0916", init: "Исаев Г. М.", sum: 3840000, src: "Почта", in: "2026-09-20", done: "2026-09-21", st: "toLegal", trr: "ТРУ-220.31", plan: "Нет", codeTru: true, dispute: false, + subj: "Панели управления для ЩУ-2", justif: "Панели не включены в план закупок: потребность подтверждена актом осмотра." }, + { id: "ЗК-0917", init: "Кадырова Ж. С.", sum: 2960000, src: "1С", in: "2026-09-19", done: "2026-09-22", st: "toSign", trr: "ТРУ-442.01", plan: "Да", codeTru: true, dispute: false, + subj: "Оборудование передачи данных", justif: "Поставка по утверждённому плану на вторую половину года." }, + { id: "ЗК-0914", init: "Мухитов Р. О.", sum: 1730000, src: "1С", in: "2026-09-18", done: "", st: "disputed", trr: "ТРУ-440.12", plan: "Да", codeTru: true, dispute: true, + subj: "Коммутаторы сетевые, 8 шт", justif: "В заявке указано оборудование, не совпадающее с артикулом плана закупки." }, + { id: "ЗК-0913", init: "Нурлан С. А.", sum: 640000, src: "Почта", in: "2026-09-17", done: "2026-09-17", st: "rejected", trr: "ТРУ-071.19", plan: "Нет", codeTru: true, dispute: false, + subj: "Прокат телефонной линии", justif: "Возвращена: услуга уже приобретена по договору № 4542/2026." }, + { id: "ЗК-0912", init: "Оспанова Л. Т.", sum: 1250000, src: "1С", in: "2026-09-16", done: "2026-09-18", st: "sent", trr: "ТРУ-501.03", plan: "Да", codeTru: true, dispute: false, + subj: "Стройматериалы для ремонта серверной", justif: "Ремонт по смете, согласованной в плановом периоде." }, + { id: "ЗК-0911", init: "Павлов К. И.", sum: 480000, src: "1С", in: "2026-09-15", done: "2026-09-16", st: "sent", trr: "ТРУ-118.40", plan: "Да", codeTru: true, dispute: true, + subj: "Комплект ЗИП для линии связи", justif: "Подтверждён вручную: ИИ предложил неверный код (ТРУ-118.44), закупщик исправил." }, + { id: "ЗК-0910", init: "Сафин Д. Р.", sum: 910000, src: "Почта", in: "2026-09-14", done: "2026-09-15", st: "sent", trr: "ТРУ-330.08", plan: "Да", codeTru: true, dispute: false, + subj: "Эксплуатационные материалы", justif: "Запас на третий квартал, остатки ниже минимального уровня." }, + { id: "ЗК-0909", init: "Тлеуберди А. М.", sum: 3200000, src: "1С", in: "2026-09-12", done: "2026-09-16", st: "sent", trr: "ТРУ-443.02", plan: "Да", codeTru: true, dispute: false, + subj: "Кабель оптический, 48 волокон", justif: "Резервирующая линия между узлами, включена в план." } +]; + +// ---------- data.json ---------- +function loadData() { + let d = null; + try { d = JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) {} + if (!d || !Array.isArray(d.requests)) { + d = { requests: JSON.parse(JSON.stringify(SEED)), log: [] }; + saveData(d); + } + return d; +} +function saveData(d) { + d.updatedAt = new Date().toISOString(); + fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); +} +function logIt(d, what, who) { + d.log.push({ when: new Date().toISOString(), what: what, who: who || "система" }); + if (d.log.length > 500) d.log = d.log.slice(-500); +} + +// ---------- утилиты ---------- +function isoToDdMm(dd) { + if (!dd) return ""; + var p = dd.split("-"); + return p[2] + "." + p[1] + "." + p[0]; +} + +function todayIso() { + return new Date().toISOString().slice(0, 10); +} + +function nextId(reqs) { + var max = 920; + for (var i = 0; i < reqs.length; i++) { + var m = /ЗК-(\d+)\/(\d+)/.exec(reqs[i].id); + if (m) max = Math.max(max, parseInt(m[2], 10)); + } + return "ЗК-09/" + String(max + 1).padStart(2, "0"); +} + +function body(req) { + return new Promise(function (resolve) { + var s = ""; + req.on("data", function (c) { s += c; }); + req.on("end", function () { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +function sendJson(res, code, obj) { + var s = JSON.stringify(obj); + res.writeHead(code, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(s) }); + res.end(s); +} + +function daysBetween(inIso, doneIso) { + if (!inIso) return 0; + var a = new Date(inIso + "T00:00:00"); + var b = doneIso ? new Date(doneIso + "T00:00:00") : new Date(); + var diff = Math.round((b - a) / 86400000); + return diff < 0 ? 0 : diff; +} + +// ---------- HTTP ---------- +http.createServer(async function (req, res) { + var url = req.url.split("?")[0]; + + // ===== API ===== + if (req.method === "GET" && url === "/api/requests") { + var d = loadData(); + var out = d.requests.map(function (r) { + return { + id: r.id, init: r.init, sum: r.sum, src: r.src, + in: isoToDdMm(r.in), done: r.done ? isoToDdMm(r.done) : "", + st: r.st, trr: r.trr, plan: r.plan, codeTru: !!r.codeTru, + dispute: !!r.dispute, subj: r.subj, justif: r.justif, + days: daysBetween(r.in, r.done), + hints: TRU_HINTS[r.id] || (r.codeTru ? [{ c: r.trr, n: r.subj, conf: 100 }] : null), + confirmedAt: r.confirmedAt || "" + }; + }); + return sendJson(res, 200, out); + } + + if (req.method === "POST" && url === "/api/requests/new") { + var d2 = loadData(); + var b = await body(req); + var id2 = nextId(d2.requests); + var item = { + id: id2, + init: String(b.init || "—").slice(0, 80), + sum: parseInt(b.sum, 10) || 0, + src: b.src === "Почта" ? "Почта" : "1С", + in: todayIso(), done: "", st: "new", + trr: String(b.trr || "—").slice(0, 30), + plan: b.plan === "Нет" ? "Нет" : "Да", + codeTru: false, dispute: false, + subj: String(b.subj || "—").slice(0, 140), + justif: String(b.justif || "").slice(0, 300) + }; + d2.requests.push(item); + logIt(d2, id2 + " создана", "закупщик"); + saveData(d2); + return sendJson(res, 200, { ok: true, id: id2 }); + } + + if (req.method === "PATCH" && url === "/api/requests") { + var d3 = loadData(); + var b3 = await body(req); + var id3 = b3.id; + var who3 = b3.who || "система"; + var found = null; + for (var i = 0; i < d3.requests.length; i++) { + if (d3.requests[i].id === id3) { found = d3.requests[i]; break; } + } + if (!found) return sendJson(res, 404, { ok: false, error: "Заявка не найдена" }); + var what = ""; + if (b3.st) { found.st = b3.st; what = id3 + " — статус → " + STATUS[b3.st].label; } + if (b3.codeTru) { + found.codeTru = true; + found.trr = b3.trr || found.trr; + found.confirmedAt = new Date().toISOString(); + what += (what ? " · " : "") + "код ТРУ подтверждён (" + found.trr + ")"; + } + if (b3.dispute) { found.dispute = true; what += (what ? " · " : "") + "в спорные"; } + if (b3.done) { found.done = todayIso(); what += (what ? " · " : "") + "обработана"; } + if (b3.justif) { found.justif = String(b3.justif).slice(0, 500); } + if (!what) what = id3 + " — обновлена"; + logIt(d3, what, who3); + saveData(d3); + return sendJson(res, 200, { ok: true }); + } + + if (req.method === "GET" && url === "/api/log") { + var d4 = loadData(); + return sendJson(res, 200, d4.log.slice().reverse().slice(0, 100)); + } + + if (req.method === "GET" && url === "/api/reset") { + var d5 = { requests: JSON.parse(JSON.stringify(SEED)), log: [] }; + saveData(d5); + return sendJson(res, 200, { ok: true }); + } + + if (req.method === "GET" && url === "/api/export.csv") { + var d6 = loadData(); + var head = "Номер;Инициатор;Сумма;Статус;Срок (дней);Поступила;Обработана;Источник;Предмет;Обоснование"; + var rows = d6.requests.map(function (r) { + var st = STATUS[r.st] ? STATUS[r.st].label : r.st; + var days = daysBetween(r.in, r.done); + return [ + r.id, r.init, r.sum, st, days, + isoToDdMm(r.in), r.done ? isoToDdMm(r.done) : "", + r.src, '"' + (r.subj || "").replace(/"/g, '""') + '"', + '"' + (r.justif || "").replace(/"/g, '""') + '"' + ].join(";"); + }); + var csv = "\uFEFF" + head + "\n" + rows.join("\n"); + res.writeHead(200, { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="reestr-zakupki.csv"', + "Content-Length": Buffer.byteLength(csv) + }); + return res.end(csv); + } + + // ===== статика ===== + var file = url === "/" ? "/index.html" : url; + var full = path.join(ROOT, file); + if (full.startsWith(ROOT) && fs.existsSync(full) && fs.statSync(full).isFile()) { + var ext = path.extname(full).toLowerCase(); + var types = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".ico": "image/x-icon" }; + res.writeHead(200, { "Content-Type": (types[ext] || "application/octet-stream") + "; charset=utf-8" }); + return res.end(fs.readFileSync(full)); + } + res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.end("Not found"); +}).listen(PORT, function () { + console.log("Сервер согласования заявок на закупку запущен на порту " + PORT); +});