"use strict"; const $app = document.getElementById("app"), $top = document.getElementById("side"), $banner = document.getElementById("banner"); let token = localStorage.getItem("token"), me = null, timer = null; const cache = {}; // ---------- утилиты ---------- function h(tag, attrs, ...kids) { const e = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v == null || v === false) continue; if (k.startsWith("on")) e.addEventListener(k.slice(2), v); else if (k === "class") e.className = v; else if (k === "value") e.value = v; else if (v === true) e.setAttribute(k, ""); else e.setAttribute(k, v); } for (const c of kids.flat(9)) if (c != null && c !== false) e.append(c.nodeType ? c : document.createTextNode(c)); return e; } function toast(msg, err) { const t = document.getElementById("toast"); t.textContent = msg; t.className = "show" + (err ? " err" : ""); clearTimeout(toast.t); toast.t = setTimeout(() => t.className = "", err ? 4500 : 1800); } const API = "/api/v1"; let refreshing = null; async function refreshTokens() { const rt = localStorage.getItem("refresh"); if (!rt) return false; refreshing = refreshing || fetch(API + "/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: rt, device: "web" }) }) .then(async r => { if (!r.ok) return false; const d = await r.json(); token = d.access_token; localStorage.setItem("token", token); localStorage.setItem("refresh", d.refresh_token); return true; }).catch(() => false).finally(() => { refreshing = null; }); return refreshing; } // fetch с авторизацией и прозрачным обновлением токена async function authFetch(url, opts = {}) { const go = () => fetch(url, { ...opts, headers: { ...(opts.headers || {}), ...(token ? { Authorization: "Bearer " + token } : {}) } }); let r = await go(); if (r.status === 401 && token && await refreshTokens()) r = await go(); return r; } async function api(method, path, body) { const r = await authFetch(API + path, { method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); if (r.status === 401 && token && !path.startsWith("/auth/")) { logout(); throw new Error("Сессия истекла"); } if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (e) {} if (Array.isArray(d)) d = d.map(x => (x.loc || []).slice(1).join(".") + ": " + x.msg).join("; "); throw new Error(d); } return r.status === 204 ? null : r.json(); } /** Список сотрудников с поиском: вводите имя или фамилию (в любом порядке), список сузится, первый подходящий выберется сам. */ function searchSelect(sel, placeholder = "🔍 Начните вводить имя или фамилию") { const inp = h("input", { type: "search", placeholder, autocomplete: "off" }); inp.addEventListener("input", () => { const words = inp.value.trim().toLowerCase().split(/\s+/).filter(Boolean); let first = null; for (const o of sel.options) { const t = o.text.toLowerCase(), hit = !o.value || words.every(w => t.includes(w)); o.hidden = !hit; if (hit && o.value && !first) first = o; } const cur = sel.selectedOptions[0]; if (words.length && first && (!cur || cur.hidden || !cur.value)) { sel.value = first.value; sel.dispatchEvent(new Event("change")); } }); inp.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); sel.focus(); } }); return h("div", { class: "pick" }, inp, sel); } const guard = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message, true); } }; async function ref(name, fresh) { if (!cache[name] || fresh) cache[name] = await api("GET", "/" + name); return cache[name]; } const fmt = s => s ? new Date(s).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }) : "—"; const STATUS = { new: "Новый", in_progress: "В работе", review: "На проверке", completed: "Выполнен", cancelled: "Отменён" }; const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; const isMgr = () => me && (me.is_admin || me.is_moderator); const isEng = () => me && (isMgr() || me.position?.kind === "engineer"); const badge = o => [h("span", { class: "badge b-" + o.status }, STATUS[o.status]), o.is_overdue ? h("span", { class: "badge b-overdue", style: "margin-left:4px" }, "просрочен") : null]; // ---------- каркас ---------- async function logout() { try { await pushOff(true); } catch (e) {} const rt = localStorage.getItem("refresh"); if (rt) fetch(API + "/auth/logout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: rt }) }).catch(() => {}); token = null; me = null; localStorage.removeItem("token"); localStorage.removeItem("refresh"); route(); } // боковое меню: на широком экране всегда видно, на телефоне выезжает по кнопке ☰ function menu(open) { document.body.classList.toggle("menu-open", open); } document.getElementById("burger").onclick = () => menu(!document.body.classList.contains("menu-open")); document.getElementById("shade").onclick = () => menu(false); function nav() { const groups = [ ["Работа", [["#/work", "🎯", "Задачи"], ["#/", "📋", "Наряды"], ["#/tasks", "🗓", "Задачи и поручения"], ...(isEng() ? [["#/new-order", "➕", "Новый наряд"], ["#/templates", "⏱", "Расписание нарядов"], ["#/checklists", "☑", "Чек-листы"]] : [])]], ["Оборудование", [["#/equipment", "⚙", "Оборудование"], ["#/map", "🗺", "Карта завода"], ["#/incidents", "❗", "Отказы"], ["#/faults", "⚠", "Неисправности"], ...(isEng() ? [["#/locations", "📍", "Места"]] : [])]], // крановое хозяйство — отдельная служба: включается галочкой «Краны — отдельная служба» в настройках сервера ...(me.crane_map ? [["Краны", [["#/cranes", "🏗", "Краны"], ["#/map/cranes", "🗺", "Карта кранов"], ["#/incidents/cranes", "❗", "Отказы по кранам"], ["#/faults/cranes", "⚠", "Неисправности по кранам"]]]] : []), ["Инструмент", [["#/my-tools", "🧰", "Мой инструмент"], ["#/requests", "🛒", "Заявки на инструмент"], ...(canStock() ? [["#/inventory", "🏬", "Склады и учёт"], ["#/map/warehouses", "🗺", "Карта складов"]] : [])]], ["Люди", [["#/people", "👥", "Сотрудники"]]], ...(innerWidth <= 900 ? [["Телефон", [["/m", "📱", "Наряды без связи (моб. версия)"]]]] : []), ...(isEng() ? [["Анализ", [["#/reports", "📊", "Отчёты"], ...(me.reliability ? [["#/reliability", "📈", "Анализ надёжности"]] : [])]]] : []), ...(isMgr() ? [["", [["#/admin", "🛠", "Администрирование"]]]] : []), ]; const cur = location.hash.split("?")[0] || "#/"; const all = groups.flatMap(([, l]) => l.map(([x]) => x)); // подсвечиваем самый длинный подходящий пункт (#/map/warehouses — не #/map) const best = all.filter(x => cur === x || (x !== "#/" && cur.startsWith(x + "/"))).sort((a, b) => b.length - a.length)[0] || ({ "#/order": "#/", "#/task": "#/tasks", "#/new-task": "#/tasks", "#/user": "#/people" })[cur.split("/").slice(0, 2).join("/")] || (cur === "#/" ? "#/" : ""); const role = me.is_admin ? "админ" : me.is_moderator ? "модератор" : me.is_storekeeper ? "склад" : ""; $banner.replaceChildren(...(me.demo_mode ? [demoBanner()] : [])); $top.replaceChildren(h("div", { class: "brand" }, "🔧 Техслужба"), h("a", { class: "meblock", href: "#/user/" + me.id, onclick: () => menu(false) }, avatar(me, "ava sm"), h("div", {}, h("b", {}, me.full_name || me.username), h("div", { class: "small muted" }, [me.position?.name, role].filter(Boolean).join(" · ") || "—", me.position ? (me.on_shift ? " · на смене" : " · не на смене") : ""))), h("nav", {}, ...groups.flatMap(([title, items]) => [title ? h("div", { class: "navh" }, title) : h("div", { class: "navsep" }), ...items.map(([href, ic, t]) => h("a", { href, class: href === best ? "on" : "", onclick: () => menu(false) }, h("i", {}, ic), t))])), h("div", { class: "sidelinks" }, h("a", { href: "/guide.pdf", target: "_blank", rel: "noopener" }, h("i", {}, "📘"), "Инструкция"), h("a", { href: "/presentation.pdf", target: "_blank", rel: "noopener" }, h("i", {}, "🎞"), "Презентация"), h("a", { href: "/app.apk", download: "checklists.apk" }, h("i", {}, "📱"), "Приложение для Android"), h("a", { href: "/report", target: "_blank", rel: "noopener" }, h("i", {}, "📨"), "Форма для заявки")), h("div", { class: "sidefoot" }, pushButton(), h("button", { class: "sec sm", onclick: logout }, "Выйти"))); } async function route() { clearInterval(timer); if (!token) return loginPage(); if (!me) { try { me = await api("GET", "/me"); } catch (e) { return loginPage(); } } nav(); feedbackFooter(); const hash = (location.hash || "#/").split("?")[0]; const [, page, id] = hash.split("/"); menu(false); try { if (!location.hash) { location.replace("#/work"); return; } // стартовый экран — общая вкладка «Задачи» if (!page) await ordersPage(); else if (page === "work") await workPage(); else if (page === "order") await orderPage(+id); else if (page === "reports" && isEng()) await reportsPage(id); else if (page === "reliability" && me.reliability) await reliabilityPage(id); else if (page === "demo" && me.is_admin) await demoPage(); else if (page === "admin") await adminPage(); else if (page === "server") await serverPage(); else if (page === "feedback") await feedbackPage(id); else if (page === "backups") await backupsPage(); else if (page === "setup") await setupPage(id); else if (page === "new-order") await newOrderPage(); else if (page === "checklists") id === undefined ? await checklistsPage() : await checklistEditor(id); else if (page === "schedules" && isMgr()) await schedulesPage(); else if (page === "equipment" && id !== undefined) await equipmentPage(+id); else if (page === "tasks") await tasksPage(); else if (page === "task") await taskPage(+id); else if (page === "new-task") await taskForm(null); else if (page === "inventory") await inventoryPage(id); else if (page === "my-tools") await myToolsPage(); else if (page === "requests") await requestsPage(); else if (page === "incidents") await incidentsPage(id); else if (page === "faults") await incidentsPage(id, "fault"); else if (page === "map") await mapPage(id); else if (page === "people") await peoplePage(); else if (page === "user") await userCardPage(+id); else if (CRUD[page] && (ADMIN_PAGES.includes(page) ? isMgr() : (page === "equipment" || page === "cranes" || isEng()))) await crudPage(page); else $app.replaceChildren(h("p", {}, "Страница не найдена")); } catch (e) { $app.replaceChildren(h("div", { class: "card" }, "Ошибка: " + e.message)); } } window.addEventListener("hashchange", route); function loginPage() { $top.replaceChildren(); $banner.replaceChildren(); document.getElementById("appfoot")?.replaceChildren(); const u = h("input", { autofocus: true, autocomplete: "username" }), p = h("input", { type: "password", autocomplete: "current-password" }); const go = guard(async () => { const r = await api("POST", "/auth/login", { username: u.value, password: p.value, device: "web" }); token = r.access_token; localStorage.setItem("token", token); localStorage.setItem("refresh", r.refresh_token); me = null; route(); }); p.addEventListener("keydown", e => e.key === "Enter" && go()); const note = h("p", { class: "small", style: "display:none;color:var(--warn)" }, "🎭 Доступны демонстрационные учётные записи (пароль — у администратора). Рабочие учётные записи входят как обычно."); const apkSize = h("span", {}); const apk = h("div", { style: "display:none" }, h("a", { href: "/app.apk", class: "btn sec", style: "display:block;text-align:center", download: "checklists.apk" }, "📱 Скачать приложение для Android", apkSize), h("p", { class: "small muted", style: "margin:6px 0 0" }, "Откройте скачанный файл на телефоне. Android попросит разрешить установку из этого источника.")); const docs = h("p", { class: "small", style: "margin:12px 0 0;text-align:center" }, h("a", { href: "/guide.pdf", target: "_blank", rel: "noopener" }, "📘 Инструкция"), " · ", h("a", { href: "/presentation.pdf", target: "_blank", rel: "noopener" }, "🎞 Презентация"), " ", h("a", { href: "/presentation.pptx", class: "muted" }, "(PPTX)")); fetch("/app.apk", { method: "HEAD" }).then(r => { if (!r.ok) return; const n = +r.headers.get("content-length"); if (n) apkSize.textContent = ` (${(n / 1048576).toFixed(1)} МБ)`; apk.style.display = ""; }).catch(() => {}); fetch(API + "/demo/status").then(r => r.json()).then(d => { if (d.active) note.style.display = ""; }).catch(() => {}); $app.replaceChildren(h("div", { class: "card login" }, h("h1", {}, "Вход"), note, h("label", {}, "Логин"), u, h("div", { style: "height:10px" }), h("label", {}, "Пароль"), p, h("div", { style: "height:14px" }), h("button", { onclick: go }, "Войти"), h("div", { style: "margin-top:14px" }, apk), docs)); } // ---------- список нарядов ---------- const filt = { q: "", status: "", mine: false, overdue: false, to_review: false }; async function ordersPage() { const box = h("div"), stats = h("div", { class: "stats" }); const sel = h("select", { onchange: e => { filt.status = e.target.value; load(); } }, h("option", { value: "" }, "Все статусы"), ...Object.entries(STATUS).map(([k, v]) => h("option", { value: k, selected: filt.status === k }, v))); const chk = (key, label) => h("label", { style: "display:flex;gap:5px;align-items:center;color:var(--text);margin:0" }, h("input", { type: "checkbox", checked: filt[key], onchange: e => { filt[key] = e.target.checked; load(); } }), label); let tm; const search = h("input", { type: "search", placeholder: "🔍 Поиск: название, оборудование, № наряда", value: filt.q, style: "flex:1;min-width:220px", oninput: e => { filt.q = e.target.value; clearTimeout(tm); tm = setTimeout(guard(load), 300); }, onkeydown: e => { if (e.key === "Escape") { search.value = filt.q = ""; load(); } } }); $app.replaceChildren(h("h1", {}, "Наряды"), stats, h("div", { class: "row", style: "margin-bottom:12px" }, search, sel, chk("mine", "Мои"), chk("overdue", "Просроченные"), isEng() ? chk("to_review", "Ждут моей проверки") : null), box); let seq = 0; async function load() { const my = ++seq, q = new URLSearchParams(); if (filt.q.trim()) q.set("q", filt.q.trim()); if (filt.status) q.set("status", filt.status); for (const k of ["mine", "overdue", "to_review"]) if (filt[k]) q.set(k, "true"); const [orders, st] = await Promise.all([api("GET", "/orders?" + q), api("GET", "/orders/stats")]); if (my !== seq) return; // пришёл устаревший ответ на более раннюю строку поиска const stat = (n, label, fn) => h("div", { class: "stat", onclick: () => { fn(); location.reload(); } }, h("b", {}, n), h("span", {}, label)); stats.replaceChildren( ...["new", "in_progress", "review", "completed"].map(s => stat(st.by_status[s] || 0, STATUS[s], () => { filt.status = s; filt.overdue = false; })), stat(st.overdue, "Просрочено", () => { filt.status = ""; filt.overdue = true; })); box.replaceChildren(orders.length ? h("div", { class: "card tablewrap" }, h("table", {}, h("thead", {}, h("tr", {}, ...["#", "Наряд", "Оборудование / место", "Исполнитель", "Срок", "Ход", "Статус"].map(t => h("th", {}, t)))), h("tbody", {}, ...orders.map(o => h("tr", { class: "click", onclick: () => location.hash = "#/order/" + o.id }, h("td", { class: "muted" }, o.id), h("td", {}, o.title, o.comments_count ? h("span", { class: "muted small" }, " 💬" + o.comments_count) : null, o.can_execute ? h("div", { class: "small", style: "color:var(--accent)" }, "▶ можно выполнять") : null, o.can_review ? h("div", { class: "small", style: "color:#9333ea" }, "✔ ждёт вашей проверки") : null), h("td", {}, o.equipment_name || "—", h("div", { class: "small muted" }, o.location_name || "")), h("td", {}, o.executor_name || o.assignee_name || "—", o.executor_name && o.assignee_name && o.executor_name !== o.assignee_name ? h("div", { class: "small muted" }, "назн.: " + o.assignee_name) : null), h("td", {}, fmt(o.due_at)), h("td", {}, o.total_count ? [h("div", { class: "bar" }, h("i", { style: `width:${100 * o.done_count / o.total_count}%` })), h("span", { class: "small muted" }, `${o.done_count}/${o.total_count}`)] : "—"), h("td", {}, badge(o))))))) : h("div", { class: "card muted" }, filt.q.trim() ? `По запросу «${filt.q.trim()}» ничего не найдено` : "Нарядов нет")); } await load(); timer = setInterval(guard(load), 30000); } // ---------- наряд ---------- async function orderPage(id) { const o = await api("GET", "/orders/" + id); const act = fn => guard(async () => { await fn(); orderPage(id); }); const meta = (k, v) => h("div", {}, h("label", {}, k), v || "—"); const head = h("div", { class: "card" }, h("div", { class: "row" }, h("h1", { style: "margin:0;flex:1" }, `Наряд №${o.id}: ${o.title}`), badge(o)), h("div", { class: "grid", style: "margin-top:12px" }, meta("Оборудование", o.equipment_name), meta("Место", o.location_name), meta("Назначен", o.assignee_name), meta("Выполняет", o.executor_name), meta("Проверяющий", o.reviewer_name), meta("Срок", fmt(o.due_at)), meta("Создан", fmt(o.created_at)), meta("Начат", fmt(o.started_at)), meta("Сдан", fmt(o.submitted_at)), meta("Завершён", fmt(o.completed_at))), o.review_comment ? h("div", { class: "warnline", style: "margin-top:10px" }, "Комментарий проверяющего: " + o.review_comment) : null); const items = h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, `Чек-лист (${o.done_count}/${o.total_count})`), ...o.items.map((it, n) => { const edit = o.can_execute, save = v => act(() => api("PUT", `/orders/${id}/items/${it.id}`, { value: v }))(); let ctl; if (!edit) ctl = h("div", {}, it.value ? (it.answer_type === "status" ? (it.value === "done" ? "✅ Выполнено" : "⏳ В работе") : it.value + (it.unit ? " " + it.unit : "")) : h("span", { class: "muted" }, "нет ответа")); else if (it.answer_type === "status") ctl = h("select", { onchange: e => save(e.target.value) }, ...[["", "— не начато —"], ["in_progress", "В работе"], ["done", "Выполнено"]].map(([v, t]) => h("option", { value: v, selected: (it.value || "") === v }, t))); else if (it.answer_type === "choice") ctl = h("select", { onchange: e => save(e.target.value) }, h("option", { value: "" }, "— выберите —"), ...it.options.map(v => h("option", { value: v, selected: it.value === v }, v))); else if (it.answer_type === "number") ctl = h("div", { class: "row" }, h("input", { type: "text", inputmode: "decimal", style: "max-width:160px", value: it.value ?? "", onchange: e => save(e.target.value) }), it.unit); else ctl = h("textarea", { onchange: e => save(e.target.value) }, it.value ?? ""); const range = it.answer_type === "number" && (it.min_value != null || it.max_value != null) ? `норма: ${it.min_value ?? "…"} – ${it.max_value ?? "…"} ${it.unit}` : ""; return h("div", { class: "item" + (it.out_of_range ? " oor" : "") }, h("div", { class: "q" }, `${n + 1}. ${it.text}`, it.required ? "" : h("span", { class: "muted small" }, " (необязательно)")), ctl, range ? h("div", { class: "small muted" }, range) : null, it.photo !== "none" ? photoBlock(o, it, act) : null, it.out_of_range ? h("div", { class: "warnline" }, "⚠ Значение вне допустимого диапазона") : null); })); const btns = h("div", { class: "row" }); if (o.can_execute) { if (o.status === "new") btns.append(h("button", { class: "sec", onclick: act(() => api("POST", `/orders/${id}/start`)) }, "Взять в работу")); btns.append(h("button", { onclick: act(async () => { await api("POST", `/orders/${id}/submit`); toast("Наряд сдан"); }) }, "Сдать наряд")); } let reviewBox = null; if (o.can_review) { const c = h("textarea", { placeholder: "Комментарий (обязателен при возврате)" }); reviewBox = h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Проверка"), c, h("div", { class: "row", style: "margin-top:10px" }, h("button", { onclick: act(() => api("POST", `/orders/${id}/review`, { approve: true, comment: c.value })) }, "Принять"), h("button", { class: "bad", onclick: act(() => api("POST", `/orders/${id}/review`, { approve: false, comment: c.value })) }, "Вернуть на доработку"))); } if (isEng() && !["completed", "cancelled"].includes(o.status)) btns.append(h("button", { class: "sec", onclick: act(async () => { if (confirm("Отменить наряд?")) await api("POST", `/orders/${id}/cancel`); }) }, "Отменить наряд")); const cbox = h("div", { class: "card" }); $app.replaceChildren(h("a", { href: "#/" }, "← К списку"), head, items, ...(reviewBox ? [reviewBox] : []), btns, cbox); commentsBlock(id, cbox); } // ---------- форма (универсальная) ---------- /* поле: {k, label, type: text|textarea|number|bool|date|time|datetime|password|enum|ref|weekdays, options, ref, show(v), req} */ function formFields(fields, data, onchange, opts = {}) { const vals = { ...data }, wrap = h("div", { class: "grid form" + (opts.one ? " one" : "") }), refs = {}; const lab = f => h("div", { class: "lh" }, h("label", {}, f.label + (f.req ? " *" : "")), f.hint ? h("div", { class: "small muted hint" }, f.hint) : null); async function build() { for (const f of fields) if (f.type === "ref" && !refs[f.ref]) refs[f.ref] = await ref(f.ref); wrap.replaceChildren(...fields.filter(f => !f.show || f.show(vals)).map(f => { const set = v => { vals[f.k] = v; if (f.reload) build(); }; let ctl, v = vals[f.k]; if (f.type === "bool") return h("div", {}, lab(f), h("input", { type: "checkbox", checked: !!v, onchange: e => set(e.target.checked) })); if (f.type === "textarea") ctl = h("textarea", { onchange: e => set(e.target.value) }, v ?? ""); else if (f.type === "enum") ctl = h("select", { onchange: e => set(e.target.value) }, ...f.options.map(([k, t]) => h("option", { value: k, selected: v === k }, t))); else if (f.type === "ref") { ctl = h("select", { onchange: e => set(e.target.value ? +e.target.value : null) }, h("option", { value: "" }, "—"), ...refs[f.ref].map(r => h("option", { value: r.id, selected: v === r.id }, f.label2 ? f.label2(r) : (r.full_name || r.name || r.username)))); if (f.ref === "users") ctl = searchSelect(ctl); } else if (f.type === "weekdays") ctl = h("div", { class: "days" }, ...DAYS.map((d, i) => h("label", {}, h("input", { type: "checkbox", checked: (v || []).includes(i), onchange: e => set(DAYS.map((_, j) => j).filter(j => j === i ? e.target.checked : (vals[f.k] || []).includes(j))) }), d))); else { const t = { number: "number", date: "date", time: "time", datetime: "datetime-local", password: "password" }[f.type] || "text"; let shown = v ?? ""; if (t === "time" && shown) shown = String(shown).slice(0, 5); if (t === "datetime-local" && shown) shown = String(shown).slice(0, 16); ctl = h("input", { type: t, step: f.type === "number" ? "any" : null, value: shown, autocomplete: "off", onchange: e => set(e.target.value === "" ? (f.type === "text" || f.type === "password" ? "" : null) : (f.type === "number" ? +e.target.value : e.target.value)) }); } return h("div", {}, lab(f), ctl); })); } build(); return { el: wrap, vals }; } // ---------- универсальные справочники ---------- const ADMIN_PAGES = ["users", "positions", "schedules", "departments"]; const WD = { k: "weekdays", label: "Дни недели", type: "weekdays" }; const CRUD = { equipment: { title: "Оборудование", path: "equipment", cols: [["name", "Название"], ["inventory_no", "Инв. №"], ["location_id", "Место", "locations"], ["department_id", "Отдел", "departments"], ["is_crane", "Кран", null, v => v ? "🏗 да" : ""], ["meter_hours", "Наработка, м/ч", null, v => v ? hrs(v) : "—"], ["is_active", "Активно"]], fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "inventory_no", label: "Инвентарный номер", type: "text" }, { k: "location_id", label: "Место", type: "ref", ref: "locations" }, { k: "department_id", label: "Закреплено за отделом", hint: "Видят сотрудники этого отдела и вложенных; сотрудники других отделов — нет. Пусто — видят все", type: "ref", ref: "departments" }, { k: "is_crane", label: "Кран (грузоподъёмное оборудование)", hint: "При включённой карте кранов показывается на ней, а не на карте завода", type: "bool" }, { k: "notes", label: "Примечание", type: "textarea" }, { k: "is_active", label: "Активно", type: "bool" }], init: { is_active: true }, filter: r => !(me.crane_map && r.is_crane), // при отдельной службе кранов краны живут в своём разделе canEdit: () => isEng(), icon: true, actions: r => h("a", { class: "btn sec sm", style: "background:transparent;color:var(--text);border:1px solid var(--line)", href: "#/equipment/" + r.id }, "Карточка") }, locations: { title: "Места", path: "locations", cols: [["name", "Название"], ["parent_id", "Входит в", "locations"]], fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "parent_id", label: "Входит в", type: "ref", ref: "locations" }], init: {} }, positions: { title: "Должности", path: "positions", cols: [["name", "Название"], ["kind", "Тип", null, v => v === "engineer" ? "Инженер" : "Техник"], ["schedule_id", "График", "schedules"]], fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["technician", "Техник (выполняет наряды)"], ["engineer", "Инженер (планирует, проверяет)"]] }, { k: "schedule_id", label: "График работы", hint: "Пусто — всегда на смене", type: "ref", ref: "schedules" }], init: { kind: "technician" } }, schedules: { title: "Графики работы", path: "schedules", cols: [["name", "Название"], ["kind", "Тип", null, (v, r) => v === "cycle" ? `${r.work_days}/${r.off_days}` : r.weekdays.map(i => DAYS[i]).join(" ")], ["start_time", "Смена", null, (v, r) => `${v.slice(0, 5)}–${r.end_time.slice(0, 5)}`]], fields: [{ k: "name", label: "Название", type: "text", req: 1 }, { k: "kind", label: "Тип", type: "enum", options: [["weekly", "По дням недели"], ["cycle", "Цикл (N через M)"]], reload: 1 }, { ...WD, show: v => v.kind === "weekly" }, { k: "work_days", label: "Рабочих дней подряд", type: "number", show: v => v.kind === "cycle" }, { k: "off_days", label: "Выходных подряд", type: "number", show: v => v.kind === "cycle" }, { k: "cycle_start", label: "Первый рабочий день цикла", type: "date", show: v => v.kind === "cycle" }, { k: "start_time", label: "Начало смены", type: "time" }, { k: "end_time", label: "Конец смены (меньше начала — ночная)", type: "time" }], init: { kind: "weekly", weekdays: [0, 1, 2, 3, 4], work_days: 2, off_days: 2, start_time: "08:00", end_time: "20:00" } }, departments: { title: "Отделы", path: "departments", cols: [["name", "Отдел"], ["parent_id", "Входит в", "departments"], ["head_user_id", "Руководитель", "users"], ["members", "Сотрудников"]], fields: [{ k: "name", label: "Название отдела", type: "text", req: 1 }, { k: "parent_id", label: "Входит в отдел", hint: "Для вложенных служб и участков; пусто — верхний уровень", type: "ref", ref: "departments" }, { k: "head_user_id", label: "Руководитель", type: "ref", ref: "users" }, { k: "notes", label: "Примечание", type: "textarea" }], init: {}, actions: r => h("a", { class: "btn sec sm", href: "#/people?dep=" + r.id }, "Сотрудники") }, users: { title: "Пользователи", path: "users", cols: [["username", "Логин"], ["full_name", "ФИО"], ["position_id", "Должность", "positions"], ["department_id", "Отдел", "departments"], ["is_admin", "Роль", null, (v, r) => [v ? "Админ" : r.is_moderator ? "Модератор" : "", r.is_storekeeper ? "Склад" : ""].filter(Boolean).join(", ") || "—"], ["is_active", "Активен"], ["on_shift", "На смене"]], fields: [{ k: "username", label: "Логин", type: "text", req: 1 }, { k: "full_name", label: "ФИО", type: "text" }, { k: "password", label: "Пароль", hint: "При правке оставьте пустым, чтобы не менять", type: "password" }, { k: "position_id", label: "Должность", type: "ref", ref: "positions" }, { k: "department_id", label: "Отдел", type: "ref", ref: "departments" }, { k: "is_moderator", label: "Модератор", hint: "Управляет пользователями, должностями, чек-листами; наряды сам не выполняет", type: "bool", show: () => me.is_admin }, { k: "is_admin", label: "Администратор", hint: "Полный доступ к администрированию", type: "bool", show: () => me.is_admin }, { k: "portal_admin", label: "Администратор портала", hint: "Настройки сервера и ответы обратной связи. Выдаёт только администратор по умолчанию (admin); у самого admin эти права всегда", type: "bool", show: () => me.is_root }, { k: "is_storekeeper", label: "Администратор склада", hint: "Каталог инструмента, склады, выдача сотрудникам, заявки на инструмент", type: "bool" }, { k: "phone", label: "Телефон", type: "text" }, { k: "skills", label: "Что умеет", type: "textarea" }, { k: "is_active", label: "Активен", type: "bool" }], init: { is_active: true }, dropEmpty: ["password"] }, templates: { title: "Расписание нарядов (автозапуск)", path: "order-templates", cols: [["title", "Наряд"], ["recurrence", "Периодичность", null, (v, r) => ({ daily: "Каждый день", weekly: "Еженедельно: " + (r.weekdays || []).map(i => DAYS[i]).join(" "), monthly: `Ежемесячно, ${r.month_day}-го`, interval: `Каждые ${r.interval_days} дн.` })[v] + " в " + r.run_time.slice(0, 5)], ["next_run", "Следующий запуск", null, v => fmt(v)], ["is_active", "Активно"]], fields: [{ k: "title", label: "Название наряда", type: "text", req: 1 }, { k: "checklist_id", label: "Чек-лист", type: "ref", ref: "checklists", req: 1 }, { k: "equipment_id", label: "Оборудование", type: "ref", ref: "equipment" }, { k: "location_id", label: "Место", type: "ref", ref: "locations" }, { k: "assignee_position_id", label: "Исполнитель: должность (кто на смене)", type: "ref", ref: "positions" }, { k: "assignee_user_id", label: "Исполнитель: конкретный человек", type: "ref", ref: "users" }, { k: "reviewer_position_id", label: "Проверяющий: должность", type: "ref", ref: "positions" }, { k: "reviewer_user_id", label: "Проверяющий: конкретный человек", type: "ref", ref: "users" }, { k: "due_hours", label: "Срок выполнения, часов", type: "number" }, { k: "recurrence", label: "Периодичность", type: "enum", reload: 1, options: [["daily", "Каждый день"], ["weekly", "По дням недели"], ["monthly", "Раз в месяц"], ["interval", "Каждые N дней"]] }, { ...WD, show: v => v.recurrence === "weekly" }, { k: "month_day", label: "День месяца", type: "number", show: v => v.recurrence === "monthly" }, { k: "interval_days", label: "Раз в N дней", type: "number", show: v => v.recurrence === "interval" }, { k: "run_time", label: "Время создания наряда", type: "time" }, { k: "start_date", label: "Начиная с даты", type: "date" }, { k: "is_active", label: "Активно", type: "bool" }], init: { recurrence: "daily", due_hours: 24, run_time: "08:00", month_day: 1, interval_days: 7, weekdays: [], is_active: true }, actions: (r, reload) => h("button", { class: "sec sm", onclick: guard(async () => { const x = await api("POST", `/order-templates/${r.id}/run-now`); toast("Создан наряд №" + x.order_id); reload(); }) }, "Создать сейчас") }, }; CRUD["order-templates"] = CRUD.templates; CRUD.cranes = { ...CRUD.equipment, title: "Краны", init: { is_active: true, is_crane: true }, filter: r => r.is_crane, cols: CRUD.equipment.cols.filter(c => c[0] !== "is_crane"), fields: CRUD.equipment.fields.map(f => f.k === "is_crane" ? { ...f, hint: "Снимите, чтобы перенести машину в обычное оборудование" } : f) }; async function crudPage(page) { const C = CRUD[page], box = h("div"), formBox = h("div"), can = C.canEdit ? C.canEdit() : true; $app.replaceChildren(h("div", { class: "row" }, h("h1", { style: "flex:1" }, C.title), can ? h("button", { onclick: () => edit(null) }, "+ Добавить") : null), formBox, box); async function load() { const rows = (await api("GET", "/" + C.path)).filter(C.filter || (() => true)); const lookups = {}; for (const c of C.cols) if (c[2]) lookups[c[2]] = await ref(c[2], true); const cell = (r, [k, , rf, fn]) => { const v = r[k]; if (rf) { const x = lookups[rf].find(q => q.id === v); return x ? (x.full_name || x.name) : "—"; } if (fn) return fn(v, r); if (typeof v === "boolean") return v ? "✔" : "—"; return v ?? "—"; }; box.replaceChildren(rows.length ? h("div", { class: "card tablewrap" }, h("table", {}, h("thead", {}, h("tr", {}, ...C.cols.map(c => h("th", {}, c[1])), h("th"))), h("tbody", {}, ...rows.map(r => h("tr", {}, ...C.cols.map((c, i) => h("td", {}, i === 0 && C.icon ? h("span", { class: "row", style: "flex-wrap:nowrap" }, eqIcon(r, "eqicon sm"), cell(r, c)) : cell(r, c))), h("td", { class: "row end" }, C.actions ? C.actions(r, load) : null, can ? h("button", { class: "sec sm", onclick: () => edit(r) }, "Изменить") : null, can ? h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить?")) { await api("DELETE", `/${C.path}/${r.id}`); load(); } }) }, "✕") : null)))))) : h("div", { class: "card muted" }, "Пока пусто")); } async function edit(row) { const f = formFields(C.fields, row ? { ...row, password: "" } : structuredClone(C.init)); formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, row ? "Изменение" : "Новая запись"), f.el, h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => { const body = {}; for (const fl of C.fields) if (fl.k in f.vals) body[fl.k] = f.vals[fl.k]; for (const k of C.dropEmpty || []) if (!body[k]) delete body[k]; await api(row ? "PATCH" : "POST", `/${C.path}` + (row ? "/" + row.id : ""), body); formBox.replaceChildren(); for (const k in cache) delete cache[k]; load(); toast("Сохранено"); }) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена")))); formBox.scrollIntoView({ behavior: "smooth" }); } await load(); } // ---------- ручной наряд ---------- async function newOrderPage() { const f = formFields([ { k: "title", label: "Название наряда", type: "text", req: 1 }, { k: "checklist_id", label: "Чек-лист", type: "ref", ref: "checklists", req: 1 }, { k: "equipment_id", label: "Оборудование", type: "ref", ref: "equipment" }, { k: "location_id", label: "Место", type: "ref", ref: "locations" }, { k: "assignee_position_id", label: "Исполнитель: должность (кто на смене)", type: "ref", ref: "positions" }, { k: "assignee_user_id", label: "Исполнитель: конкретный человек", type: "ref", ref: "users" }, { k: "reviewer_position_id", label: "Проверяющий: должность", type: "ref", ref: "positions" }, { k: "reviewer_user_id", label: "Проверяющий: конкретный человек", type: "ref", ref: "users" }, { k: "due_at", label: "Срок", type: "datetime" }], {}); $app.replaceChildren(h("h1", {}, "Новый наряд"), h("div", { class: "card" }, f.el, h("p", { class: "small muted" }, "Если указан конкретный человек — наряд видит только он; если только должность — любой, кто сейчас на смене."), h("p", { class: "small muted" }, "Для чек-листа «Регламентное обслуживание» обязательно выберите оборудование: в наряд попадут его обслуживаемые узлы."), h("button", { onclick: guard(async () => { const o = await api("POST", "/orders", f.vals); location.hash = "#/order/" + o.id; }) }, "Создать наряд"))); } // ---------- чек-листы ---------- async function checklistsPage() { const list = await api("GET", "/checklists"); $app.replaceChildren(h("div", { class: "row" }, h("h1", { style: "flex:1" }, "Чек-листы"), h("a", { class: "btn", href: "#/checklists/new" }, "+ Новый чек-лист")), list.length ? h("div", { class: "card tablewrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "Название"), h("th", {}, "Пунктов"), h("th"))), h("tbody", {}, ...list.map(c => h("tr", {}, h("td", {}, c.name, c.kind === "regular" ? h("span", { class: "badge b-review", style: "margin-left:6px" }, "регламентное обслуживание") : null, h("div", { class: "small muted" }, c.description)), h("td", {}, c.items.length), h("td", { class: "row end" }, h("a", { class: "btn sec sm", style: "background:transparent;color:var(--text);border:1px solid var(--line)", href: "#/checklists/" + c.id }, "Изменить"), h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить чек-лист?")) { await api("DELETE", "/checklists/" + c.id); checklistsPage(); } }) }, "✕"))))))) : h("div", { class: "card muted" }, "Пока пусто")); } async function checklistEditor(id) { const cl = id === "new" ? { name: "", description: "", kind: "inspection", only_due: false, items: [] } : await api("GET", "/checklists/" + id); const items = cl.items.map(i => ({ ...i, optText: (i.options || []).join("\n") })); const name = h("input", { value: cl.name }), desc = h("textarea", {}, cl.description), box = h("div"), kindBox = h("div"); const kv = { kind: cl.kind || "inspection", only_due: !!cl.only_due }; function drawKind() { kindBox.replaceChildren(h("label", {}, "Тип чек-листа"), h("select", { onchange: e => { kv.kind = e.target.value; drawKind(); } }, h("option", { value: "inspection", selected: kv.kind === "inspection" }, "Обычный (обход, осмотр)"), h("option", { value: "regular", selected: kv.kind === "regular" }, "Регламентное обслуживание")), kv.kind === "regular" ? h("div", { style: "margin-top:8px" }, h("p", { class: "small muted", style: "margin:0 0 6px" }, "В наряд автоматически добавятся: показание наработки машины, ваши задачи ниже и по одному пункту «обслужено?» на каждый обслуживаемый узел выбранной машины (узлы настраиваются в карточке машины). Наряд создаётся только с указанием оборудования. После завершения наряда узлы сбрасываются, наработка обновляется."), h("label", { style: "display:flex;gap:6px;align-items:center;color:var(--text);font-size:14px;margin:0" }, h("input", { type: "checkbox", checked: kv.only_due, onchange: e => kv.only_due = e.target.checked }), "Включать только узлы со статусом «Требуется» или «Скоро»")) : null); } drawKind(); const TYPES = [["status", "Статус: в работе/выполнено"], ["choice", "Список"], ["number", "Число"], ["text", "Текст"]]; function draw() { box.replaceChildren(...items.map((it, n) => h("div", { class: "card" }, h("div", { class: "row", style: "margin-bottom:8px" }, h("b", {}, "#" + (n + 1)), h("div", { style: "flex:1;min-width:200px" }, h("input", { placeholder: "Текст задачи", value: it.text, onchange: e => it.text = e.target.value })), h("select", { style: "width:auto", onchange: e => { it.answer_type = e.target.value; draw(); } }, ...TYPES.map(([k, t]) => h("option", { value: k, selected: it.answer_type === k }, t)))), it.answer_type === "choice" ? h("div", {}, h("label", {}, "Варианты ответа (по одному в строке)"), h("textarea", { onchange: e => it.optText = e.target.value }, it.optText)) : null, it.answer_type === "number" ? h("div", { class: "row" }, ...[["unit", "Единица", "text"], ["min_value", "Минимум", "number"], ["max_value", "Максимум", "number"]].map(([k, l, t]) => h("div", { style: "width:130px" }, h("label", {}, l), h("input", { type: t, step: "any", value: it[k] ?? "", onchange: e => it[k] = e.target.value === "" ? (k === "unit" ? "" : null) : (t === "number" ? +e.target.value : e.target.value) })))) : null, h("div", { class: "row", style: "margin-top:8px" }, h("label", { style: "display:flex;gap:5px;align-items:center;margin:0;color:var(--text)" }, h("input", { type: "checkbox", checked: it.required, onchange: e => it.required = e.target.checked }), "Обязательный"), h("select", { style: "width:auto", onchange: e => it.photo = e.target.value }, ...[["none", "📷 без фото"], ["optional", "📷 фото по желанию"], ["required", "📷 фото обязательно"]].map(([k, t]) => h("option", { value: k, selected: (it.photo || "none") === k }, t))), h("span", { style: "flex:1" }), h("button", { class: "sec sm", disabled: n === 0, onclick: () => { [items[n - 1], items[n]] = [items[n], items[n - 1]]; draw(); } }, "↑"), h("button", { class: "sec sm", disabled: n === items.length - 1, onclick: () => { [items[n + 1], items[n]] = [items[n], items[n + 1]]; draw(); } }, "↓"), h("button", { class: "sec sm", onclick: () => { items.splice(n, 1); draw(); } }, "Удалить"))))); } draw(); const add = () => { items.push({ text: "", answer_type: "status", required: true, photo: "none", optText: "" }); draw(); box.lastChild?.querySelector("input")?.focus(); }; $app.replaceChildren(h("a", { href: "#/checklists" }, "← Чек-листы"), h("h1", {}, id === "new" ? "Новый чек-лист" : "Чек-лист"), h("div", { class: "card" }, h("label", {}, "Название *"), name, h("div", { style: "height:8px" }), h("label", {}, "Описание"), desc, h("div", { style: "height:8px" }), kindBox), h("h2", {}, "Задачи"), box, h("div", { class: "row" }, h("button", { class: "sec", onclick: add }, "+ Добавить задачу"), h("button", { onclick: guard(async () => { const body = { name: name.value, description: desc.value, kind: kv.kind, only_due: kv.only_due, items: items.map(i => ({ text: i.text, answer_type: i.answer_type, required: i.required, photo: i.photo || "none", options: i.answer_type === "choice" ? i.optText.split("\n").map(s => s.trim()).filter(Boolean) : null, unit: i.answer_type === "number" ? (i.unit || "") : "", min_value: i.answer_type === "number" ? i.min_value ?? null : null, max_value: i.answer_type === "number" ? i.max_value ?? null : null })) }; if (id === "new") await api("POST", "/checklists", body); else await api("PUT", "/checklists/" + id, body); delete cache.checklists; toast("Сохранено"); location.hash = "#/checklists"; }) }, "Сохранить чек-лист"))); } // ---------- фото ---------- const blobCache = {}; async function photoUrl(id) { if (!blobCache[id]) { const r = await authFetch(API + "/photos/" + id); if (!r.ok) throw new Error("фото недоступно"); blobCache[id] = URL.createObjectURL(await r.blob()); } return blobCache[id]; } async function downscale(file, max = 1600) { try { const bmp = await createImageBitmap(file); const k = Math.min(1, max / Math.max(bmp.width, bmp.height)); const cv = document.createElement("canvas"); cv.width = Math.round(bmp.width * k); cv.height = Math.round(bmp.height * k); cv.getContext("2d").drawImage(bmp, 0, 0, cv.width, cv.height); return (await new Promise(res => cv.toBlob(res, "image/jpeg", 0.85))) || file; } catch (e) { return file; } } function photoBlock(o, it, act) { const box = h("div", { class: "photos" }); for (const p of it.photos) { const img = h("img", { class: "thumb", alt: "фото" }); photoUrl(p.id).then(u => { img.src = u; img.onclick = () => window.open(u, "_blank"); }).catch(() => img.alt = "⚠"); const canDel = isMgr() || (o.can_execute && p.user_id === me.id); box.append(h("div", { class: "ph" }, img, canDel ? h("button", { class: "x", title: "Удалить", onclick: act(async () => { if (confirm("Удалить фото?")) await api("DELETE", "/photos/" + p.id); }) }, "✕") : null)); } if (o.can_execute) { const inp = h("input", { type: "file", accept: "image/*", style: "display:none", onchange: guard(async e => { const f = e.target.files[0]; if (!f) return; toast("Загрузка…"); const blob = await downscale(f); const r = await authFetch(`${API}/orders/${o.id}/items/${it.id}/photos`, { method: "POST", headers: { "Content-Type": blob.type || "image/jpeg", "Idempotency-Key": crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random() }, body: blob }); if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); } orderPage(o.id); }) }); box.append(inp, h("button", { class: "sec sm", onclick: () => inp.click() }, "📷 Добавить фото")); } return h("div", { style: "margin-top:8px" }, box, it.photo === "required" && !it.photos.length ? h("div", { class: "warnline" }, "Требуется фото") : null); } // ---------- комментарии ---------- async function commentsBlock(id, box) { const list = await api("GET", `/orders/${id}/comments`); const ta = h("textarea", { placeholder: "Написать комментарий…" }); const send = guard(async () => { if (!ta.value.trim()) return; await api("POST", `/orders/${id}/comments`, { text: ta.value }); commentsBlock(id, box); }); box.replaceChildren(h("h2", { style: "margin-top:0" }, `Комментарии (${list.length})`), ...list.map(c => h("div", { class: "cm" }, h("div", { class: "small muted" }, `${c.user_name || "—"} · ${fmt(c.created_at)}`, isMgr() || c.user_id === me.id ? h("a", { href: "javascript:void 0", style: "margin-left:8px", onclick: guard(async () => { await api("DELETE", "/comments/" + c.id); commentsBlock(id, box); }) }, "удалить") : null), h("div", { style: "white-space:pre-wrap" }, c.text))), ta, h("div", { style: "margin-top:8px" }, h("button", { onclick: send }, "Отправить"))); } // ---------- администрирование ---------- async function adminPage() { if (!isMgr()) throw new Error("Нет доступа"); const [users, pos, sch] = await Promise.all([api("GET", "/users"), api("GET", "/positions"), api("GET", "/schedules")]); const tile = (href, title, text) => h("a", { href, class: "tile" }, h("b", {}, title), h("span", { class: "muted small" }, text)); const eqCount = me.is_admin && !me.demo_mode ? (await api("GET", "/equipment").catch(() => [])).length : 1; $app.replaceChildren(h("h1", {}, me.is_admin ? "Панель администратора" : "Панель модератора"), ...(eqCount === 0 || (me.is_admin && users.length <= 1) ? [h("div", { class: "card banner-setup" }, h("b", { style: "font-size:17px" }, "🧭 Похоже, система ещё не настроена."), h("span", { class: "muted" }, "Мастер по шагам проведёт через графики, должности, отделы, места, оборудование, сотрудников, карту и склады."), h("a", { class: "btn", href: "#/setup" }, "Запустить мастер"))] : []), h("h2", {}, "Люди и графики"), h("div", { class: "tiles" }, tile("#/users", "Пользователи", `${users.length} · на смене сейчас: ${users.filter(u => u.on_shift && u.position).length}`), tile("#/positions", "Должности", `${pos.length}: ${pos.filter(p => p.kind === "engineer").length} инж., ${pos.filter(p => p.kind === "technician").length} техн.`), tile("#/schedules", "Графики работы", String(sch.length)), tile("#/departments", "Отделы", "службы и участки, состав сотрудников")), h("h2", {}, "Рабочие данные"), h("div", { class: "tiles" }, tile("#/checklists", "Чек-листы", "шаблоны проверок"), tile("#/equipment", "Оборудование", "справочник"), tile("#/locations", "Места", "расположение"), tile("#/templates", "Расписание нарядов", "автозапуск")), ...(me.is_admin ? [h("h2", {}, "Сервер (только администратор)"), h("div", { class: "tiles" }, tile("#/setup", "🧭 Мастер первичной настройки", "пошагово: графики, люди, оборудование, карта, склады"), ...(me.portal_admin ? [tile("#/server", "Настройки сервера", "каталог фото, лимиты, уведомления, внешние обращения, AI-помощник"), tile("#/feedback", "Обратная связь", "идеи и замечания сотрудников по порталу")] : []), tile("#/backups", "Резервные копии", "база и конфигурация, автокопии, восстановление"), tile("#/demo", "Демонстрационный режим", me.demo_mode ? "ВКЛЮЧЁН — управление" : "тестовые данные для презентации"))] : [h("p", { class: "small muted" }, "Параметры сервера доступны только администратору. Модератор сам наряды не выполняет.")])); } async function serverPage() { if (!me.portal_admin) throw new Error("Настройки сервера доступны только администраторам портала"); const keysBox = h("div", { style: "margin-top:14px" }), aiBox = h("div", { class: "card" }); const s = await api("GET", "/settings"); const f = formFields([ { k: "photo_dir", label: "Каталог для фото", hint: "Абсолютный путь на сервере. В Docker — примонтированный том", type: "text" }, { k: "max_photo_mb", label: "Максимальный размер фото, МБ", type: "number" }, { k: "vapid_contact", label: "Контакт для push-уведомлений", hint: "mailto:… или https://…", type: "text" }, { k: "fcm_credentials_path", label: "Android push (FCM): ключ Firebase", hint: "Путь к JSON-ключу сервисного аккаунта на сервере. Пусто — выключено", type: "text" }, { k: "public_incidents", label: "Публичная форма «Сообщить о неисправности» (/report)", hint: "Сторонние люди без входа сообщают об аварии: место, машина, описание, контакты", type: "bool" }, { k: "crane_map", label: "Краны — отдельная служба", hint: "Оборудование с отметкой «Кран» уходит в свой раздел меню «Краны»: список, карта кранов (своя картинка или план завода), отказы и неисправности по кранам", type: "bool" }, { k: "reliability", label: "Анализ надёжности", hint: "Раздел для инженеров и руководителей: наработка на отказ, время восстановления, готовность, причины, прогноз, дрейф параметров, выгрузки CSV", type: "bool" }], { ...s, public_incidents: s.public_incidents !== "0", crane_map: s.crane_map === "1", reliability: s.reliability !== "0" }, null, { one: true }); const mb = n => (n / 1048576).toFixed(1) + " МБ"; $app.replaceChildren(h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Настройки сервера"), h("div", { class: "card" }, f.el, h("div", { style: "margin-top:12px" }, h("button", { onclick: guard(async () => { await api("PUT", "/settings", { photo_dir: f.vals.photo_dir, max_photo_mb: f.vals.max_photo_mb, vapid_contact: f.vals.vapid_contact, fcm_credentials_path: f.vals.fcm_credentials_path ?? "", public_incidents: !!f.vals.public_incidents, crane_map: !!f.vals.crane_map, reliability: !!f.vals.reliability }); me = await api("GET", "/me"); toast("Сохранено"); serverPage(); }) }, "Сохранить")), h("p", { class: "small muted" }, "Смена каталога не переносит уже загруженные фото — скопируйте их вручную в новый каталог.")), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Состояние"), h("div", { class: "grid" }, h("div", {}, h("label", {}, "Фото на сервере"), `${s.photo_stats.files} шт., ${mb(s.photo_stats.bytes)}`), h("div", {}, h("label", {}, "Свободно на диске"), mb(s.photo_stats.disk_free)), h("div", {}, h("label", {}, "Время сервера"), `${fmt(s.now)} (${s.timezone})`), h("div", {}, h("label", {}, "Адрес клиента, каким его видит сервер"), s.client_ip || "—", s.forwarded_for ? h("div", { class: "small muted" }, "X-Forwarded-For: " + s.forwarded_for) : null), h("div", {}, h("label", {}, "Android push (FCM)"), s.fcm_enabled ? "включён" : "выключен"), h("div", {}, h("label", {}, "Браузерные уведомления"), location.protocol === "https:" || location.hostname === "localhost" ? "доступны" : "нужен HTTPS"))), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Сообщения об отказах и неисправностях извне"), h("p", { class: "small" }, "Публичная форма: ", h("a", { href: "/report", target: "_blank" }, location.origin + "/report"), " — ссылку можно разместить на QR-коде у оборудования или на сайте."), keysBox), aiBox); apiKeys(keysBox); assistantSettings(aiBox); } async function apiKeys(box) { const keys = await api("GET", "/api-keys"); const name = h("input", { placeholder: "Название системы, например «Диспетчерская» или «SCADA цех 1»" }); const shown = h("div"); box.replaceChildren(h("h2", {}, "Ключи доступа к API"), h("p", { class: "small muted" }, "Внешняя система заводит отказ или неисправность запросом POST /api/v1/external/incidents с заголовком X-API-Key. Справочник машин — GET /api/v1/external/equipment. Подробнее — docs/API.md."), keys.length ? h("table", {}, h("tbody", {}, ...keys.map(k => h("tr", { style: k.is_active ? "" : "opacity:.5" }, h("td", {}, k.name), h("td", { class: "small muted" }, k.prefix + "…"), h("td", { class: "small" }, "создан " + fmt(k.created_at)), h("td", { class: "small" }, k.last_used_at ? "использован " + fmt(k.last_used_at) : "не использовался"), h("td", { class: "row end" }, k.is_active ? h("button", { class: "sec sm", onclick: guard(async () => { if (confirm(`Отозвать ключ «${k.name}»? Система больше не сможет присылать сообщения.`)) { await api("DELETE", "/api-keys/" + k.id); apiKeys(box); } }) }, "Отозвать") : "отозван"))))) : h("p", { class: "muted small" }, "Ключей нет"), h("div", { class: "row", style: "margin-top:10px" }, h("div", { style: "flex:1;min-width:240px" }, name), h("button", { onclick: guard(async () => { const k = await api("POST", "/api-keys", { name: name.value }); await apiKeys(box); box.append(h("div", { class: "sheet", style: "margin-top:10px;border:1px solid var(--warn)" }, h("b", {}, "Новый ключ «" + k.name + "»: "), h("code", { style: "user-select:all;word-break:break-all" }, k.key), h("div", { class: "small", style: "margin-top:4px" }, "Скопируйте сейчас — повторно ключ не показывается."))); }) }, "Выпустить ключ")), shown); } // ---------- push-уведомления ---------- const b64u = k => Uint8Array.from(atob(k.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(k.length / 4) * 4, "=")), c => c.charCodeAt(0)); const pushOK = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; async function pushSub() { const reg = await navigator.serviceWorker.getRegistration("/"); return reg ? reg.pushManager.getSubscription() : null; } async function pushOn() { if (!window.isSecureContext) throw new Error("Уведомления работают только по HTTPS"); if (await Notification.requestPermission() !== "granted") throw new Error("Разрешение на уведомления не выдано (проверьте настройки сайта в браузере)"); const reg = await navigator.serviceWorker.register("/sw.js"); await navigator.serviceWorker.ready; const { key } = await api("GET", "/push/key"); const sub = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64u(key) }); await api("POST", "/push/subscribe", sub.toJSON()); await api("POST", "/push/test"); } async function pushOff(quiet) { const sub = pushOK() && await pushSub(); if (!sub) return; try { await api("POST", "/push/unsubscribe", sub.toJSON()); } catch (e) { if (!quiet) throw e; } await sub.unsubscribe(); } function pushButton() { if (!pushOK()) return null; const b = h("button", { class: "sec sm", title: "Уведомления о новых нарядах" }, "🔔"); const paint = async () => { const on = !!(await pushSub().catch(() => null)) && Notification.permission === "granted"; b.textContent = on ? "🔔 вкл" : "🔕 выкл"; b.dataset.on = on; }; b.onclick = guard(async () => { if (b.dataset.on === "true") await pushOff(); else await pushOn(); await paint(); }); paint(); // подписка уже есть в этом браузере — привяжем её к текущему пользователю pushSub().then(s => s && Notification.permission === "granted" && api("POST", "/push/subscribe", s.toJSON())).catch(() => {}); return b; } // На телефоне предлагаем мобильную версию (/m); выбор «Остаться» запоминается. (function mobilePrompt() { const mobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && innerWidth <= 900; if (!mobile || localStorage.getItem("stay_desktop") || new URLSearchParams(location.search).has("desktop")) return; const b = h("div", { class: "mprompt" }, h("span", {}, "📱 Для телефона есть удобная мобильная версия — работает и без связи."), h("a", { class: "btn sm", href: "/m" }, "Открыть"), h("button", { class: "sec sm", onclick: () => { localStorage.setItem("stay_desktop", "1"); b.remove(); } }, "Остаться")); document.body.prepend(b); })(); route();