v2: рабочее решение — server.js (node:http + data.json), app.js на fetch API, форма новой заявки, CSV /api/export.csv, без демо-меток
This commit is contained in:
parent
f1abbd96e4
commit
59cb01edc9
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
data.json
|
||||
.vibe42-run.log
|
||||
.vibe42-run.pid
|
||||
833
app.js
Normal file
833
app.js
Normal file
@ -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 '<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, "&").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 = '<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>Экспорт</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", 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 '<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();
|
||||
});
|
||||
@ -59,6 +59,6 @@
|
||||
|
||||
<script src="design-system/icons/kt-ai-icons.js"></script>
|
||||
<script src="design-system/kt-ai-chart-tip.js"></script>
|
||||
<script src="script.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
12
package-lock.json
generated
Normal file
12
package-lock.json
generated
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "soglasovanie-zakupki",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "soglasovanie-zakupki",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
package.json
Normal file
10
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
262
server.js
Normal file
262
server.js
Normal file
@ -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 '<span class="kt-ai-chip" data-tone="' + s.tone + '">' + s.label + "</span>";
|
||||
}
|
||||
|
||||
// ИИ-подсказки по кодам ТРУ: для каждой заявки — 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user