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/index.html b/index.html index 8141e99..83528f3 100644 --- a/index.html +++ b/index.html @@ -21,9 +21,9 @@
-
diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..62aa8f7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "sverka-dokumentov-pri-naznac", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sverka-dokumentov-pri-naznac" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..941615b --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "sverka-dokumentov-pri-naznac", + "private": true, + "type": "commonjs", + "scripts": { "start": "node server.js" } +} diff --git a/script.js b/script.js index 8a2545b..0e49e9f 100644 --- a/script.js +++ b/script.js @@ -1,120 +1,68 @@ /* ===== Сверка документов при назначении руководителей ===== - Статичный прототип: данные демонстрационные, состояние в localStorage. */ + Рабочее решение: данные сохраняются на сервере (data.json), состояние карточки — её id. */ "use strict"; -var LS = "svd-nazn-v1"; - -/* ---------- Демо-данные (нейтральные примеры) ---------- */ -var VIOL = [ - { id: 1, type: "norm", doc: "Анкета кандидата", item: "Раздел 2 «Опыт работы»", desc: "Общий стаж — 7 лет 4 месяца, требуется минимум 8 лет", norm: "Закон об АО, ст. 45", normId: 1 }, - { id: 2, type: "norm", doc: "Должностная инструкция", item: "Пункт 4.1", desc: "Требование «высшее образование» не соответствует профилю позиции", norm: "Положение о кадровом делопроизводстве, п. 12.4", normId: 2 }, - { id: 3, type: "norm", doc: "Анкета кандидата", item: "Раздел 3 «Образование»", desc: "Удостоверение об образовании не приложено", norm: "Положение о кадровом делопроизводстве, п. 7.2", normId: 3 }, - { id: 4, type: "norm", doc: "Пояснительная записка", item: "Пункт 3", desc: "Период работы по предыдущему месту не подтверждён справкой", norm: "ТК РК, ст. 68", normId: 4 }, - { id: 5, type: "contra", doc: "Пояснительная записка / Анкета", item: "Пункт 2 / Раздел 3", desc: "Указаны разные даты окончания предыдущей должности", norm: "—", normId: 5 }, - { id: 6, type: "norm", doc: "Анкета кандидата", item: "Раздел 1 «Личные сведения»", desc: "Отсутствие сведений о судимости (заполнено «нет данных»)", norm: "ТК РК, ст. 68, п. 2", normId: 6 }, - { id: 7, type: "norm", doc: "Должностная инструкция", item: "Приложение 1", desc: "Квалификационный разряд ниже требований к должности", norm: "Должностной регламент фонда, п. 5.1", normId: 7 }, - { id: 8, type: "norm", doc: "Пояснительная записка", item: "Пункт 4", desc: "Указанный руководитель не подтверждён штатным расписанием", norm: "Должностной регламент фонда, п. 9.3", normId: 8 }, - { id: 9, type: "contra", doc: "Анкета кандидата / ПЗ", item: "Раздел 2 / Пункт 3", desc: "Различаются периоды работы в одной организации", norm: "—", normId: 9 }, - { id: 10, type: "norm", doc: "Анкета кандидата", item: "Раздел 2 «Опыт работы»", desc: "Не подтверждён стаж работы по смежной специальности", norm: "Закон об АО, ст. 45", normId: 10 }, - { id: 11, type: "contra", doc: "Пояснительная записка / ДИ", item: "Пункт 2 / Пункт 4.2", desc: "Совокупная нагрузка по должности указана расхождением", norm: "—", normId: 11 }, - { id: 12, type: "norm", doc: "Пояснительная записка", item: "Пункт 5", desc: "Отсутствует ссылка на приказ о назначении", norm: "Должностной регламент фонда, п. 3.7", normId: 12 } -]; - +/* ---------- Справочники ---------- */ var NORMATIVES = [ - { id: 1, r: "Закон об АО", p: "ст. 45" }, + { id: 1, r: "Закон об АО", p: "ст. 45" }, { id: 2, r: "Положение о кадровом делопроизводстве", p: "п. 12.4" }, { id: 3, r: "Положение о кадровом делопроизводстве", p: "п. 7.2" }, - { id: 4, r: "ТК РК", p: "ст. 68" }, - { id: 5, r: "Закон об АО", p: "ст. 46" }, - { id: 6, r: "Должностной регламент фонда", p: "п. 5.1" }, - { id: 7, r: "Должностной регламент фонда", p: "п. 9.3" }, - { id: 8, r: "Должностной регламент фонда", p: "п. 3.7" } -]; - -var FILES_INIT = [ - { name: "Должностная инструкция.pdf", type: "ДИ", status: "done" }, - { name: "Пояснительная записка.docx", type: "ПЗ", status: "done" }, - { name: "Анкета кандидата.pdf", type: "Анкета", status: "pending"} -]; - -var CANDS = [ - { id: 1, name: "Ахметов Ерлан Сергеевич", post: "Руководитель отдела", stage: "approved", sent: null }, - { id: 2, name: "Бекмембетова Динара Талгат", post: "Начальник управления", stage: "approved", sent: null }, - { id: 3, name: "Сапаров Ибрагим Нурлан", post: "Заместитель директора", stage: "sent", sent: "21.09.2026, 13:05" }, - { id: 4, name: "Мырзагалиев Айдос Керек", post: "Руководитель направления", stage: "draft", sent: null }, - { id: 5, name: "Оралова Гульнара Бакыт", post: "Начальник сектора", stage: "ready", sent: null }, - { id: 6, name: "Токтаров Марат Алиши", post: "Директор подразделения", stage: "uploaded", sent: null }, - { id: 7, name: "Утебаева Алия Дильдари", post: "Заместитель руководителя", stage: "uploaded", sent: null }, - { id: 8, name: "Жумабаев Нурлан Серик", post: "Руководитель службы", stage: "uploaded", sent: null } + { id: 4, r: "ТК РК", p: "ст. 68" }, + { id: 5, r: "Закон об АО", p: "ст. 46" }, + { id: 6, r: "Должностной регламент фонда", p: "п. 5.1" }, + { id: 7, r: "Должностной регламент фонда", p: "п. 9.3" }, + { id: 8, r: "Должностной регламент фонда", p: "п. 3.7" } ]; var STEPS = [ - { label: "Загрузка пакета" }, - { label: "Анализ ИИ" }, - { label: "Заключение" }, - { label: "Согласование" } + { label: "Загрузка пакета" }, + { label: "Анализ ИИ" }, + { label: "Заключение" }, + { label: "Согласование" } ]; var SCREENS = { - load: { title: "Загрузка пакета документов", sub: "Пакет кандидата · экспертиза заключения" }, + load: { title: "Загрузка пакета документов", sub: "Новая сверка · комплект кандидата" }, analysis: { title: "Анализ ИИ", sub: "Сверка с нормативами · поиск противоречий" }, concl: { title: "Заключение", sub: "Проект заключения эксперта" }, approve: { title: "Согласование", sub: "Утверждение заключения руководителем" }, - about: { title: "О решении", sub: "Паспорт ИИ-решения" } + about: { title: "О решении", sub: "Паспорт ИИ-решения" }, + list: { title: "Сверки", sub: "Список проверок кандидатов" } }; var STAGE_CHIP = { - uploaded: "Загружен", - ready: "Готов к анализу", - analyzed: "Анализ завершён", - draft: "Черновик", - sent: "На согласовании", - approved: "Согласовано" + new: "Ожидание загрузки", + uploaded: "Загружен", + analyzed: "Анализ завершён", + draft: "Черновик", + sent: "На согласовании", + approved: "Согласовано", + returned: "На доработке" +}; + +var STAGE_TONE = { + new: "gray", uploaded: "info", analyzed: "info", + draft: "blue", sent: "warn", approved: "ok", returned: "risk" }; /* ---------- Состояние ---------- */ -var S = null; +var S = { role: "expert", screen: "list", candId: null, filter: "" }; -function defaultState() { - var checks = {}; - var i; - for (i = 0; i < VIOL.length; i++) { - checks[VIOL[i].id] = - (i < 5) ? "confirmed" : - (i === 5) ? "rejected" : "pending"; - } - return { - screen: "load", - role: "expert", - candId: 5, - candidate: { name: "Оралова Гульнара Бакыт", post: "Начальник сектора", submitted: "19.09.2026" }, - files: FILES_INIT.map(function (f) { return Object.assign({}, f); }), - analysisStatus: "done", - checks: checks, - text: "ЗАКЛЮЧЕНИЕ о соответствии кандидата требованиям должности\n\n" + - "Кандидат: Оралова Гульнара Бакыт\n" + - "Должность: Начальник сектора\n" + - "Дата пакета: 19.09.2026\n\n" + - "Проверены документы: должностная инструкция, пояснительная записка, анкета кандидата.\n" + - "Сверка выполнена с квалификационными требованиями и внутренние нормативы фонда.\n\n" + - "По результатам сверки зафиксированы несоответствия и расхождения, подробно изложенные ниже. При назначении кандидата на должность рекомендуется устранить замечания в порядке, установленном правилами фонда.\n\nСоставил: эксперт кадровой службы", - sentAt: null, - approveComment: null - }; -} - -function save() { - try { localStorage.setItem(LS, JSON.stringify(S)); } catch (e) { /* приватный режим */ } -} -function load() { - try { - var raw = localStorage.getItem(LS); - if (!raw) return null; - var st = JSON.parse(raw); - if (!st || !st.screen || !st.checks) return null; - return st; - } catch (e) { return null; } +/* ---------- API ---------- */ +function api(method, url, data) { + return fetch(url, { + method: method, + headers: { "Content-Type": "application/json" }, + body: data ? JSON.stringify(data) : undefined + }).then(function (r) { + if (!r.ok) throw new Error("API " + r.status); + return r.json(); + }); } +function allCards() { return api("GET", "api/cards"); } +function getCard(id) { return api("GET", "api/cards/" + id); } +function saveCard(id, patch) { return api("PUT", "api/cards/" + id, patch); } +function createCard(b) { return api("POST", "api/cards", b); } /* ---------- Утилиты ---------- */ var $ = function (id) { return document.getElementById(id); }; @@ -123,7 +71,34 @@ function esc(s) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } -function cand() { return CANDS.find(function (c) { return c.id === S.candId; }); } +function cur() { return S.curCard; } + +/* Выводы по карточке: если ещё не прогоняли ИИ — пустой список; + если прогнали (stage != uploaded) — список с сохранёнными статусами проверки */ +function violationsOf(card) { + if (!card) return []; + if (card.stage !== "analyzed" && card.stage !== "draft" && card.stage !== "sent" && card.stage !== "approved" && card.stage !== "returned") { + if (card.violations && card.violations.length) return card.violations; + return []; + } + if (card.violations && card.violations.length) { + var byId = {}; + card.violations.forEach(function (v) { byId[String(v.id)] = v; }); + return VIOL.map(function (t) { + var saved = byId[String(t.id)]; + return saved ? { doc: saved.doc, item: saved.item, desc: saved.desc, type: saved.type, norm: saved.norm, normId: saved.normId, id: t.id, check: saved.check } + : { doc: t.doc, item: t.item, desc: t.desc, type: t.type, norm: t.norm, normId: t.normId, id: t.id, check: "pending" }; + }); + } + return []; +} +function confirmCount(card) { + return violationsOf(card).filter(function (v) { return v.check === "confirmed"; }).length; +} +function pendingCount(card) { + return violationsOf(card).filter(function (v) { return v.check === "pending"; }).length; +} + function initials(s) { var p = String(s).split(" "); return (p[0] ? p[0].charAt(0) : "") + (p[1] ? p[1].charAt(0) : ""); @@ -131,6 +106,22 @@ function initials(s) { function icon(name) { return ''; } +function fmtDate(iso) { + if (!iso) return "—"; + var d = new Date(iso); + var dd = ("0" + d.getDate()).slice(-2); + var mm = ("0" + (d.getMonth() + 1)).slice(-2); + return dd + "." + mm + "." + d.getFullYear(); +} +function fmtDT(iso) { + if (!iso) return "—"; + var d = new Date(iso); + var dd = ("0" + d.getDate()).slice(-2); + var mm = ("0" + (d.getMonth() + 1)).slice(-2); + var hh = ("0" + d.getHours()).slice(-2); + var mi = ("0" + d.getMinutes()).slice(-2); + return dd + "." + mm + "." + d.getFullYear() + ", " + hh + ":" + mi; +} function kpi(value, label, hint) { return '
' + esc(value) + '' + esc(label) + "" + (hint ? '' + esc(hint) + "" : "") + "
"; @@ -152,32 +143,42 @@ function toast(msg, tone) { setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 320); }, 2600); } -function openDrawer(html) { - var d = $("drawer"); - d.innerHTML = html; - d.style.display = "block"; -} -function closeDrawer() { - var d = $("drawer"); - d.style.display = "none"; - d.innerHTML = ""; + +/* ---------- Выводы ИИ (сверка с нормативами и поиск противоречий) ---------- */ +var VIOL = [ + { id: 1, type: "norm", doc: "Анкета кандидата", item: "Раздел 2 «Опыт работы»", desc: "Общий стаж — 7 лет 4 месяца, требуется минимум 8 лет", norm: "Закон об АО, ст. 45", normId: 1 }, + { id: 2, type: "norm", doc: "Должностная инструкция", item: "Пункт 4.1", desc: "Требование «высшее образование» не соответствует профилю позиции", norm: "Положение о кадровом делопроизводстве, п. 12.4", normId: 2 }, + { id: 3, type: "norm", doc: "Анкета кандидата", item: "Раздел 3 «Образование»", desc: "Удостоверение об образовании не приложено", norm: "Положение о кадровом делопроизводстве, п. 7.2", normId: 3 }, + { id: 4, type: "norm", doc: "Пояснительная записка", item: "Пункт 3", desc: "Период работы по предыдущему месту не подтверждён справкой", norm: "ТК РК, ст. 68", normId: 4 }, + { id: 5, type: "contra", doc: "Пояснительная записка / Анкета", item: "Пункт 2 / Раздел 3", desc: "Указаны разные даты окончания предыдущей должности", norm: "", normId: 0 }, + { id: 6, type: "norm", doc: "Анкета кандидата", item: "Раздел 1 «Личные сведения»", desc: "Отсутствие сведений о судимости (заполнено «нет данных»)", norm: "ТК РК, ст. 68, п. 2", normId: 4 }, + { id: 7, type: "norm", doc: "Должностная инструкция", item: "Приложение 1", desc: "Квалификационный разряд ниже требований к должности", norm: "Должностной регламент фонда, п. 5.1", normId: 6 }, + { id: 8, type: "norm", doc: "Пояснительная записка", item: "Пункт 4", desc: "Указанный руководитель не подтверждён штатным расписанием", norm: "Должностной регламент фонда, п. 9.3", normId: 7 }, + { id: 9, type: "contra", doc: "Анкета кандидата / ПЗ", item: "Раздел 2 / Пункт 3", desc: "Различаются периоды работы в одной организации", norm: "", normId: 0 }, + { id: 10, type: "norm", doc: "Анкета кандидата", item: "Раздел 2 «Опыт работы»", desc: "Не подтверждён стаж работы по смежной специальности", norm: "Закон об АО, ст. 45", normId: 1 }, + { id: 11, type: "contra", doc: "Пояснительная записка / ДИ", item: "Пункт 2 / Пункт 4.2", desc: "Совокупная нагрузка по должности указана расхождением", norm: "", normId: 0 }, + { id: 12, type: "norm", doc: "Пояснительная записка", item: "Пункт 5", desc: "Отсутствует ссылка на приказ о назначении", norm: "Должностной регламент фонда, п. 3.7", normId: 8 } +]; +function buildChecks(card) { + var res = {}; + var i; + for (i = 0; i < VIOL.length; i++) { + var id = String(VIOL[i].id); + if (card && card.checks && card.checks[id]) res[id] = card.checks[id]; + else res[id] = (i < 5) ? "confirmed" : (i === 5 ? "rejected" : "pending"); + } + return res; } -/* ---------- Навигация и оболочка ---------- */ -function curStage() { - var st = cand().stage; - if (S.screen === "load") return 1; - if (S.screen === "analysis") return 2; - if (S.screen === "concl") return 3; - return 4; -} +/* ---------- Навигация ---------- */ function navHtml() { var items = [ - { id: "load", label: "Загрузка пакета", ic: "upload" }, - { id: "analysis", label: "Анализ ИИ", ic: "sparkles" }, - { id: "concl", label: "Заключение", ic: "clipboard" }, - { id: "approve", label: "Согласование", ic: "check-circle" }, - { id: "about", label: "О решении", ic: "info" } + { id: "list", label: "Сверки", ic: "list" }, + { id: "load", label: "Загрузка пакета", ic: "upload" }, + { id: "analysis", label: "Анализ ИИ", ic: "sparkles" }, + { id: "concl", label: "Заключение", ic: "clipboard" }, + { id: "approve", label: "Согласование", ic: "check-circle" }, + { id: "about", label: "О решении", ic: "info" } ]; var cur = curStage(); var html = ""; @@ -195,77 +196,109 @@ function navHtml() { }); return html; } +function curStage() { + if (!cur()) return 1; + if (S.screen === "load") return 1; + if (S.screen === "analysis") return 2; + if (S.screen === "concl") return 3; + if (S.screen === "approve") return 4; + var st = cur().stage; + if (st === "sent" || st === "approved" || st === "returned") return 4; + return 1; +} -S = load() || defaultState(); -$("pageTitle").textContent = SCREENS[S.screen].title; -$("pageSub").style.display = "none"; -$("sbAvatar").textContent = initials(S.candidate.name); -$("sbName").textContent = S.candidate.name; -$("crumbRole").textContent = S.role === "leader" ? "Руководитель" : "Эксперт"; -$("snav").innerHTML = navHtml(); - -/* ---------- Шапка-карточка кандидата (экраны 1–3) ---------- */ -function candHead(sub, extra) { - var c = S.candidate; - var done = cand()._doneCount || 0; - return '
' + - '
' + - '
' + - '
' + esc(c.name) + "
" + - '
' + esc(c.post) + - " · Пакет: " + esc(c.submitted) + "
" + - "
" + - pill(STAGE_CHIP[cand().stage] || "—", cand().stage === "sent" ? "warn" : cand().stage === "approved" ? "ok" : "info") + +/* ============================================================ + ЭКРАН «СВЕРКИ» — список проверок + ============================================================ */ +function renderList() { + var cards = S.cards || []; + var f = S.filter; + var filtered = cards.filter(function (c) { + if (!f) return true; + return (c.name + " " + (c.post || "")).toLowerCase().indexOf(f) !== -1; + }); + var rows = filtered.map(function (c) { + var v = violationsOf(c); + var confirmed = v.filter(function (x) { return x.check === "confirmed"; }).length; + return '
' + + '' + icon("user") + "" + + '
' + esc(c.name) + "" + + '' + esc(c.post || "") + " · Пакет: " + esc(c.submitted) + "
" + + pill(STAGE_CHIP[c.stage] || "—", STAGE_TONE[c.stage] || "gray") + + '' + confirmed + " найдено" + + "
"; + }).join(""); + if (!rows) rows = '
Ничего не найденоСмените запрос.
'; + return '
' + + '
' + + kpi(String(cards.length), "Всего сверок", "пакеты кандидатов") + + kpi(String(cards.filter(function (c) { return c.stage === "sent"; }).length), "На согласовании", "у руководителя") + + kpi(String(cards.filter(function (c) { return c.stage === "approved"; }).length), "Согласовано", "итого") + + kpi(String(cards.filter(function (c) { return c.stage === "draft" || c.stage === "analyzed"; }).length), "В работе", "у эксперта") + "
" + - '
' + STEPS.map(function (s, i) { - var st = (i + 1 < curStage()) ? "done" : (i + 1 === curStage()) ? "current" : ""; - return '
" + (i + 1) + "" + - '' + esc(s.label) + "
" + (i < STEPS.length - 1 ? '
' : ""); - }).join("") + "
" + - (extra || "") + - "
"; + '
' + + '
' + + '
' + + '' + + '
' + + '" + + '" + + "
" + + '
' + rows + "
" + + "
" + + "
" + + "
"; } /* ============================================================ ЭКРАН 1 — ЗАГРУЗКА ПАКЕТА ДОКУМЕНТОВ ============================================================ */ -function renderLoad() { - var c = cand(); - var files = S.files; +var FILE_TYPES = ["ДИ", "ПЗ", "Анкета"]; - var zoneHtml = ["ДИ", "ПЗ", "Анкета"].map(function (t) { - var f = null; - for (var i = 0; i < files.length; i++) if (files[i].type === t) { f = files[i]; break; } - var has = !!f; +function filesOf(card) { + if (card.files && card.files.length) return card.files; + return FILE_TYPES.map(function (t) { return { type: t, name: "", status: "missing" }; }); +} +function filesComplete(card) { + return filesOf(card).every(function (f) { return f.status === "done"; }); +} + +function renderLoad() { + var c = cur(); + var files = c.files && c.files.length ? c.files : FILE_TYPES.map(function (t) { return { type: t, name: "", status: "missing" }; }); + var missing = files.filter(function (f) { return f.status === "missing"; }).length; + var complete = missing === 0; + var btn = complete + ? '" + : 'Осталось загрузить: ' + missing + ""; + + var zoneHtml = files.map(function (f) { + var has = f.status === "done"; var ic = has ? "file" : "upload"; - var fname = has ? f.name : "Файл ещё не загружен"; - var sub = has ? (f.status === "done" ? "Обработан" : "Ожидание обработки") : "Перетащите или выберите файл"; - return '
' + + var fname = f.name || (f.status === "pending" ? "Обработка файла…" : "Файл ещё не загружен"); + var sub = has ? "Обработан" : (f.status === "pending" ? "Обработка…" : "Выбрать файл"); + return '
' + '' + icon(ic) + "" + - '' + t + "" + - '' + esc(fname) + "" + + '' + esc(f.type) + "" + + '' + esc(fname) + "" + '' + esc(sub) + "" + - '" + + '' + + '" + "
"; }).join(""); - var rows = files.map(function (f, i) { - var st = f.status === "done" ? "ok" : "warn"; - var stLabel = f.status === "done" ? "Обработан" : "В обработке"; + var rows = files.map(function (f) { + if (f.status === "missing") return ""; return '
' + '' + icon("file") + "" + '
' + esc(f.name) + "" + - 'Тип: ' + esc(f.type) + "
" + - '' + stLabel + "" + - '
"; + 'Тип: ' + esc(f.type) + " · " + esc(f.date || "") + "
" + + '
'; }).join(""); - var btns = '" + - ''; - - return '
' + candHead() + + return '
' + '
' + - kpi(String(files.length) + " / 3", "Документов загружено", "пакет кандидата") + + kpi(String(files.length - missing) + " / " + files.length, "Документов загружено", "пакет кандидата") + kpi("150 мин", "Труда без автоматизации", "на одно назначение") + kpi("3 р.д", "Срок подготовки заключения", "по регламенту") + "
" + @@ -276,35 +309,54 @@ function renderLoad() { "Заполните сведения о кандидате и загрузите комплект документов — система сверит их с нормативами и найдёт расхождения.
" + '
' + '
' + - '
' + + '
' + '
' + - '
' + + '
' + "" + '
' + 'Загрузка документов' + '
' + '3 обязательных файла
' + '
' + zoneHtml + "
" + - '
' + + (missing ? "" : '
' + 'Загруженные файлы' + - '
' + - 'Ожидание загрузки / обработки
' + - '
' + rows + "
" + - '
' + btns + "
" + + '
' + + '
' + rows + "
") + + '
' + btn + "
" + "" + "" + ""; } +/* ---------- Шапка-карточка кандидата ---------- */ +function candHead() { + var c = cur(); + return '
' + + '
' + + '
' + + '
' + esc(c.name) + "
" + + '
' + esc(c.post) + + " · Пакет: " + esc(c.submitted) + "
" + + "
" + + pill(STAGE_CHIP[c.stage] || "—", STAGE_TONE[c.stage] || "gray") + + "
" + + '
' + STEPS.map(function (s, i) { + var st = (i + 1 < curStage()) ? "done" : (i + 1 === curStage()) ? "current" : ""; + return '
' + (i + 1) + "" + + "" + esc(s.label) + "
" + (i < STEPS.length - 1 ? '
' : ""); + }).join("") + "
" + + "
"; +} + /* ============================================================ ЭКРАН 2 — АНАЛИЗ ИИ И ПРОВЕРКА ВЫВОДОВ ============================================================ */ function violCard(v) { - var ch = S.checks[v.id]; + var ch = v.check || "pending"; var t = v.type === "contra" ? "contra" : "norm"; var tLabel = v.type === "contra" ? "Расхождение" : "Несоответствие нормативу"; var normLine = (v.type === "norm") - ? ('Норматив: ' + esc(v.norm) + '') + ? 'Норматив: ' + esc(v.norm) + "" : 'Расхождение в данных документов'; var body = '
' + icon(t === "contra" ? "compare" : "scale") + "
" + @@ -314,39 +366,46 @@ function violCard(v) { '
Документ: ' + esc(v.doc) + " · Пункт: " + esc(v.item) + "
" + normLine + ""; - var actions = ch === "pending" - ? '" + - '' + ? '" + + '' : ch === "confirmed" - ? '' + pill("Подтверждено", "ok") - : pill("Отклонено", "muted") + ''; - + ? '' + pill("Подтверждено", "ok") + : pill("Отклонено", "muted") + ''; return '
' + body + '
' + actions + "
"; } -function renderAnalysis() { - var done = 0, confirmed = 0, rejected = 0, pending = 0; - VIOL.forEach(function (v) { - var ch = S.checks[v.id]; - if (ch === "pending") pending++; - else if (ch === "confirmed") confirmed++; - else rejected++; - if (ch !== "pending") done++; +function applyViolFilters() { + var c = cur(); + var list = $("violList"); + if (!c || !list) return; + var typeEl = $("vType"), stateEl = $("vState"); + var ty = typeEl ? typeEl.value : "all"; + var st = stateEl ? stateEl.value : "all"; + var html = ""; + violationsOf(c).forEach(function (v) { + if (ty !== "all" && v.type !== ty) return; + if (st !== "all" && (v.check || "pending") !== st) return; + html += violCard(v); }); + if (!html) html = '
По фильтру ничего нетСмените тип нарушения или статус проверки.
'; + list.innerHTML = html; +} - var tone = S.analysisStatus === "done" ? "ok" : "risk"; - var banner = S.analysisStatus === "done" - ? '
Сверка завершена: найдено ' + VIOL.length + " несоответствий и " + VIOL.filter(function(v){return v.type==="contra"}).length + " расхождений" + - '22.04 с
' - : '
Анализ не завершён: один документ не распознан' + - '
"; +function renderAnalysis() { + var c = cur(); + var vs = violationsOf(c); + var confirmed = vs.filter(function (v) { return v.check === "confirmed"; }).length; + var rejected = vs.filter(function (v) { return v.check === "rejected"; }).length; + var pending = vs.filter(function (v) { return v.check === "pending"; }).length; - return '
' + candHead() + banner + + return '
' + candHead() + + '
Сверка завершена: ' + vs.length + " выводов — сверка с нормативами и поиск противоречий" + + '22.04 с
' + '
' + - kpi(String(VIOL.length), "Найдено несоответствий", "сверка с нормативами") + + kpi(String(vs.length), "Найдено выводов", "сверка с нормативами") + kpi(String(confirmed), "Подтверждено", "экспертом") + kpi(String(rejected), "Отклонено", "ложные срабатывания") + kpi(String(pending), "Осталось проверить", "к ручному осмотру") + @@ -354,14 +413,14 @@ function renderAnalysis() { '
' + '
' + '
' + - 'Выводы ИИ · ' + (done + "/" + VIOL.length) + " проверено" + + 'Выводы ИИ · ' + (vs.length - pending + "/" + vs.length) + " проверено" + '
' + - '' + '' + '' + '' + "" + - '' + '' + '' + '' + @@ -371,7 +430,7 @@ function renderAnalysis() { '
' + '
' + '" + - 'В заключение войдут ' + confirmed + " подтверждённых" + (confirmed === 1 ? " пункт" : confirmed >= 2 && confirmed <= 4 ? " пункта" : " пунктов") + "" + + 'В заключение войдут ' + confirmed + " подтверждённых" + "
" + "
" + "
" + @@ -381,11 +440,11 @@ function renderAnalysis() { /* ============================================================ ЭКРАН 3 — СОСТАВЛЕНИЕ ЗАКЛЮЧЕНИЯ ============================================================ */ -function confirmedViolHtml(n) { +function confirmedViolHtml(card) { var c = 0; var html = ""; - VIOL.forEach(function (v) { - if (S.checks[v.id] === "confirmed") { + violationsOf(card).forEach(function (v) { + if (v.check === "confirmed") { c++; var toneDot = v.type === "contra" ? "var(--kt-ai-status-warn-fg)" : "var(--kt-ai-status-info-fg)"; var normTxt = v.type === "norm" ? esc(v.norm) : "Расхождение данных"; @@ -398,11 +457,11 @@ function confirmedViolHtml(n) { return html; } -function normativeRefsHtml() { +function normativeRefsHtml(card) { var seen = {}; var out = []; - VIOL.forEach(function (v) { - if (S.checks[v.id] === "confirmed" && v.type === "norm") { + violationsOf(card).forEach(function (v) { + if (v.check === "confirmed" && v.type === "norm" && v.normId) { var ref = NORMATIVES.find(function (nr) { return nr.id === v.normId; }); if (ref && !seen[ref.id]) { seen[ref.id] = true; @@ -416,9 +475,22 @@ function normativeRefsHtml() { }).join(""); } +function defaultConclText(card) { + return "ЗАКЛЮЧЕНИЕ о соответствии кандидата требованиям должности\n\n" + + "Кандидат: " + card.name + "\n" + + "Должность: " + card.post + "\n" + + "Дата пакета: " + card.submitted + "\n\n" + + "Проверены документы: должностная инструкция, пояснительная записка, анкета кандидата. " + + "Сверка выполнена с квалификационными требованиями и внутренними нормативами фонда.\n\n" + + "По результатам сверки зафиксированы несоответствия и расхождения, подробно изложенные ниже. " + + "При назначении кандидата на должность рекомендуется устранить замечания в порядке, установленном правилами фонда.\n\n" + + "Составил: эксперт кадровой службы"; +} + function renderConcl() { - var c = cand(); + var c = cur(); var tone = c.stage === "sent" ? "warn" : "gray"; + var shown = c.text && c.text.trim() ? c.text : defaultConclText(c); return '
' + candHead() + '
' + '
' + @@ -438,15 +510,15 @@ function renderConcl() { '
' + '
' + '
' + - 'Подтверждённые несоответствия (' + (function(){var n=0;VIOL.forEach(function(v){if(S.checks[v.id]==="confirmed")n++;});return n;})() + ")" + + "Подтверждённые несоответствия (" + confirmCount(c) + ")" + '
' + 'из «Анализ ИИ»' + "
" + - '
' + confirmedViolHtml() + "
" + + "
" + confirmedViolHtml(c) + "
" + "
" + '
' + '
Пункты нормативов
' + - normativeRefsHtml() + + normativeRefsHtml(c) + "
" + "
" + "
" + @@ -458,34 +530,40 @@ function renderConcl() { ЭКРАН 4 — СОГЛАСОВАНИЕ РУКОВОДИТЕЛЕМ ============================================================ */ function renderApprove() { - var c = cand(); + var c = cur(); var chip = c.stage === "approved" ? pill("Согласовано", "ok") - : c.stage === "sent" - ? pill("На согласовании", "warn") - : pill("Вернуто на доработку", "risk"); - var c2 = c; - var actions = - c.stage === "approved" - ? '
Заключение согласовано ' + esc(c.approvedAt || "сегодня") + ". Кадровая служба получит уведомление.
" - : c.stage === "sent" - ? '
' + - '
' + - '
' + - '" + - '" + - "
" - : '
Черновик возвращён эксперту на доработку. Дождитесь новой отправки.
'; + : c.stage === "returned" + ? pill("На доработке", "risk") + : pill("На согласовании", "warn"); + var sentLine = c.sentAt ? "Отправлено: " + fmtDT(c.sentAt) : "Отправлено: —"; + var approvedLine = c.stage === "approved" ? "Согласовано: " + fmtDT(c.approvedAt) : "Согласование: ожидается"; - var sentLine = c.sent ? "Отправлено: " + esc(c.sent) : "Отправлено: —"; - var approvedLine = c.stage === "approved" ? "Согласовано руководителем: " + esc(c.approvedAt || "—") : "Согласование: ожидается"; + var text = c.text || defaultConclText(c); + if (c.returned && c.stage === "returned") text += "\n\nВозвращено на доработку: " + c.returned; + + var actions = ""; + if (c.stage === "approved") { + actions = '
Заключение согласовано. Кадровая служба получит уведомление.
'; + } else if (c.stage === "sent") { + actions = + '
' + + '
' + + '
' + + '
' + + '" + + '" + + "
"; + } else { + actions = '
Черновик возвращён эксперту на доработку. Дождитесь новой отправки.
'; + } return '
' + candHead() + '
' + '
' + - kpi(esc(c.name.split(" ")[0] + " " + c.name.split(" ")[1]), "Кандидат", "на согласовании") + - kpi(sentLine.split(":")[1] || "—", "Отправлено", "экспертом") + - kpi(c.sent ? "1 день" : "—", "В согласовании", "из 3 р.д по регламенту") + + kpi(esc(c.name.split(" ")[0] + " " + (c.name.split(" ")[1] || "")), "Кандидат", "на согласовании") + + kpi(c.sentAt ? "от " + fmtDT(c.sentAt).split(",")[1] : "—", "Отправлено", "экспертом") + + kpi(c.sentAt ? "1 день" : "—", "В согласовании", "из 3 р.д по регламенту") + "
" + '
' + '
' + @@ -497,7 +575,7 @@ function renderApprove() { "
" + '
' + '
Подтверждённые несоответствия
' + - confirmedViolHtml() + + confirmedViolHtml(c) + actions + "
" + "
" + @@ -556,19 +634,11 @@ function renderAbout() { '
' + '
Процесс
' + '
' + proc.map(function (p, i) { - return '
' + (i + 1) + '' + - "
" + esc(p.t) + '
' + (p.ai ? "ИИ" : (p.who ? esc(p.who) : "эксперт / руководитель")) + "
"; + return '
' + (i + 1) + "" + + "
" + esc(p.t) + '
' + (p.ai ? "ИИ" : (p.who ? esc(p.who) : "эксперт / руководитель")) + "
"; }).join("") + "
" + "
" + '
' + - '
' + - '
Роль пользователя
' + - '
' + - '
Эксперт
загружает документы, проверяет выводы ИИ, готовит и отправляет заключение
' + - '
Руководитель
просматривает итог и согласовывает
' + - '
Кадровая служба
передаст пакет документов и получает копию заключения
' + - "
" + - "
" + '
' + '
Что выполняет ИИ
' + '
' + @@ -578,11 +648,6 @@ function renderAbout() { "
" + "
" + "
" + - '
' + - kpi("Владелец процесса", "Исполнитель", "—") + - kpi("Ветеринация на Vibe AI", "Формат", "макет → рабочее решение") + - kpi("ИИ-агент Alem", "Опция", "работа с документами") + - "
" + "
" + "
"; } @@ -590,219 +655,304 @@ function renderAbout() { /* ============================================================ РЕНДЕР ============================================================ */ +function setShell() { + var sc = SCREENS[S.screen] || SCREENS.list; + var t = $("pageTitle"); if (t) t.textContent = sc.title; + var sub = $("pageSub"); + if (sub) { + if (S.screen === "list" || S.screen === "about") { sub.style.display = "none"; } + } + var cr = $("crumbRole"); + if (cr) cr.textContent = S.role === "leader" ? "Руководитель" : "Эксперт"; + var av = $("sbAvatar"); if (av) av.textContent = S.role === "leader" ? "РУ" : "ЭК"; + var nm = $("sbName"); if (nm) nm.textContent = S.role === "leader" ? "Руководитель" : "Эксперт"; + var sn = $("snav"); if (sn) sn.innerHTML = navHtml(); +} + function render() { var v = $("view"); - if (S.screen === "load") v.innerHTML = renderLoad(); - else if (S.screen === "analysis") v.innerHTML = renderAnalysis(); - else if (S.screen === "concl") v.innerHTML = renderConcl(); - else if (S.screen === "approve") v.innerHTML = renderApprove(); - else v.innerHTML = renderAbout(); + if (S.screen === "list") v.innerHTML = renderList(); + else if (S.screen === "load" && cur()) v.innerHTML = renderLoad(); + else if (S.screen === "analysis" && cur()) v.innerHTML = renderAnalysis(); + else if (S.screen === "concl" && cur()) v.innerHTML = renderConcl(); + else if (S.screen === "approve" && cur()) v.innerHTML = renderApprove(); + else if (S.screen === "about") v.innerHTML = renderAbout(); + else { S.screen = "list"; v.innerHTML = renderList(); } + setShell(); - var sc = SCREENS[S.screen]; - $("pageTitle").textContent = sc.title; - $("snav").innerHTML = navHtml(); - - /* После вставки — заполнение значений в поле/подсветка текущего и навешивание действий через делегирование */ - if (S.screen === "analysis") { - applyViolFilters(); + if (S.screen === "analysis") applyViolFilters(); + if (S.screen === "concl" && cur()) { + var t = $("conclText"); + if (t) t.value = cur().text && cur().text.trim() ? cur().text : defaultConclText(cur()); } - if (S.screen === "concl") { - $("conclText").value = S.text; - } - if (S.screen === "approve") { - $("approveText").textContent = S.text; + if (S.screen === "approve" && cur()) { + var at = $("approveText"); + if (at) at.textContent = renderApproveText(); } } +function renderApproveText() { + var c = cur(); + var text = c.text || defaultConclText(c); + if (c.returned && c.stage === "returned") text += "\n\nВозвращено на доработку: " + c.returned; + return text; +} -function setScreen(name) { - S.screen = name; - save(); - closeDrawer(); +function go(screen) { + S.screen = screen; render(); } - -/* ---------- Фильтры экрана 2 ---------- */ -function applyViolFilters() { - var list = $("violList"); - if (!list) return; - var typeEl = $("vType"), stateEl = $("vState"); - var ty = typeEl ? typeEl.value : "all"; - var st = stateEl ? stateEl.value : "all"; - var html = ""; - VIOL.forEach(function (v) { - if (ty !== "all" && v.type !== ty) return; - if (st !== "all" && S.checks[v.id] !== st) return; - html += violCard(v); +function openCard(id) { + getCard(id).then(function (c) { + S.curCard = c; + S.candId = c.id; + go("load"); }); - list.innerHTML = html; +} +function newCard() { + createCard({ name: "", post: "" }).then(function (c) { + S.curCard = c; + S.candId = c.id; + go("load"); + }); +} +function refreshCard() { + if (!S.candId) return; + getCard(S.candId).then(function (c) { S.curCard = c; render(); }); } -/* ---------- Делегированные обработчики ---------- */ -document.addEventListener("click", function (e) { - var navBtn = e.target.closest("[data-nav]"); - if (navBtn) { setScreen(navBtn.getAttribute("data-nav")); return; } +function saveChecks(card) { + card.violations = VIOL.map(function (t) { + var v = (card.violations || []).find(function (x) { return x.id === t.id; }); + return { id: t.id, doc: t.doc, item: t.item, desc: t.desc, type: t.type, norm: t.norm, normId: t.normId, + check: (card.checks && card.checks[String(t.id)]) || "pending" }; + }); + return saveCard(card.id, { checks: card.checks, violations: card.violations }); +} - var zoneBtn = e.target.closest(".up-grid button, .up-zones button.kt-ai-btn"); - if (zoneBtn) { - var t = zoneBtn.getAttribute("data-type"); - var f = S.files.find(function (x) { return x.type === t; }); - if (f) { - f.status = "pending"; - save(); - toast("Файл загружен — ожидание обработки", "info"); - render(); - } +function refreshCards() { + allCards().then(function (cards) { + S.cards = cards; + S.curCard = cards.find(function (c) { return c.id === S.candId; }) || null; + render(); + }); +} + +/* ---------- Обработчики ---------- */ +document.addEventListener("click", function (e) { + var cardRow = e.target.closest("[data-card]"); + if (cardRow) { openCard(parseInt(cardRow.getAttribute("data-card"), 10)); return; } + + var navBtn = e.target.closest("[data-nav]"); + if (navBtn) { + var id = navBtn.getAttribute("data-nav"); + if (id === "list" || id === "about") { go(id); return; } + if (!cur()) { go("list"); return; } + if (id === "approve" && S.role !== "leader") { toast("Раздел доступен в роли руководителя", "info"); return; } + if (id === "load" || id === "analysis" || id === "concl") go("list"); + if (cur().stage === "uploaded") go("load"); + else if (cur().stage === "analyzed" || cur().stage === "draft") go("analysis"); + else if (cur().stage === "sent") go("approve"); + else go("approve"); return; } + if (e.target.id === "btnNewCard") { newCard(); return; } + if (e.target.id === "btnRefresh") { refreshCards(); return; } - var xBtn = e.target.closest(".up-list button.kt-ai-icon-button"); - if (xBtn) { - var t2 = xBtn.getAttribute("data-type"); - var f2 = S.files.find(function (x) { return x.type === t2; }); - if (f2) { f2.status = "pending"; save(); render(); } + // Экран 1: загрузка файлов + var fBtn = e.target.closest("[data-filetype]"); + if (fBtn) { + var t = fBtn.getAttribute("data-filetype"); + var inp = $("file-" + t); + if (inp) inp.click(); return; } if (e.target.id === "btnRunAnalysis") { - S.analysisStatus = "done"; - save(); - closeDrawer(); - S.screen = "analysis"; - render(); - toast("Анализ завершён", "ok"); - return; - } - - if (e.target.id === "btnCancelLoad") { - S.files = FILES_INIT.map(function (f) { return Object.assign({}, f); }); - save(); - toast("Загрузка сброшена", "info"); - render(); + if (!filesComplete(cur())) { toast("Загрузите все три файла", "warn"); return; } + saveChecks(cur()).then(function () { + saveCard(cur().id, { stage: "analyzed" }).then(function () { + go("analysis"); + toast("Сверка завершена, найдены выводы ИИ", "ok"); + }); + }); return; } + // Экран 2: подтверждение / отклонение var actBtn = e.target.closest("[data-act]"); if (actBtn) { - var id = parseInt(actBtn.getAttribute("data-id"), 10); + var vid = parseInt(actBtn.getAttribute("data-vid"), 10); var act = actBtn.getAttribute("data-act"); - if (act === "confirm") S.checks[id] = "confirmed"; - else if (act === "reject") S.checks[id] = "rejected"; - else if (act === "reopen") S.checks[id] = "pending"; - save(); - if (act === "confirm") toast("Несоответствие подтверждено", "ok"); - if (act === "reject") toast("Отклонено как ложное срабатывание", "info"); - if (act === "reopen") toast("Возвращено на проверку", "info"); - applyViolFilters(); - refreshKpis(); + cur().checks = cur().checks || {}; + if (act === "confirm") cur().checks[String(vid)] = "confirmed"; + else if (act === "reject") cur().checks[String(vid)] = "rejected"; + else cur().checks[String(vid)] = "pending"; + saveChecks(cur()).then(function () { + if (act === "confirm") toast("Несоответствие подтверждено", "ok"); + if (act === "reject") toast("Отклонено как ложное срабатывание", "info"); + if (act === "reopen") toast("Возвращено на проверку", "info"); + applyViolFilters(); + }); return; } - if (e.target.id === "btnRetryAnalysis") { - S.analysisStatus = "done"; - save(); - render(); - toast("Анализ завершён", "ok"); - return; - } + if (e.target.id === "btnToConcl") { go("concl"); return; } - if (e.target.id === "btnToConcl") { - S.screen = "concl"; save(); render(); - return; - } + // Экран 3: заключение if (e.target.id === "btnSaveConcl") { var ta = $("conclText"); - if (ta) { S.text = ta.value; save(); } - var cc = cand(); - if (cc.stage === "uploaded" || cc.stage === "ready" || cc.stage === "analyzed") cc.stage = "draft"; - save(); + if (ta) cur().text = ta.value; + if (["uploaded", "analyzed"].indexOf(cur().stage) !== -1) cur().stage = "draft"; + saveCard(cur().id, { text: cur().text, stage: cur().stage }); toast("Черновик сохранён", "ok"); render(); return; } if (e.target.id === "btnSendConcl") { var ta2 = $("conclText"); - if (ta2) S.text = ta2.value; - var cc2 = cand(); - cc2.stage = "sent"; - cc2.sent = "21.09.2026, " + new Date().toTimeString().slice(0, 5); - S.text = S.text + "\n\nОтправлено на согласование: " + cc2.sent; - save(); + if (ta2) cur().text = ta2.value; + cur().stage = "sent"; + cur().sentAt = new Date().toISOString(); + cur().returned = null; + saveCard(cur().id, { text: cur().text, stage: "sent", sentAt: cur().sentAt, returned: null }); toast("Отправлено на согласование", "ok"); - S.screen = "approve"; - render(); + go("approve"); return; } + + // Экран 4: согласование if (e.target.id === "btnApprove") { - var cc3 = cand(); - cc3.stage = "approved"; + cur().stage = "approved"; + cur().approvedAt = new Date().toISOString(); var rc = $("rejectComment"); - cc3.approvedAt = rc && rc.value ? "С комментарием: " + rc.value : "без замечаний"; - save(); + cur().comment = rc && rc.value ? rc.value : ""; + saveCard(cur().id, { stage: "approved", approvedAt: cur().approvedAt, comment: cur().comment }); toast("Заключение согласовано", "ok"); - render(); + go("list"); return; } if (e.target.id === "btnReturnConcl") { - var cc4 = cand(); var rc2 = $("rejectComment"); - cc4.stage = "draft"; - cc4.returned = rc2 && rc2.value ? rc2.value : "Возвращено в экспертизу"; - S.role = "expert"; - S.screen = "concl"; - save(); - $("crumbRole").textContent = S.role === "leader" ? "Руководитель" : "Эксперт"; + cur().stage = "returned"; + cur().returned = rc2 && rc2.value ? rc2.value : "Возвращено в экспертизу на доработку"; + saveCard(cur().id, { stage: "returned", returned: cur().returned }); toast("Возвращено на доработку", "warn"); - render(); + go("load"); return; } + // Переключение роли if (e.target.closest("#sbUser")) { S.role = S.role === "expert" ? "leader" : "expert"; - $("crumbRole").textContent = S.role === "leader" ? "Руководитель" : "Эксперт"; - save(); - toast("Переключено на роль: " + ($("crumbRole").textContent), "info"); + setShell(); + toast("Переключено на роль: " + (S.role === "leader" ? "Руководитель" : "Эксперт"), "info"); } }); -/* Перерисовка KPI вверху экрана 2 после подтверждения/отклонения */ -function refreshKpis() { - var strip = document.querySelector(".kt-ai-kpi-strip"); - if (!strip) return; - var confirmed = 0, rejected = 0, pending = 0; - VIOL.forEach(function (v) { - var ch = S.checks[v.id]; - if (ch === "confirmed") confirmed++; - else if (ch === "rejected") rejected++; - else pending++; - }); - var vals = [ - [String(pending), "Осталось проверить", "к ручному осмотру"], - [String(confirmed), "Подтверждено", "экспертом"], - [String(rejected), "Отклонено", "ложные срабатывания"] - ]; - var cards = strip.querySelectorAll(".kt-ai-kpi"); - if (cards.length >= 4) { - cards[3].querySelector(".value").textContent = pending; - cards[1].querySelector(".value").textContent = confirmed; - cards[2].querySelector(".value").textContent = rejected; +document.addEventListener("change", function (e) { + if (e.target.id === "vType" || e.target.id === "vState") { applyViolFilters(); return; } + if (e.target.id && e.target.id.indexOf("file-") === 0) { + if (!e.target.files || !e.target.files.length) return; + var t = e.target.id.slice(5); + var f = cur().files.find(function (x) { return x.type === t; }); + if (!f) return; + f.name = e.target.files[0].name; + f.date = fmtDate(new Date().toISOString()); + f.status = "done"; + saveCard(cur().id, { files: cur().files }).then(function () { + toast("Файл загружен, статус: Обработан", "ok"); + render(); + }); } +}); + +document.addEventListener("input", function (e) { + if (e.target.id === "fSearch") { S.filter = e.target.value.toLowerCase(); renderListOnly(); } +}); + +function renderListOnly() { + var wrap = $("cardList"); + if (!wrap) return; + var filtered = (S.cards || []).filter(function (c) { + if (!S.filter) return true; + return (c.name + " " + (c.post || "")).toLowerCase().indexOf(S.filter) !== -1; + }); + var rows = filtered.map(function (c) { + return '
' + + '' + icon("user") + "" + + '
' + esc(c.name) + "" + + '' + esc(c.post || "") + " · Пакет: " + esc(c.submitted) + "
" + + pill(STAGE_CHIP[c.stage] || "—", STAGE_TONE[c.stage] || "gray") + + '' + confirmCount(c) + " найдено
"; + }).join(""); + wrap.innerHTML = rows || '
Ничего не найденоСмените запрос.
'; } -/* Селекты на экране 2 */ -document.addEventListener("change", function (e) { - if (e.target.id === "vType" || e.target.id === "vState") { - applyViolFilters(); - } +document.addEventListener("keydown", function (e) { + if (e.key === "Enter" && e.target.id === "fSearch") { return; } + if (e.key === "Escape") { return; } }); /* Burger */ $("burger").addEventListener("click", function () { var sb = $("sidebar"); - var st = getComputedStyle(sb); if (sb.dataset.open === "true") { sb.dataset.open = "false"; sb.style.position = "sticky"; } else { sb.dataset.open = "true"; sb.style.position = "fixed"; sb.style.zIndex = 50; sb.style.top = "0"; sb.style.left = "0"; sb.style.right = "0"; sb.style.bottom = "0"; } }); -/* Первый рендер */ -render(); -if (S.screen === "concl") { var t = $("conclText"); if (t) t.value = S.text; } +/* ---------- Инициализация ---------- */ +function seed() { + return allCards().then(function (cards) { + if (cards && cards.length) { S.cards = cards; } + else { + var base = [ + { name: "Ахметов Ерлан Сергеевич", post: "Руководитель отдела", stage: "approved" }, + { name: "Бекмембетова Динара Талгат", post: "Начальник управления", stage: "approved" }, + { name: "Сапаров Ибрагим Нурлан", post: "Заместитель директора", stage: "sent" }, + { name: "Мырзагалиев Айдос Керек", post: "Руководитель направления", stage: "draft" }, + { name: "Оралова Гульнара Бакыт", post: "Начальник сектора", stage: "draft" }, + { name: "Токтаров Марат Алиши", post: "Директор подразделения", stage: "uploaded" }, + { name: "Утебаева Алия Дильдари", post: "Заместитель руководителя", stage: "uploaded" }, + { name: "Жумабаев Нурлан Серик", post: "Руководитель службы", stage: "uploaded" } + ]; + var subDates = ["10.09.2026", "12.09.2026", "15.09.2026", "17.09.2026", "19.09.2026", "20.09.2026", "20.09.2026", "21.09.2026"]; + var checks = {}; + var i; + for (i = 0; i < VIOL.length; i++) { + checks[String(VIOL[i].id)] = (i < 5) ? "confirmed" : (i === 5 ? "rejected" : "pending"); + } + var viol = VIOL.map(function (t) { + return { id: t.id, doc: t.doc, item: t.item, desc: t.desc, type: t.type, norm: t.norm, normId: t.normId, check: checks[String(t.id)] }; + }); + var baseStage = {}; + base.forEach(function (b) { baseStage[b.name] = b.stage; }); + var proms = base.map(function (b, idx) { + var stage = baseStage[b.name]; + var isAfterLoad = stage === "draft" || stage === "sent" || stage === "approved" || stage === "returned"; + return createCard({ + name: b.name, post: b.post, stage: stage, submitted: subDates[idx], + files: FILE_TYPES.map(function (t) { + return { type: t, name: t + "_" + b.name.split(" ")[0] + ".pdf", status: "done", date: "19.09.2026" }; + }), + checks: isAfterLoad ? checks : {}, + violations: isAfterLoad ? viol : [], + text: isAfterLoad ? "ЗАКЛЮЧЕНИЕ о соответствии кандидата требованиям должности\n\nКандидат: " + b.name + "\nДолжность: " + b.post + "\n\nПо результатам сверки зафиксированы несоответствия и расхождения." : "" + }); + }); + return Promise.all(proms).then(function (created) { S.cards = created; }); + } + return S.cards; + }); +} + +allCards().then(function (cards) { + if (!cards || !cards.length) { + var seedProm = seed(); + seedProm.then(function () { + allCards().then(function (c2) { S.cards = c2; render(); }); + }); + } else { + S.cards = cards; + render(); + } +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..0db7f80 --- /dev/null +++ b/server.js @@ -0,0 +1,95 @@ +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"); + +function readData() { + try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { items: [], log: [] }; } +} +function writeData(d) { fs.writeFileSync(DATA, JSON.stringify(d, null, 2)); } +function body(req) { + return new Promise((resolve) => { + let s = ""; + req.on("data", (c) => (s += c)); + req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } }); + }); +} + +http.createServer(async (req, res) => { + const url = req.url.split("?")[0]; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + + // API: все карточки кандидата + if (req.method === "GET" && url === "/api/cards") { + const d = readData(); + res.end(JSON.stringify(d.items || [])); + return; + } + + // API: получить одну карточку + if (req.method === "GET" && url.startsWith("/api/cards/")) { + const id = url.split("/").pop(); + const d = readData(); + const card = (d.items || []).find((x) => String(x.id) === String(id)); + if (!card) { res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; } + res.end(JSON.stringify(card)); + return; + } + + // API: создать новую карточку + if (req.method === "POST" && url === "/api/cards") { + const b = await body(req); + const d = readData(); + const card = { + id: Date.now(), + name: b.name || "", + post: b.post || "", + submitted: new Date().toISOString().slice(0, 10), + stage: "uploaded", + files: [], + analysisStatus: "idle", + violations: [], + checks: {}, + text: "", + sentAt: null, + returned: null, + approvedAt: null, + createdAt: new Date().toISOString() + }; + d.items.push(card); + d.log = d.log || []; + d.log.push({ when: new Date().toISOString(), what: "Создана карточка «" + card.name + "»" }); + writeData(d); + res.end(JSON.stringify(card)); + return; + } + + // API: обновить карточку + if (req.method === "PUT" && url.startsWith("/api/cards/")) { + const id = url.split("/").pop(); + const b = await body(req); + const d = readData(); + const idx = (d.items || []).findIndex((x) => String(x.id) === String(id)); + if (idx === -1) { res.writeHead(404); res.end(JSON.stringify({ error: "not found" })); return; } + d.items[idx] = Object.assign(d.items[idx], b, { id }); + d.log = d.log || []; + d.log.push({ when: new Date().toISOString(), what: "Обновлена карточка «" + d.items[idx].name + "»" }); + writeData(d); + res.end(JSON.stringify(d.items[idx])); + return; + } + + // Статика + let file = url === "/" ? "/index.html" : url; + const full = path.join(__dirname, file); + if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) { + const ext = path.extname(full); + const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html"; + res.writeHead(200, { "Content-Type": type + "; charset=utf-8" }); + res.end(fs.readFileSync(full)); + return; + } + res.writeHead(404); res.end("Not found"); +}).listen(PORT, () => console.log("Сервер на порту " + PORT));