Files
tech-service-portal/app/static/app.js
T

690 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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();