Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,689 @@
|
||||
"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();
|
||||
Reference in New Issue
Block a user