Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit 67e411eea8
246 changed files with 23152 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
"use strict";
// ---------- Администрирование сервера: резервные копии ----------
const mbx = n => n >= 1048576 ? (n / 1048576).toFixed(1) + " МБ" : Math.max(1, Math.round(n / 1024)) + " КБ";
async function backupsPage() {
if (!me.is_admin) throw new Error("Только для администратора");
const j = await api("GET", "/backups");
const reload = () => backupsPage();
const media = h("input", { type: "checkbox" }), note = h("input", { placeholder: "Комментарий, например «перед обновлением»" });
const auto = h("input", { type: "number", min: "0", max: "720", value: j.auto_hours, style: "max-width:120px" });
const keep = h("input", { type: "number", min: "1", max: "365", value: j.keep, style: "max-width:120px" });
const amedia = h("input", { type: "checkbox", checked: j.media });
const upload = h("input", { type: "file", accept: ".zip", style: "display:none", onchange: guard(async e => {
const f = e.target.files[0]; if (!f) return;
toast("Загрузка копии…");
const r = await authFetch(API + "/backups/upload", { method: "POST", headers: { "Content-Type": "application/zip" }, body: f });
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
toast("Копия загружена"); reload();
}) });
async function download(name) {
const r = await authFetch(`${API}/backups/${encodeURIComponent(name)}`);
if (!r.ok) throw new Error("Не удалось скачать копию");
const a = h("a", { href: URL.createObjectURL(await r.blob()), download: name });
document.body.append(a); a.click(); a.remove();
}
fill($app, h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Резервные копии"),
me.demo_mode ? h("div", { class: "card", style: "border-color:var(--warn)" }, "Вы сейчас в демо-режиме: копировать и восстанавливать рабочую базу можно только после выхода из демо.") : null,
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Создать копию"),
h("p", { class: "small muted" }, "В копию входят вся продуктовая база (пользователи, оборудование, наряды, задания, склады, инциденты…) и конфигурация: настройки сервера и несекретные параметры окружения. ",
"Пароли, SECRET_KEY и строка подключения к базе в копию не попадают — только их названия."),
h("div", { class: "row" }, h("div", { style: "flex:1;min-width:260px" }, note),
h("label", { class: "chk" }, media, "включить фото и картинки (файл будет больше)"),
h("button", { onclick: guard(async () => { toast("Создаю копию…"); const b = await api("POST", "/backups", { include_media: media.checked, note: note.value }); toast("Копия создана: " + mbx(b.size)); reload(); }) }, "Создать копию"))),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Автоматические копии"),
h("div", { class: "grid" },
field("Делать копию каждые, часов (0 — выключено)", auto), field("Сколько автокопий хранить", keep),
h("div", {}, h("label", {}, "Состав"), h("label", { class: "chk" }, amedia, "с фото и картинками"))),
h("p", { class: "small muted" }, j.next_auto ? `Следующая автокопия: ${fmt(j.next_auto)}. ` : "Автокопии выключены. ", `Каталог на сервере: ${j.folder}. В Docker это том appdata — копируйте файлы и за пределы сервера.`),
h("button", { class: "sec", onclick: guard(async () => {
await api("PUT", "/settings", { backup_auto_hours: +auto.value, backup_keep: +keep.value, backup_media: amedia.checked }); toast("Сохранено"); reload();
}) }, "Сохранить")),
h("div", { class: "card" }, h("div", { class: "row" }, h("h2", { style: "margin:0;flex:1" }, `Копии (${j.items.length})`), upload,
h("button", { class: "sec sm", onclick: () => upload.click() }, "⬆ Загрузить файл копии")),
j.items.length ? h("div", { class: "tablewrap" }, h("table", { style: "margin-top:8px" }, h("thead", {}, h("tr", {}, ...["Создана", "Комментарий", "Размер", "Записей", "Фото", ""].map(t => h("th", {}, t)))),
h("tbody", {}, ...j.items.map(b => h("tr", {},
h("td", {}, fmt(b.created_at), h("div", { class: "small muted" }, b.name)), h("td", {}, b.note || "—"), h("td", {}, mbx(b.size)),
h("td", {}, b.rows), h("td", {}, b.media ? "✔" : "—"),
h("td", { class: "row end" },
h("button", { class: "sec sm", onclick: guard(() => download(b.name)) }, "⬇ Скачать"),
h("button", { class: "sec sm", onclick: guard(async () => {
if (!confirm(`Восстановить данные из копии от ${fmt(b.created_at)}?\n\nВСЕ текущие данные будут заменены содержимым копии. Перед этим текущее состояние автоматически сохранится отдельной копией.`)) return;
if (prompt("Для подтверждения введите слово ВОССТАНОВИТЬ") !== "ВОССТАНОВИТЬ") return;
const r = await api("POST", `/backups/${encodeURIComponent(b.name)}/restore`, { confirm: true });
alert(`Восстановлено записей: ${r.rows}${r.media_files ? `, файлов: ${r.media_files}` : ""}.\nКопия состояния до восстановления: ${r.safety_copy}.\nВойдите заново.`);
logout();
}) }, "↺ Восстановить"),
h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить файл копии?")) { await api("DELETE", "/backups/" + encodeURIComponent(b.name)); reload(); } }) }, "✕"))))))) :
h("p", { class: "muted" }, "Копий пока нет")));
}
// ---------- AI-помощник: настройки (вызывается со страницы «Настройки сервера») ----------
async function assistantSettings(box) {
const c = await api("GET", "/assistant/config");
const st = { provider: c.provider || "openai" };
const enabled = h("input", { type: "checkbox", checked: c.enabled });
const prov = h("select", { onchange: e => { st.provider = e.target.value; url.placeholder = c.default_urls[st.provider] || ""; hint.replaceChildren(...hints()); } },
...Object.entries(c.providers).map(([k, t]) => h("option", { value: k, selected: k === st.provider }, t)));
const url = h("input", { value: c.base_url, placeholder: c.default_urls[st.provider] || "" });
const model = h("input", { value: c.model, placeholder: "например: yandexgpt-lite, gpt-4o-mini, GigaChat, claude-haiku-…" });
const key = h("input", { type: "password", autocomplete: "new-password", placeholder: c.api_key_set ? `ключ задан (${c.api_key_hint}) — оставьте пустым, чтобы не менять` : "ключ API провайдера" });
const scope = h("textarea", { placeholder: "Необязательно: дополнительные указания помощнику, например «о закупках — к Козлову Д. О., тел. 123»" }, c.scope);
const out = h("div"), hint = h("div", { class: "small muted" });
const hints = () => ({
openai: ["Подходит для OpenAI, YandexGPT (адрес https://llm.api.cloud.yandex.net/v1, модель gpt://<каталог>/yandexgpt-lite/latest, ключ — API-ключ сервисного аккаунта), DeepSeek, OpenRouter и локальных моделей (Ollama: http://localhost:11434/v1, ключ не нужен)."],
anthropic: ["Anthropic: адрес можно не указывать, модель — например claude-haiku-4-5, ключ — sk-ant-…"],
gigachat: ["GigaChat: адрес можно не указывать, модель — GigaChat, ключ — «Ключ авторизации» из личного кабинета Сбера (Base64)."],
})[st.provider];
hint.replaceChildren(...hints());
const body = () => { const b = { enabled: enabled.checked, provider: st.provider, base_url: url.value.trim(), model: model.value.trim(), scope: scope.value };
if (key.value) b.api_key = key.value; return b; };
box.replaceChildren(h("h2", { style: "margin-top:0" }, "🤖 AI-помощник"),
h("p", { class: "small muted" }, "Кнопка помощника появится у всех пользователей в правом нижнем углу. Помощник отвечает только о работе в портале (остальное вежливо отклоняет), ",
"учитывает права пользователя и читает данные только от его имени, ничего не меняет. В провайдер уходят вопрос, справка по порталу и найденные для ответа данные пользователя. Лимит — 30 вопросов в час на человека."),
h("div", { class: "grid" }, field("Провайдер", prov), field("Адрес API", url), field("Модель", model), field("Ключ API", key)), hint,
h("div", { style: "margin-top:8px" }, field("Указания помощнику", scope)),
h("div", { class: "row", style: "margin-top:10px" }, h("label", { class: "chk" }, enabled, "Помощник включён"), h("span", { style: "flex:1" }),
h("button", { class: "sec", onclick: guard(async () => {
await api("PUT", "/assistant/config", body()); key.value = "";
toast("Проверяю…"); const r = await api("POST", "/assistant/test");
out.replaceChildren(h("div", { class: "sheet", style: "margin-top:8px" }, h("b", {}, "Подключение работает. "),
r.offtopic_filtered ? "Фильтр тематики работает. " : h("span", { style: "color:var(--warn)" }, "Модель не отклонила вопрос не по теме — выберите модель поумнее. "),
h("div", { class: "small", style: "white-space:pre-wrap;margin-top:4px" }, "Пример ответа: " + r.answer)));
window.assistantRefresh && window.assistantRefresh();
}) }, "Проверить"),
h("button", { onclick: guard(async () => { await api("PUT", "/assistant/config", body()); toast("Сохранено"); key.value = ""; window.assistantRefresh && window.assistantRefresh(); assistantSettings(box); }) }, "Сохранить")),
out);
}
+689
View File
@@ -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();
+111
View File
@@ -0,0 +1,111 @@
"use strict";
// AI-помощник: кнопка в правом нижнем углу на всех экранах (полная версия и мобильная /m).
// Показывается, только если пользователь вошёл и администратор включил помощника.
(function () {
const API_ = "/api/v1", MOBILE = location.pathname.startsWith("/m");
const el = (t, a, ...k) => { const e = document.createElement(t); for (const [n, v] of Object.entries(a || {})) { if (v == null || v === false) continue; if (n.startsWith("on")) e.addEventListener(n.slice(2), v); else if (n === "class") e.className = v; else e.setAttribute(n, v); } for (const c of k.flat()) if (c != null && c !== false) e.append(c.nodeType ? c : document.createTextNode(c)); return e; };
const req = (path, opts = {}) => {
const f = typeof authFetch === "function" ? authFetch : (u, o) => fetch(u, { ...o, headers: { ...(o.headers || {}), Authorization: "Bearer " + localStorage.getItem("token") } });
return f(API_ + path, opts);
};
let history = [];
try { history = JSON.parse(sessionStorage.getItem("assistant") || "[]"); } catch (e) { history = []; }
const save = () => { try { sessionStorage.setItem("assistant", JSON.stringify(history.slice(-20))); } catch (e) {} };
const style = el("style", {});
style.textContent = `
#ai-btn{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:70;width:56px;height:56px;border-radius:50%;border:0;padding:0;
background:linear-gradient(135deg,#2563eb,#7c3aed);color:#fff;font-size:26px;cursor:pointer;box-shadow:0 6px 20px rgba(37,99,235,.45);display:none;align-items:center;justify-content:center}
#ai-btn.on{display:flex}
#ai-panel{position:fixed;right:18px;bottom:calc(84px + env(safe-area-inset-bottom,0px));z-index:71;width:min(380px,calc(100vw - 24px));height:min(720px,calc(100vh - 120px));
background:var(--card,#fff);color:var(--text,#1d2330);border:1px solid var(--line,#dfe3ea);border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.25);display:none;flex-direction:column;overflow:hidden;font:15px/1.45 system-ui,sans-serif}
#ai-panel.open{display:flex}
#ai-panel header{all:unset;display:flex;align-items:center;gap:8px;padding:12px 14px;background:linear-gradient(135deg,#2563eb,#7c3aed);color:#fff}
#ai-panel header b{flex:1}#ai-panel header button{all:unset;cursor:pointer;font-size:18px;padding:0 6px;color:#fff}
#ai-log{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
.ai-m{max-width:88%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;word-wrap:break-word}
.ai-m.u{align-self:flex-end;background:#2563eb;color:#fff;border-bottom-right-radius:4px}
.ai-m.a{align-self:flex-start;background:var(--bg,#f4f5f8);border-bottom-left-radius:4px}
.ai-m.a a{color:#2563eb;font-weight:600}.ai-m.e{align-self:center;color:#c62828;font-size:13px}
.ai-hint{font-size:12px;color:var(--muted,#6b7385);text-align:center;padding:2px 12px}
#ai-form{display:flex;gap:6px;padding:10px;border-top:1px solid var(--line,#dfe3ea)}
#ai-form textarea{flex:1;resize:none;height:44px;min-height:44px;font:inherit;padding:10px;border:1px solid var(--line,#dfe3ea);border-radius:10px;background:var(--bg,#f4f5f8);color:inherit}
#ai-form button{border:0;border-radius:10px;background:#2563eb;color:#fff;font-weight:700;padding:0 14px;cursor:pointer;min-height:44px}
#ai-form button:disabled{opacity:.5}
.ai-sug{display:flex;flex-wrap:wrap;gap:6px;padding:0 12px 6px}.ai-sug button{border:1px solid var(--line,#dfe3ea);background:none;color:inherit;border-radius:99px;padding:4px 10px;font-size:13px;cursor:pointer}
@media(max-width:600px){#ai-panel{right:6px;left:6px;width:auto;bottom:calc(80px + env(safe-area-inset-bottom,0px));height:calc(100vh - 110px)}}`;
document.head.append(style);
const log = el("div", { id: "ai-log" });
const ta = el("textarea", { placeholder: "Спросите, где что найти или как сделать…", maxlength: "1000" });
const send = el("button", { type: "submit" }, "➤");
const sug = el("div", { class: "ai-sug" });
const panel = el("div", { id: "ai-panel", role: "dialog", "aria-label": "Помощник" },
el("header", {}, "🤖", el("b", {}, "Помощник по порталу"),
el("button", { title: "Очистить", onclick: () => { history = []; save(); draw(); } }, "⟲"),
el("button", { title: "Закрыть", onclick: () => panel.classList.remove("open") }, "✕")),
log, sug, el("div", { class: "ai-hint" }, "Отвечает только о работе в портале и видит только ваши данные"),
el("form", { id: "ai-form", onsubmit: e => { e.preventDefault(); ask(ta.value); } }, ta, send));
const btn = el("button", { id: "ai-btn", title: "Помощник", "aria-label": "Помощник", onclick: () => { panel.classList.toggle("open"); if (panel.classList.contains("open")) { draw(); ta.focus(); } } }, "🤖");
ta.addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); ask(ta.value); } });
// ответ модели: только безопасный текст; ссылки — лишь на разделы портала (#/…, /report, /m)
function rich(text) {
const out = [], re = /\[([^\]]{1,80})\]\((#\/[^\s)]*|\/report|\/m)\)|\*\*([^*]{1,120})\*\*/g;
let last = 0, m;
while ((m = re.exec(text))) {
if (m.index > last) out.push(text.slice(last, m.index));
if (m[1]) {
const href = MOBILE && m[2].startsWith("#/") ? "/" + m[2] : m[2];
out.push(el("a", { href, onclick: () => { if (window.innerWidth < 600) panel.classList.remove("open"); } }, m[1]));
} else out.push(el("b", {}, m[3]));
last = re.lastIndex;
}
out.push(text.slice(last));
return out;
}
function draw() {
log.replaceChildren(...(history.length ? [] : [el("div", { class: "ai-m a" }, "Здравствуйте! Я подскажу, где что найти в портале и как выполнить действие, а также покажу ваши задания, наряды и выданный инструмент.")]),
...history.map(m => el("div", { class: "ai-m " + (m.role === "user" ? "u" : m.error ? "e" : "a") }, m.role === "user" ? m.content : rich(m.content))));
sug.replaceChildren(...(history.length ? [] : ["Какие у меня задания на сегодня?", "Как зафиксировать аварию?", "Где мой инструмент?", "Как подать заявку на инструмент?"]
.map(q => el("button", { type: "button", onclick: () => ask(q) }, q))));
log.scrollTop = log.scrollHeight;
}
async function ask(q) {
q = (q || "").trim();
if (!q || send.disabled) return;
ta.value = "";
history.push({ role: "user", content: q });
draw();
send.disabled = true;
const wait = el("div", { class: "ai-m a" }, "…");
log.append(wait); log.scrollTop = log.scrollHeight;
try {
const r = await req("/assistant/chat", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: history.filter(m => !m.error).slice(-10).map(m => ({ role: m.role, content: m.content })) }) });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(typeof d.detail === "string" ? d.detail : "Помощник недоступен");
history.push({ role: "assistant", content: d.reply });
} catch (e) {
history.push({ role: "assistant", content: e instanceof TypeError ? "Нет связи с сервером" : e.message, error: true });
} finally { send.disabled = false; save(); draw(); }
}
let checked = 0;
async function check() {
if (!localStorage.getItem("token")) { btn.classList.remove("on"); panel.classList.remove("open"); return; }
if (Date.now() - checked < 60000 && btn.dataset.state) return;
checked = Date.now();
try {
const r = await req("/assistant/status");
const on = r.ok && (await r.json()).enabled;
btn.classList.toggle("on", !!on); btn.dataset.state = on ? "on" : "off";
if (!on) panel.classList.remove("open");
} catch (e) { /* нет связи — кнопку не трогаем */ }
}
document.body.append(btn, panel);
window.addEventListener("hashchange", check);
setInterval(check, 15000);
setTimeout(check, 800);
window.assistantRefresh = () => { checked = 0; delete btn.dataset.state; check(); };
})();
+81
View File
@@ -0,0 +1,81 @@
"use strict";
// ---------- Демонстрационный режим (персональный) ----------
function setSession(t) {
token = t.access_token; localStorage.setItem("token", token); localStorage.setItem("refresh", t.refresh_token); me = null;
}
function demoBanner() {
const back = sessionStorage.getItem("admin_tokens");
return h("div", { class: "demobar" },
h("span", {}, "🎭 ДЕМО-РЕЖИМ (только у вас) — данные тестовые. Остальные пользователи работают с рабочими данными."),
back ? h("button", { class: "sm", onclick: backToAdmin }, "↩ Вернуться в админа") :
h("button", { class: "sm", onclick: guard(async () => {
if (!confirm("Выйти из демо-режима и вернуться к рабочим данным?")) return;
const t = await api("POST", "/demo/leave"); setSession(t); location.hash = "#/"; route();
}) }, "Выйти из демо"),
me.is_admin ? h("a", { href: "#/demo" }, "Управление") : null);
}
function backToAdmin() {
const t = JSON.parse(sessionStorage.getItem("admin_tokens") || "null");
sessionStorage.removeItem("admin_tokens");
if (t) { setSession(t); location.hash = "#/demo"; route(); }
}
async function demoPage() {
if (!me.is_admin) throw new Error("Только для администратора");
const info = await api("GET", "/demo");
const reload = () => demoPage();
const switchTo = guard(async (path, body, hash = "#/demo") => {
const t = await api("POST", path, body);
sessionStorage.removeItem("admin_tokens"); setSession(t); location.hash = hash; route();
});
const q = h("input", { type: "search", placeholder: "🔍 Найти пользователя", style: "max-width:320px" });
const tbody = h("tbody");
const drawUsers = () => {
const s = q.value.trim().toLowerCase();
tbody.replaceChildren(...info.users.filter(u => !s || (u.full_name + " " + u.username).toLowerCase().includes(s)).map(u => h("tr", {},
h("td", {}, u.full_name || u.username, h("div", { class: "small muted" }, u.username, u.is_admin ? " · админ" : "")),
h("td", {}, u.in_demo ? h("span", { class: "badge b-in_progress" }, "в демо") : h("span", { class: "muted" }, "рабочие данные")),
h("td", { class: "row end" }, u.id === me.id && info.in_demo ? h("span", { class: "small muted" }, "это вы") :
h("button", { class: "sec sm", onclick: guard(async () => {
if (!u.in_demo && !confirm(`Перевести «${u.full_name || u.username}» в демо-режим? Он увидит тестовые данные до выхода из демо.`)) return;
await api("POST", `/demo/users/${u.id}`, { on: !u.in_demo }); toast(u.in_demo ? "Выведен из демо" : "Переведён в демо"); reload();
}) }, u.in_demo ? "Выключить демо" : "Включить демо")))));
};
q.oninput = drawUsers;
drawUsers();
const usersCard = h("div", { class: "card tablewrap" }, h("h2", { style: "margin-top:0" }, "Кто в демо-режиме"),
h("p", { class: "small muted" }, "Демо включается каждому отдельно. Если кто-то включил его случайно и не может выйти — выключите здесь: со следующего действия он вернётся к рабочим данным."),
q, h("table", { style: "margin-top:8px" }, tbody));
const head = [h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Демонстрационный режим")];
const intro = h("ul", { class: "small" },
h("li", {}, h("b", {}, "Демо — персональное."), " Его видит только тот, кто в него вошёл; остальные в это время работают с рабочими данными как обычно."),
h("li", {}, "Тестовая база отдельная: рабочие данные не изменяются. Всё, что сделано в демо, исчезнет при удалении демо-данных."),
h("li", {}, "Выйти из демо можно кнопкой в жёлтой полосе вверху экрана."));
if (!info.active) {
$app.replaceChildren(...head, h("div", { class: "card" },
h("p", {}, "Тестовый набор: сотрудники, графики, оборудование, чек-листы, наряды и задания за 14 дней, карта завода, склады, инциденты."), intro,
h("button", { onclick: () => confirm("Войти в демонстрационный режим?\nДругие пользователи его не увидят.") && switchTo("/demo/enable", { confirm: true }, "#/") }, "Войти в демо-режим")), usersCard);
return;
}
const control = h("div", { class: "card" },
h("p", {}, `Демо-данные созданы: ${fmtFull(info.since)}. Сейчас в демо: ${info.users.filter(u => u.in_demo).length} чел.`), intro,
h("div", { class: "row" },
info.in_demo ? h("button", { onclick: () => confirm("Выйти из демо и вернуться к рабочим данным?") && switchTo("/demo/leave", undefined, "#/") }, "Выйти из демо")
: h("button", { onclick: () => switchTo("/demo/enable", { confirm: true }, "#/") }, "Войти в демо"),
h("button", { class: "sec", onclick: guard(async () => { if (confirm("Пересоздать тестовые данные? Все правки в демо будут потеряны.")) { await api("POST", "/demo/reset"); toast("Демо-данные пересозданы"); location.reload(); } }) }, "Пересоздать демо-данные"),
h("button", { class: "bad", onclick: guard(async () => { if (confirm("Удалить демо-данные? Все, кто в демо, вернутся к рабочим данным.")) { await api("POST", "/demo/destroy"); toast("Демо-данные удалены"); location.reload(); } }) }, "Удалить демо-данные")));
const accounts = info.accounts && info.in_demo ? h("div", { class: "card tablewrap" }, h("h2", { style: "margin-top:0" }, "Тестовые учётные записи"),
h("p", { class: "small muted" }, "Пароль у всех: ", h("b", {}, info.password), ". «Войти как» открывает систему под этим пользователем без пароля — удобно показывать роли. Вернуться можно кнопкой в жёлтой полосе вверху."),
h("table", {}, h("thead", {}, h("tr", {}, ...["Логин", "ФИО", "Роль", "Должность", "Смена", ""].map(t => h("th", {}, t)))),
h("tbody", {}, ...info.accounts.map(a => h("tr", {}, h("td", {}, a.username), h("td", {}, a.full_name), h("td", {}, a.role), h("td", {}, a.position || "—"),
h("td", {}, a.position ? (a.on_shift ? "на смене" : "не на смене") : "—"),
h("td", {}, h("button", { class: "sec sm", onclick: guard(async () => {
const keep = { access_token: token, refresh_token: localStorage.getItem("refresh") };
const t = await api("POST", "/demo/impersonate", { user_id: a.id });
sessionStorage.setItem("admin_tokens", JSON.stringify(keep)); setSession(t); location.hash = "#/"; route();
}) }, "Войти как")))))))
: h("p", { class: "small muted" }, `Тестовые учётные записи (пароль ${info.password}) видны, когда вы сами в демо.`);
$app.replaceChildren(...head, control, usersCard, accounts);
}
+57
View File
@@ -0,0 +1,57 @@
"use strict";
// ---------- Обратная связь по порталу ----------
// Неприметная ссылка внизу каждой страницы открывает короткую форму. Ответы видят только администраторы портала (#/feedback).
const FB_KIND = { idea: "💡 Идея", bug: "🐞 Ошибка", other: "💬 Другое" };
const FB_STATUS = { new: "Новое", taken: "В работе", done: "Сделано", rejected: "Не будем делать" };
function feedbackFooter() {
const foot = document.getElementById("appfoot");
if (!foot) return;
foot.replaceChildren(me ? h("a", { href: "javascript:void 0", class: "fblink", onclick: openFeedback }, "Предложить улучшение портала или сообщить об ошибке") : "");
}
function openFeedback() {
if (document.querySelector(".fbmodal")) return;
const st = { kind: "idea", text: "" };
const kinds = h("div", { class: "row" });
const drawKinds = () => kinds.replaceChildren(...Object.entries(FB_KIND).map(([k, t]) =>
h("button", { type: "button", class: "chipf" + (st.kind === k ? " on" : ""), onclick: () => { st.kind = k; drawKinds(); } }, t)));
drawKinds();
const text = h("textarea", { rows: 5, maxlength: 4000, placeholder: "Что улучшить или что не работает? Где именно, как повторить." });
const close = () => wrap.remove();
const wrap = h("div", { class: "fbmodal", onclick: e => { if (e.target === wrap) close(); } },
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Обратная связь по порталу"),
h("p", { class: "small muted" }, "Сообщение получат администраторы портала. Раздел, где вы сейчас, подставится сам."),
kinds, h("div", { style: "margin-top:8px" }, text),
h("div", { class: "row", style: "margin-top:10px" },
h("button", { onclick: guard(async () => {
if (text.value.trim().length < 3) throw new Error("Опишите идею или проблему");
await api("POST", "/feedback", { kind: st.kind, text: text.value, page: (location.hash || "#/").slice(0, 200), client: "web" });
close(); toast("Спасибо! Сообщение отправлено");
}) }, "Отправить"), h("button", { class: "sec", onclick: close }, "Отмена"))));
document.body.append(wrap);
text.focus();
}
async function feedbackPage(which) {
if (!me.portal_admin) throw new Error("Доступно только администраторам портала");
const status = which === "all" ? "" : which === "done" ? "done" : "open";
const list = await api("GET", "/feedback" + (status ? "?status=" + status : ""));
const card = f => {
const sel = h("select", {}, ...Object.entries(FB_STATUS).map(([k, t]) => h("option", { value: k, selected: f.status === k }, t)));
const ans = h("textarea", { rows: 2, placeholder: "Заметка: что решили, что сделано" }, f.answer);
return h("div", { class: "card fbcard fb-" + f.status },
h("div", { class: "row" }, h("b", { style: "flex:1" }, FB_KIND[f.kind] || f.kind), h("span", { class: "badge" }, FB_STATUS[f.status] || f.status)),
h("div", { class: "small muted" }, [fmt(f.created_at), f.user_name || "—", f.page ? "раздел " + f.page : "", { web: "сайт", m: "мобильная версия", android: "приложение" }[f.client] || f.client].filter(Boolean).join(" · ")),
h("div", { style: "margin-top:6px;white-space:pre-wrap" }, f.text),
f.answered_by_name ? h("div", { class: "small muted", style: "margin-top:4px" }, `Обновил ${f.answered_by_name}, ${fmt(f.answered_at)}`) : null,
h("div", { class: "grid", style: "margin-top:8px" }, field("Статус", sel), field("Заметка", ans)),
h("div", { class: "row", style: "margin-top:8px" },
h("button", { class: "sm", onclick: guard(async () => { await api("PATCH", `/feedback/${f.id}`, { status: sel.value, answer: ans.value }); toast("Сохранено"); feedbackPage(which); }) }, "Сохранить"),
h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить сообщение?")) { await api("DELETE", `/feedback/${f.id}`); feedbackPage(which); } }) }, "Удалить")));
};
fill($app, h("h1", {}, "Обратная связь по порталу"),
tabs([["#/feedback", "Открытые"], ["#/feedback/done", "Сделано"], ["#/feedback/all", "Все"]], which === "all" ? "#/feedback/all" : which === "done" ? "#/feedback/done" : "#/feedback"),
h("p", { class: "small muted" }, "Идеи и замечания сотрудников. Видят только администраторы портала. Ссылка на форму — внизу каждой страницы и в меню приложения."),
...(list.length ? list.map(card) : [h("div", { class: "card muted" }, "Сообщений нет")]));
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#2563eb"/><path d="M18 33l9 9 19-20" fill="none" stroke="#fff" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 238 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#c2410c"/><rect x="18" y="10" width="28" height="44" rx="6" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M32 46c-5 0-7-4-5-8 1 3 3 3 3 0s2-6 4-7c-1 4 5 6 3 11-1 2-3 4-5 4z" fill="#fff"/><path d="M24 18h16" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 485 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#7c3aed"/><rect x="10" y="30" width="36" height="18" rx="9" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 30V18h12v12M18 18h20M50 39h6M14 54h4M38 54h4" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="28" cy="13" r="3" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 477 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#b45309"/><rect x="8" y="30" width="48" height="12" rx="6" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="14" cy="36" r="2.5" fill="#fff"/><circle cx="32" cy="36" r="2.5" fill="#fff"/><circle cx="50" cy="36" r="2.5" fill="#fff"/><rect x="18" y="18" width="10" height="10" rx="1" fill="#fff"/><rect x="34" y="20" width="8" height="8" rx="1" fill="#fff"/><path d="M16 42l-4 12M48 42l4 12" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 667 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#ca8a04"/><path d="M14 56V12l36 6M14 18l36 0M20 12l-6 8M50 18v14" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M46 32h8l-2 6h-4z" fill="#fff"/><path d="M8 56h16" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 441 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#0284c7"/><circle cx="32" cy="32" r="22" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="32" cy="32" r="4" fill="#fff"/><path d="M32 28c-2-8 2-14 8-12-1 6-4 9-8 12zM36 33c8-1 12 4 9 9-5-2-8-5-9-9zM29 35c-4 7-11 8-12 2 5-3 8-3 12-2z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 434 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#ea580c"/><path d="M10 48V22h18l6 14v12M34 36h4M44 12v40h12M38 22h6" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="16" cy="50" r="5" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="32" cy="50" r="5" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 535 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#b91c1c"/><path d="M12 56V20l20-10 20 10v36zM22 56V38h20v18" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M32 54c-4 0-6-3-4-7 1 2 2 2 2 0s2-5 3-5c0 3 4 5 3 8-1 3-2 4-4 4z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 373 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#57534e"/><path d="M32 12l4 6 7-2 1 7 7 2-3 7 5 5-6 4 1 7-7 1-2 7-7-3-5 5-4-6-7 1-1-7-7-2 3-7-5-5 6-4-1-7 7-1 2-7 7 3z" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="32" cy="34" r="7" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 465 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#d97706"/><rect x="8" y="20" width="48" height="28" rx="4" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M34 24l-8 12h8l-4 10 10-14h-8l4-8z" fill="#fff"/><path d="M14 48v6M50 48v6" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 459 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#0891b2"/><rect x="8" y="16" width="48" height="20" rx="4" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M14 30h36M20 42c0 4-4 4-4 8M32 42c0 4-4 4-4 8M44 42c0 4-4 4-4 8" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 448 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#334155"/><path d="M8 44h48M12 44v10M52 44v10M12 24h12v20H12zM44 30h8v14h-8zM24 34h20" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="18" cy="34" r="3" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 354 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#4d7c0f"/><path d="M14 24h36l-4 30H18zM32 8v38M24 44h16M10 24h44" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M26 40l12-4" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 403 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#0f766e"/><rect x="12" y="20" width="32" height="24" rx="4" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M44 32h10M18 20v24M24 20v24M30 20v24M36 20v24M16 50h24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 440 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#1d4ed8"/><rect x="14" y="8" width="36" height="48" rx="3" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M36 20l-7 11h6l-4 10 9-13h-6l4-8z" fill="#fff"/><circle cx="44" cy="48" r="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 383 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#475569"/><path d="M14 10h36v8H14zM20 18v20M44 18v20M10 54h44M18 48h28v6H18z" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><rect x="24" y="28" width="16" height="10" rx="1" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 365 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#2563eb"/><circle cx="28" cy="34" r="12" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M28 34l7-7M40 34h14M8 34h8M28 22V12h14" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><rect x="14" y="46" width="28" height="6" rx="2" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 467 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#0369a1"/><path d="M10 54h20M20 54V42l12-14 14 8M46 36l6-8M46 36l8 4" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="20" cy="42" r="3" fill="#fff"/><circle cx="32" cy="28" r="3" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 380 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#15803d"/><path d="M16 16c0-4 32-4 32 0v32c0 4-32 4-32 0z" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M16 16c0 4 32 4 32 0M16 32c0 4 32 4 32 0M22 54v4M42 54v4" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 441 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#9333ea"/><rect x="14" y="18" width="36" height="34" rx="3" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 18v-8M32 18v-8M42 18v-8M20 28h24M20 36h24M20 44h24" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 440 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" rx="14" fill="#be123c"/><path d="M8 28v16l24-8zM56 28v16l-24-8zM32 36V18M24 14h16" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><circle cx="32" cy="36" r="3" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 336 B

+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Портал технической службы</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<button id="burger" aria-label="Меню">☰</button>
<div id="shade"></div>
<aside id="side"></aside>
<div id="page"><div id="banner"></div><main id="app"></main><footer id="appfoot"></footer></div>
<div id="toast"></div>
<script src="/static/schedules.js"></script>
<script src="/static/service.js"></script>
<script src="/static/reports.js"></script>
<script src="/static/portal.js"></script>
<script src="/static/work.js"></script>
<script src="/static/reliability.js"></script>
<script src="/static/admin.js"></script>
<script src="/static/setup.js"></script>
<script src="/static/assistant.js"></script>
<script src="/static/demo.js"></script>
<script src="/static/feedback.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Чек-листы">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Чек-листы</title>
<link rel="manifest" href="/static/m/manifest.webmanifest">
<link rel="apple-touch-icon" href="/static/m/apple-touch-icon.png">
<link rel="icon" type="image/png" href="/static/m/icon-192.png">
<link rel="stylesheet" href="/static/m/m.css">
</head>
<body>
<button id="burger" aria-label="Меню">☰</button>
<div id="shade"></div>
<aside id="drawer"></aside>
<header id="bar"></header>
<main id="view"></main>
<div id="toast"></div>
<script src="/static/m/m.js"></script>
<script src="/static/assistant.js"></script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
:root{--bg:#f4f5f8;--card:#fff;--text:#1d2330;--muted:#6b7385;--line:#dfe3ea;--accent:#2563eb;--on-accent:#fff;--ok:#16803c;--warn:#b45309;--bad:#c62828;--info:#1d4ed8;--soft:#eef2ff;
--safe-t:env(safe-area-inset-top,0px);--safe-b:env(safe-area-inset-bottom,0px)}
@media (prefers-color-scheme:dark){:root{--bg:#12151c;--card:#1b2029;--text:#e6e9f0;--muted:#98a1b3;--line:#2c3340;--accent:#6c9bff;--on-accent:#0b0f17;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;--info:#7aa7ff;--soft:#222a3b}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding-bottom:calc(16px + var(--safe-b))}
header{position:sticky;top:0;z-index:10;background:var(--card);border-bottom:1px solid var(--line);padding:calc(8px + var(--safe-t)) 14px 8px;display:flex;align-items:center;gap:8px;min-height:56px}
header h1{font-size:18px;margin:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
header .sub{font-size:12px;color:var(--muted);display:block;font-weight:400}
header .sub.warn{color:var(--warn)}header .sub.bad{color:var(--bad)}
.ib{background:none;border:0;color:var(--text);font-size:22px;width:44px;height:44px;border-radius:50%;padding:0;display:flex;align-items:center;justify-content:center}
.ib:active{background:var(--soft)}.ib.spin{animation:sp 1s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
a{color:var(--accent)}
main{padding:12px;max-width:640px;margin:0 auto}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;margin-bottom:12px}
.card.tap:active{background:var(--soft)}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.row.sp{justify-content:space-between}
.muted{color:var(--muted)}.small{font-size:13px}.bad{color:var(--bad)}.ok{color:var(--ok)}.warn{color:var(--warn)}
h2{font-size:17px;margin:0 0 10px}
.title{font-weight:600;font-size:17px}
.badge{display:inline-block;padding:2px 10px;border-radius:99px;font-size:12px;font-weight:600;border:1px solid currentColor;white-space:nowrap}
.b-new{color:var(--info)}.b-in_progress{color:var(--warn)}.b-review{color:#9333ea}.b-completed{color:var(--ok)}.b-cancelled{color:var(--muted)}.b-overdue{color:var(--bad)}
.bar{height:6px;border-radius:4px;background:var(--line);overflow:hidden;flex:1}.bar>i{display:block;height:100%;background:var(--ok)}
input[type=text],input[type=password],input[type=search],input:not([type]),textarea{width:100%;font:inherit;padding:12px;border:1px solid var(--line);border-radius:12px;background:var(--bg);color:var(--text);min-height:48px}
textarea{min-height:84px;resize:vertical}
input:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
label{display:block;font-size:13px;color:var(--muted);margin:0 0 4px}
button,.btn{font:inherit;font-weight:600;min-height:48px;padding:0 18px;border-radius:12px;border:0;background:var(--accent);color:var(--on-accent);text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px}
button:active,.btn:active{filter:brightness(.92)}
button:disabled{opacity:.45}
button.sec,.btn.sec{background:transparent;color:var(--text);border:1px solid var(--line)}
button.bad,.btn.bad{background:var(--bad);color:#fff}
button.block,.btn.block{width:100%}
.chips{display:flex;gap:8px;flex-wrap:wrap}
.chip{min-height:44px;padding:0 16px;border-radius:99px;border:1px solid var(--line);background:var(--card);color:var(--text);font-weight:500}
.chip.on{background:var(--accent);color:var(--on-accent);border-color:var(--accent);font-weight:700}
.chip:disabled{opacity:.55}
.filters{display:flex;gap:8px;overflow-x:auto;padding-bottom:10px;margin:0 -12px 4px;padding-left:12px;padding-right:12px;scrollbar-width:none}
.filters::-webkit-scrollbar{display:none}.filters .chip{flex:0 0 auto}
.item{padding:14px 0;border-top:1px solid var(--line)}.item:first-of-type{border-top:0;padding-top:4px}
.item .q{font-weight:600;margin-bottom:8px}
.item.oor{background:color-mix(in srgb,var(--bad) 8%,transparent);margin:0 -14px;padding-left:14px;padding-right:14px}
.photos{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:center}
.ph{position:relative}.ph img{width:84px;height:84px;object-fit:cover;border-radius:10px;border:1px solid var(--line);background:var(--bg);display:block}
.ph .x{position:absolute;top:-8px;right:-8px;width:28px;height:28px;min-height:0;padding:0;border-radius:50%;background:var(--bad);color:#fff;font-size:13px}
.ph .p{position:absolute;left:4px;bottom:2px;color:#fff;text-shadow:0 0 3px #000}
.notice{border-radius:12px;padding:12px;margin-bottom:12px;font-size:15px;background:color-mix(in srgb,var(--c) 14%,transparent);color:var(--c)}
.kv{display:grid;grid-template-columns:40% 60%;gap:4px 8px;font-size:15px}.kv span:nth-child(odd){color:var(--muted)}
.cm{padding:8px 0;border-top:1px solid var(--line)}.cm:first-of-type{border-top:0}
.actions{position:sticky;bottom:0;background:linear-gradient(transparent,var(--bg) 30%);padding:12px 0 calc(8px + var(--safe-b));display:flex;gap:10px;z-index:5}
.actions button{flex:1}
.login{max-width:380px;margin:8vh auto 0;padding:0 4px}
.login h1{font-size:26px;margin:0 0 4px}
.stack>*+*{margin-top:12px}
#toast{position:fixed;left:50%;bottom:calc(24px + var(--safe-b));transform:translateX(-50%);background:var(--text);color:var(--bg);padding:12px 16px;border-radius:12px;opacity:0;transition:.2s;pointer-events:none;max-width:90vw;z-index:50;font-size:15px}
#toast.show{opacity:1}#toast.err{background:var(--bad);color:#fff}
.viewer{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:60;display:flex;align-items:center;justify-content:center}
.viewer img{max-width:100%;max-height:100%}
.sheet{background:var(--soft);border-radius:12px;padding:12px;font-size:14px}
/* боковое меню вместо шапки: заголовок экрана прокручивается вместе со страницей, меню — по кнопке ☰ */
header{position:static;background:transparent;border:0;min-height:60px}
body.authed header{padding-left:66px}
#burger{display:none;position:fixed;left:10px;top:calc(8px + var(--safe-t));z-index:40;width:48px;min-height:48px;height:48px;padding:0;border-radius:14px;font-size:24px;box-shadow:0 2px 12px rgba(0,0,0,.3)}
body.authed #burger{display:flex}
body.menu-open #burger{display:none}
#drawer{position:fixed;left:0;top:0;bottom:0;width:min(310px,86vw);background:var(--card);z-index:60;transform:translateX(-102%);transition:transform .2s;overflow-y:auto;padding:calc(12px + var(--safe-t)) 10px calc(12px + var(--safe-b));box-shadow:0 0 30px rgba(0,0,0,.35)}
body.menu-open #drawer{transform:none}
#shade{display:none}
body.menu-open #shade{display:block;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:55}
#drawer .dhead{padding:8px 10px 14px;border-bottom:1px solid var(--line);margin-bottom:6px}
#drawer .dhead b{font-size:17px}
#drawer nav{display:flex;flex-direction:column}
#drawer nav a{display:flex;gap:12px;align-items:center;min-height:48px;padding:0 10px;border-radius:12px;color:var(--text);text-decoration:none;font-size:16px}
#drawer nav a i{font-style:normal;width:24px;text-align:center}
#drawer nav a.on{background:var(--soft);color:var(--accent);font-weight:600}
#drawer .navh{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:14px 10px 4px}
/* обратная связь */
.fbm{opacity:.7;font-size:13px}
.fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:14px}
.fbmodal .card{width:100%;max-width:520px}.fbmodal textarea{width:100%;margin-top:8px}
+641
View File
@@ -0,0 +1,641 @@
"use strict";
/* Мобильная версия (PWA) с полным офлайном.
Хранилище: IndexedDB. Наряд хранится одной записью: заголовок + пункты + фото + комментарии + локальные флаги
(dirty / pendingSubmit / pending фото и комментарии). Отправка — как в Android: фото → ответы пачкой (/sync) → комментарии → загрузка. */
const API = "/api/v1";
const STATUS = { new: "Новый", in_progress: "В работе", review: "На проверке", completed: "Выполнен", cancelled: "Отменён" };
const ACTIVE = ["new", "in_progress", "review"];
const $bar = document.getElementById("bar"), $view = document.getElementById("view");
const uuid = () => (crypto.randomUUID ? crypto.randomUUID() : "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === "x" ? r : (r & 3 | 8)).toString(16); }));
// ---------- DOM ----------
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 ? 5000 : 2000);
}
const fmt = s => s ? new Date(s).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }) : "—";
const fmtFull = s => s ? new Date(s).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—";
const ms = s => { const t = Date.parse(s); return isNaN(t) ? null : t; };
const clean = n => n.flat(9).filter(x => x != null && x !== false);
let renderSeq = 0;
/** Выводит экран, только если с момента начала отрисовки не началась новая (защита от гонок). */
const commit = (my, ...n) => { if (my === renderSeq) $view.replaceChildren(...clean(n)); };
const setBar = (...n) => $bar.replaceChildren(...clean(n));
const guard = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message || String(e), true); } };
// ---------- IndexedDB ----------
const DB = (() => {
let p;
const open = () => p ||= new Promise((res, rej) => {
const r = indexedDB.open("checklists-m", 1);
r.onupgradeneeded = () => { const d = r.result; d.createObjectStore("kv"); d.createObjectStore("orders", { keyPath: "id" }); d.createObjectStore("blobs"); };
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
});
const rq = r => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
async function tx(stores, mode, fn) {
const d = await open(), names = [].concat(stores), t = d.transaction(names, mode);
const done = new Promise((res, rej) => { t.oncomplete = res; t.onerror = t.onabort = () => rej(t.error || new Error("IndexedDB")); });
const out = await fn(...names.map(n => t.objectStore(n)));
await done; return out;
}
return {
kvGet: k => tx("kv", "readonly", s => rq(s.get(k))),
kvSet: (k, v) => tx("kv", "readwrite", s => rq(s.put(v, k))),
orders: () => tx("orders", "readonly", s => rq(s.getAll())),
order: id => tx("orders", "readonly", s => rq(s.get(id))),
delOrder: id => tx("orders", "readwrite", s => rq(s.delete(id))),
/** Атомарное «прочитал → изменил → записал» (fn синхронная; вернёт новую запись). Если fn вернула null — запись удаляется. */
update: (id, fn) => tx("orders", "readwrite", async s => {
const cur = await rq(s.get(id)); const next = fn(cur);
if (next === null) await rq(s.delete(id)); else if (next) await rq(s.put(next));
return next;
}),
blobGet: k => tx("blobs", "readonly", s => rq(s.get(k))),
blobPut: (k, b) => tx("blobs", "readwrite", s => rq(s.put(b, k))),
blobDel: k => tx("blobs", "readwrite", s => rq(s.delete(k))),
wipe: () => tx(["orders", "blobs", "kv"], "readwrite", async (o, b, k) => { await rq(o.clear()); await rq(b.clear()); await rq(k.clear()); }),
};
})();
// ---------- сессия и запросы ----------
let token = localStorage.getItem("token"), me = null, refreshing = null;
const hasSession = () => !!localStorage.getItem("refresh");
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-mobile" }) })
.then(async r => {
if (r.status === 401 || r.status === 400) { localStorage.removeItem("refresh"); localStorage.removeItem("token"); token = null; authLost(); return false; }
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;
}
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;
}
class ApiError extends Error { constructor(msg, status, network) { super(msg); this.status = status; this.network = network; } }
async function api(method, path, body) {
let r;
try {
r = await authFetch(API + path, { method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
} catch (e) { throw new ApiError("Нет связи с сервером", 0, true); }
if (!r.ok) {
let d = r.statusText;
try { d = (await r.json()).detail; } catch (e) {}
if (Array.isArray(d)) d = d.map(x => x.msg).join("; ");
throw new ApiError(d || "Ошибка " + r.status, r.status, false);
}
return r.status === 204 ? null : r.json();
}
/** Запоминает профиль. Сменился режим (боевой ↔ демо) — у сервера другая база, локальные наряды стираем. */
async function applyMe(m) {
const prev = await DB.kvGet("demo_mode");
if (prev != null && !!prev !== !!m.demo_mode) {
await DB.wipe();
toast(m.demo_mode ? "Включён демо-режим: показаны тестовые данные" : "Демо-режим выключен: рабочие данные");
}
me = m;
await DB.kvSet("user_id", m.id); await DB.kvSet("me", m); await DB.kvSet("demo_mode", !!m.demo_mode);
}
async function leaveDemo() {
if (!confirm("Выйти из демо-режима и вернуться к рабочим данным?")) return;
try {
const t = await api("POST", "/demo/leave");
token = t.access_token; localStorage.setItem("token", token); localStorage.setItem("refresh", t.refresh_token);
await applyMe(await api("GET", "/me")); await render(); syncAll();
} catch (e) { toast(e.network ? "Нет связи с сервером" : e.message, true); }
}
function authLost() { me = null; sync.error = "Сессия истекла — войдите снова"; render(); }
// ---------- слияние с сервером ----------
const hasUnsynced = o => !!o && (o.pendingSubmit || (o.items || []).some(i => i.dirty) || (o.photos || []).some(p => p.pending) || (o.pendingComments || []).length > 0);
const itemDone = i => i.answer_type === "status" ? i.value === "done" : !!i.value;
function merge(old, d) {
old = old || {};
const o = { ...d };
delete o.items;
Object.assign(o, {
pendingSubmit: old.pendingSubmit || false, syncError: old.syncError || null, pendingComments: old.pendingComments || [],
comments: old.comments || [], items: old.items || [], photos: old.photos || [], hasItems: !!old.hasItems,
});
if (d.items) {
const dirty = new Map((old.items || []).filter(i => i.dirty).map(i => [i.id, i]));
o.items = d.items.map(i => dirty.get(i.id) || { ...i, photos: undefined, answered_ms: ms(i.answered_at), dirty: false });
o.photos = [
...d.items.flatMap(i => (i.photos || []).map(p => ({ key: "s" + p.id, item_id: i.id, server_id: p.id, pending: false, user_id: p.user_id, created_at: p.created_at }))),
...(old.photos || []).filter(p => p.pending),
];
o.hasItems = true;
}
return o;
}
const mergeInto = d => DB.update(d.id, cur => merge(cur, d));
// ---------- синхронизация ----------
const sync = { running: false, last: null, error: null, pending: 0, listeners: new Set(), timer: null, lastIds: null };
let initialDone = false;
async function countPending() { sync.pending = (await DB.orders()).filter(hasUnsynced).length; }
function scheduleSync(delay = 400) { clearTimeout(sync.timer); sync.timer = setTimeout(() => syncAll(), delay); }
async function syncAll() {
if (sync.running || !hasSession()) return false;
sync.running = true; renderBar();
try {
// профиль первым: если пользователя перевели в демо (или вернули), локальные данные другой базы не отправляем
try { await applyMe(await api("GET", "/me")); } catch (e) { if (!e.network && e.status !== 401) throw e; }
await uploadPhotos(); await pushAnswers(); await pushComments(); await pull();
sync.last = Date.now(); await DB.kvSet("last", sync.last); sync.error = null;
return true;
} catch (e) {
sync.error = e.network ? "Нет связи — данные сохранены на телефоне" : e.message;
return false;
} finally {
sync.running = false; await countPending(); renderBar(); softRender();
}
}
async function uploadPhotos() {
for (const o of await DB.orders()) {
for (const p of (o.photos || []).filter(x => x.pending)) {
const blob = await DB.blobGet(p.key);
if (!blob) { await DB.update(o.id, c => c && ({ ...c, photos: c.photos.filter(x => x.key !== p.key) })); continue; }
let r;
try {
r = await authFetch(`${API}/orders/${o.id}/items/${p.item_id}/photos`, { method: "POST", headers: { "Content-Type": "image/jpeg", "Idempotency-Key": p.key }, body: blob });
} catch (e) { throw new ApiError("Нет связи", 0, true); }
if (r.ok) {
const dto = await r.json();
await DB.update(o.id, c => { const n = merge(c, dto); n.photos = n.photos.filter(x => x.key !== p.key); return n; });
await DB.blobDel(p.key);
} else if ([400, 403, 404, 413, 415].includes(r.status)) {
let d = ""; try { d = (await r.json()).detail; } catch (e) {}
await DB.update(o.id, c => c && ({ ...c, photos: c.photos.filter(x => x.key !== p.key), syncError: "Фото не принято сервером: " + d }));
await DB.blobDel(p.key);
} else throw new ApiError("Ошибка сервера " + r.status, r.status, false);
}
}
}
async function pushAnswers() {
for (const o0 of await DB.orders()) {
const dirty = (o0.items || []).filter(i => i.dirty);
if (!dirty.length && !o0.pendingSubmit) continue;
const sent = new Map(dirty.map(i => [i.id, i.answered_ms]));
const req = { items: dirty.map(i => ({ item_id: i.id, value: i.value ?? "", answered_at: i.answered_ms ? new Date(i.answered_ms).toISOString() : null })), submit: !!o0.pendingSubmit };
try {
const res = await api("POST", `/orders/${o0.id}/sync`, req);
const notes = res.results.filter(r => r.status !== "ok").map(r => r.detail).concat(res.submit_error ? [res.submit_error] : []);
await DB.update(o0.id, c => {
if (!c) return c;
// «чистыми» становятся только пункты, не изменённые пользователем, пока шёл запрос
c.items.forEach(i => { if (sent.has(i.id) && i.answered_ms === sent.get(i.id)) i.dirty = false; });
const n = merge(c, res.order);
n.pendingSubmit = false; n.syncError = notes.join("; ") || null;
return n;
});
} catch (e) {
if (e.status === 403 || e.status === 404) {
await DB.update(o0.id, c => c && ({ ...c, items: c.items.map(i => ({ ...i, dirty: false })), pendingSubmit: false, syncError: "Изменения не приняты: " + e.message }));
} else throw e;
}
}
}
async function pushComments() {
for (const o of await DB.orders()) {
for (const c of (o.pendingComments || [])) {
try { await api("POST", `/orders/${o.id}/comments`, { text: c.text }); }
catch (e) { if (![403, 404, 422].includes(e.status)) throw e; }
await DB.update(o.id, cur => cur && ({ ...cur, pendingComments: cur.pendingComments.filter(x => x.id !== c.id) }));
}
}
}
async function pull() {
const since = new Date(Date.now() - 3 * 86400e3).toISOString();
const active = await api("GET", "/orders?status=new,in_progress,review&include_items=true&limit=1000");
const recent = await api("GET", `/orders?status=completed,cancelled&limit=300&updated_since=${encodeURIComponent(since)}`);
const before = new Set((await DB.orders()).map(o => o.id));
for (const d of [...active, ...recent]) await mergeInto(d);
const keep = new Set([...active, ...recent].map(d => d.id));
for (const o of await DB.orders()) if (!keep.has(o.id) && !hasUnsynced(o)) await DB.delOrder(o.id);
if (initialDone) {
const fresh = active.filter(d => !before.has(d.id) && d.can_execute);
if (fresh.length) toast(`Новый наряд: ${fresh[0].title}${fresh.length > 1 ? ` (+${fresh.length - 1})` : ""}`);
}
initialDone = true;
}
/** Подтянуть наряд и комментарии при открытии. */
async function loadDetails(id) {
if (!hasSession()) return;
try {
const dto = await api("GET", "/orders/" + id), cms = await api("GET", `/orders/${id}/comments`);
await DB.update(id, c => { const n = merge(c, dto); n.comments = cms.map(x => ({ id: x.id, user_name: x.user_name, text: x.text, created_at: x.created_at })); return n; });
softRender();
} catch (e) { if (!e.network) toast(e.message, true); }
}
// ---------- действия пользователя (работают офлайн) ----------
async function answer(orderId, itemId, value) {
const v = (value ?? "").toString().trim() || null;
await DB.update(orderId, o => {
if (!o) return o;
const it = o.items.find(i => i.id === itemId); if (!it) return o;
it.value = v; it.answered_ms = Date.now(); it.dirty = true;
it.out_of_range = false;
if (it.answer_type === "number" && v != null) {
const n = parseFloat(v.replace(",", "."));
it.out_of_range = !isNaN(n) && ((it.min_value != null && n < it.min_value) || (it.max_value != null && n > it.max_value));
}
o.syncError = null; return o;
});
await countPending(); renderBar(); scheduleSync(); softRender();
}
async function downscale(file, max = 1600, q = 0.85) {
try {
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
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", q)) || file;
} catch (e) {
// запасной путь (старые браузеры): через <img>
return await new Promise(res => {
const img = new Image(), url = URL.createObjectURL(file);
img.onload = () => { const k = Math.min(1, max / Math.max(img.width, img.height)); const cv = document.createElement("canvas"); cv.width = img.width * k; cv.height = img.height * k; cv.getContext("2d").drawImage(img, 0, 0, cv.width, cv.height); cv.toBlob(b => res(b || file), "image/jpeg", q); URL.revokeObjectURL(url); };
img.onerror = () => res(file); img.src = url;
});
}
}
async function addPhoto(orderId, itemId, file) {
const blob = await downscale(file), key = uuid();
await DB.blobPut(key, blob);
await DB.update(orderId, o => o && ({ ...o, photos: [...o.photos, { key, item_id: itemId, pending: true, user_id: me && me.id, created_at: new Date().toISOString() }] }));
await countPending(); renderBar(); scheduleSync();
}
async function removePhoto(orderId, p) {
if (p.pending) {
await DB.update(orderId, o => o && ({ ...o, photos: o.photos.filter(x => x.key !== p.key) }));
await DB.blobDel(p.key); await countPending(); renderBar();
} else {
const dto = await api("DELETE", "/photos/" + p.server_id);
await mergeInto(dto);
}
}
async function submitOrder(o) {
const missing = o.items.filter(i => i.required && !itemDone(i)).length;
if (missing) return `Не заполнены обязательные пункты: ${missing}`;
if (o.items.some(i => i.photo === "required" && !o.photos.some(p => p.item_id === i.id))) return "Не приложены обязательные фото";
await DB.update(o.id, c => c && ({ ...c, pendingSubmit: true, syncError: null }));
await countPending(); renderBar(); scheduleSync(100);
return null;
}
async function addComment(orderId, text) {
await DB.update(orderId, o => o && ({ ...o, pendingComments: [...(o.pendingComments || []), { id: uuid(), text: text.trim(), created_at: new Date().toISOString(), user_name: me && (me.full_name || me.username) }] }));
await countPending(); renderBar(); scheduleSync(100);
}
// ---------- фото: показ ----------
const urlCache = new Map();
async function photoSrc(p) {
const key = p.pending ? p.key : "p" + p.server_id;
if (urlCache.has(key)) return urlCache.get(key);
let blob = await DB.blobGet(key);
if (!blob && !p.pending) {
try { const r = await authFetch(`${API}/photos/${p.server_id}`); if (r.ok) { blob = await r.blob(); await DB.blobPut(key, blob); } } catch (e) {}
}
if (!blob) return null;
const u = URL.createObjectURL(blob); urlCache.set(key, u); return u;
}
function viewer(src) {
const v = h("div", { class: "viewer", onclick: () => v.remove() }, h("img", { src }));
document.body.append(v);
}
// ---------- роутинг и отрисовка ----------
let route = { page: "list" }, filter = "active", query = "", editing = false, pendingRender = false;
function parseHash() {
const m = location.hash.match(/^#\/o\/(\d+)/);
if (m) return { page: "order", id: +m[1] };
if (location.hash === "#/s") return { page: "settings" };
return { page: "list" };
}
async function render() {
const my = ++renderSeq;
route = parseHash();
renderBar();
const y = window.scrollY, key = JSON.stringify(route), same = $view.dataset.key === key;
$view.dataset.key = key;
if (!me && !(await restoreMe())) { loginView(my); return; }
if (route.page === "order") await orderView(route.id, my);
else if (route.page === "settings") await settingsView(my);
else await listView(my);
if (same && my === renderSeq) window.scrollTo(0, y);
}
/** Перерисовка по событиям синхронизации: не трогаем экран, пока пользователь печатает. */
function softRender() {
if (!me) return;
if (editing) { pendingRender = true; return; }
render();
}
async function restoreMe() {
if (me) return true;
if (!hasSession()) return false;
me = await DB.kvGet("me") || null;
return !!me;
}
// ---------- боковое меню (вместо верхней шапки) ----------
const $drawer = document.getElementById("drawer");
function menu(open) { document.body.classList.toggle("menu-open", open); if (open) renderDrawer(); }
document.getElementById("burger").onclick = () => menu(!document.body.classList.contains("menu-open"));
document.getElementById("shade").onclick = () => menu(false);
function renderDrawer() {
if (!me) { $drawer.replaceChildren(); return; }
const go = href => () => { menu(false); location.hash = href; };
// разделы портала открываются в полной версии сайта (нужна связь); там тоже боковое меню
const web = (hash, ic, t) => h("a", { href: "/" + hash, onclick: () => localStorage.setItem("stay_desktop", "1") }, h("i", {}, ic), t);
const eng = me.is_admin || me.is_moderator || (me.position && me.position.kind === "engineer");
const store = me.is_admin || me.is_storekeeper;
$drawer.replaceChildren(h("div", { class: "dhead" }, h("b", {}, me.full_name || me.username),
h("div", { class: "small muted" }, [me.position && me.position.name, me.is_admin ? "админ" : me.is_moderator ? "модератор" : me.is_storekeeper ? "склад" : ""].filter(Boolean).join(" · ") || "—"),
h("div", { class: "small " + (sync.pending ? "warn" : sync.error ? "bad" : "muted") }, sync.pending ? `⟳ Не отправлено: ${sync.pending}` : sync.error || (sync.last ? "Обновлено " + new Date(sync.last).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }) : ""))),
h("nav", {},
h("div", { class: "navh" }, "Работает без связи"),
h("a", { href: "#", class: route.page === "list" ? "on" : "", onclick: e => { e.preventDefault(); go("")(); } }, h("i", {}, "📋"), "Наряды"),
h("a", { href: "#/s", class: route.page === "settings" ? "on" : "", onclick: e => { e.preventDefault(); go("#/s")(); } }, h("i", {}, "⚙"), "Настройки и синхронизация"),
h("div", { class: "navh" }, "Портал (нужна связь)"),
web("#/work", "🎯", "Задачи: всё в одном месте"), web("#/tasks", "🗓", "Задачи и поручения"), web("#/equipment", "🔧", "Оборудование и инциденты"), web("#/faults", "⚠", "Неисправности"), web("#/map", "🗺", "Карта завода"), me.crane_map ? web("#/cranes", "🏗", "Краны") : null, me.crane_map ? web("#/map/cranes", "🗺", "Карта кранов") : null,
web("#/my-tools", "🧰", "Мой инструмент"), web("#/requests", "🛒", "Заявки на инструмент"),
store || eng ? web("#/inventory", "🏬", "Склады и учёт") : null,
web("#/people", "👥", "Сотрудники"), web("#/user/" + me.id, "👤", "Моя карточка"),
eng ? web("#/reports", "📊", "Отчёты") : null,
h("a", { href: "#", class: "fbm", onclick: e => { e.preventDefault(); document.body.classList.remove("menu-open"); openFeedbackM(); } }, h("i", {}, "💬"), "Обратная связь по порталу")));
}
// Обратная связь по порталу: короткая форма, ответы видят администраторы портала
function openFeedbackM() {
if (document.querySelector(".fbmodal")) return;
let kind = "idea";
const KINDS = { idea: "💡 Идея", bug: "🐞 Ошибка", other: "💬 Другое" };
const kinds = h("div", { class: "filters" });
const draw = () => kinds.replaceChildren(...Object.entries(KINDS).map(([k, t]) => h("button", { class: "chip" + (kind === k ? " on" : ""), onclick: () => { kind = k; draw(); } }, t)));
draw();
const text = h("textarea", { rows: 5, maxlength: 4000, placeholder: "Что улучшить или что не работает?" });
const wrap = h("div", { class: "fbmodal" }, h("div", { class: "card" }, h("h2", {}, "Обратная связь"),
h("p", { class: "small muted" }, "Сообщение получат администраторы портала."), kinds, text,
h("div", { class: "row", style: "margin-top:10px" },
h("button", { onclick: async () => {
if (text.value.trim().length < 3) return toast("Опишите идею или проблему", true);
try { await api("POST", "/feedback", { kind, text: text.value, page: ("m" + (location.hash || "")).slice(0, 200), client: "m" }); wrap.remove(); toast("Спасибо! Отправлено"); }
catch (e) { toast(e.network ? "Нет связи — попробуйте позже" : e.message, true); }
} }, "Отправить"), h("button", { class: "sec", onclick: () => wrap.remove() }, "Отмена"))));
document.body.append(wrap);
}
function renderBar() {
if (document.body.classList.contains("menu-open")) renderDrawer();
const back = route.page !== "list";
let sub = "";
if (sync.running) sub = h("span", { class: "sub" }, "Синхронизация…");
else if (sync.pending) sub = h("span", { class: "sub warn" }, `⟳ Не отправлено: ${sync.pending}`);
else if (sync.error) sub = h("span", { class: "sub bad" }, sync.error);
else if (sync.last) sub = h("span", { class: "sub" }, "Обновлено " + new Date(sync.last).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }));
const title = route.page === "order" ? `Наряд №${route.id}` : route.page === "settings" ? "Настройки" : "Наряды";
setBar(
back ? h("button", { class: "ib", onclick: () => history.length > 1 ? history.back() : (location.hash = ""), "aria-label": "Назад" }, "←") : null,
h("h1", {}, title, sub),
me ? h("button", { class: "ib" + (sync.running ? " spin" : ""), onclick: () => syncAll().then(() => loadCurrent()), "aria-label": "Обновить" }, "⟳") : null);
document.body.classList.toggle("authed", !!me);
}
const loadCurrent = () => route.page === "order" ? loadDetails(route.id) : null;
const badges = o => h("span", { class: "row", style: "gap:6px" }, h("span", { class: "badge b-" + o.status }, STATUS[o.status]), o.is_overdue ? h("span", { class: "badge b-overdue" }, "просрочен") : null);
const progress = o => { const t = (o.items || []).length; return t ? [(o.items || []).filter(itemDone).length, t] : null; };
// ----- вход -----
function loginView(my) {
setBar(h("h1", {}, "Чек-листы"));
const u = h("input", { autocomplete: "username", autocapitalize: "none", autocorrect: "off", placeholder: "Логин" }), p = h("input", { type: "password", autocomplete: "current-password", placeholder: "Пароль" });
const err = h("p", { class: "bad" }), btn = h("button", { class: "block" }, "Войти");
const go = async () => {
btn.disabled = true; err.textContent = "";
try {
const r = await fetch(API + "/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: u.value.trim(), password: p.value, device: "web-mobile" }) });
if (!r.ok) { let d = ""; try { d = (await r.json()).detail; } catch (e) {} throw new Error(r.status === 401 ? "Неверный логин или пароль" : r.status === 429 ? "Слишком много попыток, подождите" : d || "Ошибка " + r.status); }
const t = await r.json(); token = t.access_token;
localStorage.setItem("token", token); localStorage.setItem("refresh", t.refresh_token);
const prev = await DB.kvGet("user_id"), m = await api("GET", "/me");
if (prev && prev !== m.id) await DB.wipe(); // другой пользователь — чужие данные не оставляем
await applyMe(m);
sync.error = null; location.hash = ""; await render(); syncAll();
} catch (e) { err.textContent = e instanceof TypeError ? "Нет связи с сервером" : e.message; btn.disabled = false; }
};
btn.onclick = go; p.addEventListener("keydown", e => e.key === "Enter" && go());
const apk = h("div", { style: "display:none;margin-top:18px" },
h("a", { class: "btn sec block", href: "/app.apk", download: "checklists.apk" }, "📱 Скачать приложение для Android"));
if (/Android/i.test(navigator.userAgent)) fetch("/app.apk", { method: "HEAD" }).then(r => { if (r.ok) apk.style.display = ""; }).catch(() => {});
const ios = /iPhone|iPad|iPod/i.test(navigator.userAgent) && !navigator.standalone;
commit(my, h("div", { class: "login stack" }, h("h1", {}, "🔧 Чек-листы"), h("p", { class: "muted" }, "Обходы и обслуживание оборудования"),
sync.error && hasSession() === false && sync.error.startsWith("Сессия") ? h("p", { class: "warn" }, sync.error) : null,
u, p, err, btn, apk,
ios ? h("p", { class: "small muted" }, "Чтобы установить: нажмите «Поделиться» → «На экран Домой».") : null,
h("p", { class: "small" }, h("a", { href: "/", onclick: () => localStorage.setItem("stay_desktop", "1") }, "Полная версия сайта"))));
}
// ----- список -----
async function listView(my) {
const orders = await DB.orders();
const q = query.trim().toLowerCase();
const mine = o => o.can_execute || (me && o.executor_id === me.id);
const eng = me && (me.is_admin || me.is_moderator || (me.position && me.position.kind === "engineer"));
const filters = [["active", "Активные", orders.filter(o => ACTIVE.includes(o.status)).length], ["mine", "Мои", orders.filter(o => ACTIVE.includes(o.status) && mine(o)).length]];
if (eng) filters.push(["review", "На проверку", orders.filter(o => o.can_review).length]);
filters.push(["done", "Завершённые", orders.filter(o => !ACTIVE.includes(o.status)).length]);
const shown = orders.filter(o => ({ active: ACTIVE.includes(o.status), mine: ACTIVE.includes(o.status) && mine(o), review: o.can_review, done: !ACTIVE.includes(o.status) })[filter]
&& (!q || (o.title || "").toLowerCase().includes(q) || (o.equipment_name || "").toLowerCase().includes(q) || (o.location_name || "").toLowerCase().includes(q) || q.replace("#", "") === String(o.id)))
.sort((a, b) => (a.due_at ? ms(a.due_at) : 9e15) - (b.due_at ? ms(b.due_at) : 9e15) || b.id - a.id);
const search = h("input", { type: "search", placeholder: "🔍 Название, оборудование, №", value: query, style: "margin-bottom:10px",
oninput: e => { query = e.target.value; clearTimeout(search.t); search.t = setTimeout(() => { const pos = e.target.selectionStart; render().then(() => { const s = $view.querySelector("input[type=search]"); if (s) { s.focus(); s.setSelectionRange(pos, pos); } }); }, 250); },
onfocus: () => editing = true, onblur: () => { editing = false; if (pendingRender) { pendingRender = false; softRender(); } } });
commit(my,
me && me.demo_mode ? h("div", { class: "notice row sp", style: "--c:var(--warn)" }, h("span", {}, "🎭 Демо-режим (только у вас): данные тестовые"),
h("button", { class: "sec", style: "width:auto;padding:6px 12px", onclick: leaveDemo }, "Выйти из демо")) : null,
search,
h("div", { class: "filters" }, ...filters.map(([k, l, n]) => h("button", { class: "chip" + (filter === k ? " on" : ""), onclick: () => { filter = k; render(); } }, `${l} · ${n}`))),
shown.length ? shown.map(o => {
const pr = progress(o);
return h("a", { href: "#/o/" + o.id, style: "text-decoration:none;color:inherit;display:block" }, h("div", { class: "card tap" },
h("div", { class: "row sp", style: "align-items:flex-start;flex-wrap:nowrap" }, h("div", { class: "title", style: "min-width:0" }, o.title), h("span", { class: "muted small", style: "flex:none" }, "№" + o.id)),
[o.equipment_name, o.location_name].filter(Boolean).length ? h("div", { class: "muted small", style: "margin:2px 0 8px" }, [o.equipment_name, o.location_name].filter(Boolean).join(" · ")) : null,
h("div", { class: "row" }, badges(o), hasUnsynced(o) ? h("span", { class: "small warn" }, "⟳ не отправлено") : null, o.comments_count ? h("span", { class: "small muted" }, "💬 " + o.comments_count) : null),
h("div", { class: "row small", style: "margin-top:8px" }, h("span", { class: o.is_overdue ? "bad" : "muted" }, "Срок: " + fmt(o.due_at)), h("span", { class: "muted" }, o.executor_name || o.assignee_name || "")),
pr ? h("div", { class: "row", style: "margin-top:8px" }, h("div", { class: "bar" }, h("i", { style: `width:${100 * pr[0] / pr[1]}%` })), h("span", { class: "small" }, pr.join("/"))) : null,
o.can_execute && ACTIVE.includes(o.status) ? h("div", { class: "small", style: "color:var(--accent);margin-top:6px;font-weight:600" }, "▶ можно выполнять") : null,
o.can_review ? h("div", { class: "small", style: "color:#9333ea;margin-top:6px;font-weight:600" }, "✔ ждёт вашей проверки") : null));
}) : h("div", { class: "card muted" }, q ? "Ничего не найдено" : orders.length || sync.last ? "Нарядов нет" : "Загрузка…"));
}
// ----- наряд -----
async function orderView(id, my) {
const o = await DB.order(id);
if (!o) { commit(my, h("div", { class: "card muted" }, sync.running ? "Загрузка…" : "Наряд не найден на этом устройстве")); if (!viewLoaded[id]) { viewLoaded[id] = true; loadDetails(id); } return; }
if (!viewLoaded[id]) { viewLoaded[id] = true; loadDetails(id); }
const editable = o.can_execute && ["new", "in_progress"].includes(o.status) && !o.pendingSubmit;
const busy = { v: false };
const run = fn => guard(async () => { if (busy.v) return; busy.v = true; try { await fn(); } finally { busy.v = false; } });
const kv = [["Оборудование", o.equipment_name], ["Место", o.location_name], ["Назначен", o.assignee_name], ["Выполняет", o.executor_name], ["Проверяющий", o.reviewer_name],
["Срок", fmtFull(o.due_at)], ["Начат", o.started_at && fmtFull(o.started_at)], ["Сдан", o.submitted_at && fmtFull(o.submitted_at)], ["Завершён", o.completed_at && fmtFull(o.completed_at)]].filter(([, v]) => v && v !== "—");
const focusTrack = el => { el.addEventListener("focus", () => editing = true); el.addEventListener("blur", () => { editing = false; if (pendingRender) { pendingRender = false; setTimeout(softRender, 50); } }); return el; };
const itemBlock = (it, n) => {
let ctl;
const save = v => answer(id, it.id, v).catch(e => toast(e.message, true));
if (it.answer_type === "status") {
ctl = h("div", { class: "chips" }, ...[[null, "Не начато"], ["in_progress", "В работе"], ["done", "Выполнено"]].map(([v, l]) => h("button", { class: "chip" + ((it.value || null) === v ? " on" : ""), disabled: !editable, onclick: () => save(v) }, l)));
} else if (it.answer_type === "choice") {
ctl = h("div", { class: "chips" }, ...(it.options || []).map(op => h("button", { class: "chip" + (it.value === op ? " on" : ""), disabled: !editable, onclick: () => save(it.value === op ? null : op) }, op)));
} else {
const num = it.answer_type === "number";
const el = h(num ? "input" : "textarea", { value: it.value || "", disabled: !editable, ...(num ? { inputmode: "decimal", type: "text", placeholder: it.unit || "" } : { placeholder: "Текст…" }) });
if (!num) el.textContent = it.value || "";
let t; const commit = () => { const v = el.value.trim() || null; if (v !== (it.value || null)) save(v); };
el.addEventListener("input", () => { clearTimeout(t); t = setTimeout(commit, 700); });
el.addEventListener("blur", () => { clearTimeout(t); commit(); });
focusTrack(el);
ctl = h("div", {}, num ? h("div", { class: "row" }, h("div", { style: "flex:1" }, el), it.unit ? h("span", { class: "muted" }, it.unit) : null) : el);
}
const range = it.answer_type === "number" && (it.min_value != null || it.max_value != null) ? `норма: ${it.min_value ?? "…"} – ${it.max_value ?? "…"} ${it.unit || ""}` : "";
const ph = o.photos.filter(p => p.item_id === it.id);
let photoRow = null;
if (it.photo && it.photo !== "none") {
const cam = h("input", { type: "file", accept: "image/*", capture: "environment", style: "display:none" });
const gal = h("input", { type: "file", accept: "image/*", style: "display:none" });
const onFile = inp => async () => { const f = inp.files[0]; if (!f) return; toast("Обработка фото…"); try { await addPhoto(id, it.id, f); softRender(); } catch (e) { toast("Не удалось сохранить фото: " + e.message, true); } };
cam.onchange = onFile(cam); gal.onchange = onFile(gal);
photoRow = h("div", {}, h("div", { class: "photos" },
ph.map(p => { const img = h("img", { alt: "фото" }); photoSrc(p).then(u => { if (u) { img.src = u; img.onclick = () => viewer(u); } else img.alt = "нет в кэше"; });
const canDel = editable && (p.pending || (me && (p.user_id === me.id || me.is_admin || me.is_moderator)));
return h("div", { class: "ph" }, img, p.pending ? h("span", { class: "p" }, "⟳") : null, canDel ? h("button", { class: "x", onclick: run(async () => { if (confirm("Удалить фото?")) { await removePhoto(id, p); softRender(); } }) }, "✕") : null); }),
editable ? [cam, gal, h("button", { class: "sec", onclick: () => cam.click() }, "📷 Снять"), h("button", { class: "sec", onclick: () => gal.click() }, "🖼")] : null),
it.photo === "required" && !ph.length ? h("div", { class: "small bad", style: "margin-top:6px" }, "Требуется фото") : null);
}
return h("div", { class: "item" + (it.out_of_range ? " oor" : "") },
h("div", { class: "q" }, `${n}. ${it.text}`, it.required ? "" : h("span", { class: "muted small" }, " (необязательно)")),
ctl, range ? h("div", { class: "small muted", style: "margin-top:4px" }, range) : null,
it.out_of_range ? h("div", { class: "small bad", style: "margin-top:4px" }, "⚠ Значение вне допустимого диапазона") : null, photoRow);
};
const done = o.items.filter(itemDone).length;
const cmText = h("textarea", { placeholder: "Написать комментарий…" }); focusTrack(cmText);
const revText = h("textarea", { placeholder: "Комментарий (обязателен при возврате)" }); focusTrack(revText);
const allComments = [...(o.comments || []), ...(o.pendingComments || []).map(c => ({ ...c, pending: true }))];
commit(my,
h("div", { class: "card" }, h("div", { class: "title", style: "margin-bottom:8px" }, o.title), badges(o), h("div", { class: "kv", style: "margin-top:10px" }, kv.flatMap(([k, v]) => [h("span", {}, k), h("span", {}, v)]))),
o.review_comment && o.status === "in_progress" ? h("div", { class: "notice", style: "--c:var(--bad)" }, "Возвращён на доработку: " + o.review_comment) : null,
o.syncError ? h("div", { class: "notice", style: "--c:var(--bad)" }, "⚠ " + o.syncError) : null,
o.pendingSubmit ? h("div", { class: "notice", style: "--c:#9333ea" }, "⟳ Наряд будет сдан при появлении связи. Данные сохранены на телефоне.") : null,
o.items.length ? h("div", { class: "card" }, h("h2", {}, `Чек-лист (${done}/${o.items.length})`), o.items.map((it, i) => itemBlock(it, i + 1)))
: h("div", { class: "card muted" }, o.hasItems ? "В чек-листе нет задач" : "Загрузка чек-листа… (нужна связь)"),
o.can_review ? h("div", { class: "card" }, h("h2", {}, "Проверка"), revText, h("div", { class: "row", style: "margin-top:10px" },
h("button", { style: "flex:1", onclick: run(async () => { await mergeInto(await api("POST", `/orders/${id}/review`, { approve: true, comment: revText.value })); toast("Принято"); location.hash = ""; }) }, "Принять"),
h("button", { class: "bad", style: "flex:1", onclick: run(async () => { if (!revText.value.trim()) { toast("Укажите причину возврата", true); return; } await mergeInto(await api("POST", `/orders/${id}/review`, { approve: false, comment: revText.value })); toast("Возвращён"); location.hash = ""; }) }, "Вернуть"))) : null,
h("div", { class: "card" }, h("h2", {}, `Комментарии (${allComments.length})`),
allComments.map(c => h("div", { class: "cm" }, h("div", { class: "small muted" }, `${c.user_name || "—"} · ${c.pending ? "⟳ не отправлен" : fmtFull(c.created_at)}`), h("div", { style: "white-space:pre-wrap" }, c.text))),
h("div", { style: "margin-top:10px" }, cmText), h("div", { style: "margin-top:8px" }, h("button", { onclick: guard(async () => { if (!cmText.value.trim()) return; await addComment(id, cmText.value); cmText.value = ""; softRender(); }) }, "Отправить"))),
editable ? h("div", { class: "actions" },
o.status === "new" && !o.executor_id ? h("button", { class: "sec", style: "background:var(--card)", onclick: run(async () => { await mergeInto(await api("POST", `/orders/${id}/start`)); softRender(); }) }, "Взять в работу") : null,
h("button", { disabled: !o.items.length, onclick: run(async () => { const err = await submitOrder(o); if (err) toast(err, true); else { toast("Наряд сдан"); softRender(); } }) }, "Сдать наряд")) : null);
}
const viewLoaded = {};
// ----- настройки -----
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() { const s = await pushSub(); if (!s) return; try { await api("POST", "/push/unsubscribe", s.toJSON()); } catch (e) {} await s.unsubscribe(); }
async function settingsView(my) {
const on = pushOK() && !!(await pushSub().catch(() => null)) && Notification.permission === "granted";
const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone;
const ios = /iPhone|iPad|iPod/i.test(navigator.userAgent);
commit(my,
h("div", { class: "card" }, h("div", { class: "title" }, me.full_name || me.username),
me.position ? h("div", { class: "muted" }, me.position.name + (me.on_shift === true ? " · на смене" : me.on_shift === false ? " · не на смене" : "")) : null,
me.is_admin ? h("div", { class: "muted" }, "Администратор") : me.is_moderator ? h("div", { class: "muted" }, "Модератор") : null),
h("div", { class: "card stack" }, h("div", { class: "muted small" }, "Последняя синхронизация: " + (sync.last ? fmtFull(sync.last) : "ещё не было")),
sync.error ? h("div", { class: "bad small" }, sync.error) : null,
sync.pending ? h("div", { class: "warn small" }, `Не отправлено нарядов: ${sync.pending}`) : null,
h("button", { class: "sec block", onclick: guard(async () => { toast("Синхронизация…"); await syncAll(); renderBar(); }) }, "Синхронизировать сейчас")),
pushOK() ? h("div", { class: "card stack" }, h("div", {}, "Уведомления о новых нарядах: ", h("b", {}, on ? "включены" : "выключены")),
h("button", { class: "sec block", onclick: guard(async () => { if (on) await pushOff(); else await pushOn(); render(); }) }, on ? "Выключить" : "Включить"),
ios && !standalone ? h("div", { class: "small muted" }, "На iPhone уведомления работают только после установки: «Поделиться» → «На экран Домой».") : null) : null,
!standalone ? h("div", { class: "card small muted" }, "Установите как приложение: меню браузера → «Установить приложение» / «Добавить на главный экран».") : null,
h("div", { class: "card stack" }, h("a", { class: "btn sec block", href: "/", onclick: () => localStorage.setItem("stay_desktop", "1") }, "Полная версия сайта"),
h("button", { class: "bad block", onclick: guard(async () => {
const orders = await DB.orders(), n = orders.filter(hasUnsynced).length;
if (!confirm(n ? `Не отправлено нарядов: ${n}. При выходе эти данные будут потеряны. Выйти?` : "Выйти из аккаунта? Локальные данные будут удалены.")) return;
const rt = localStorage.getItem("refresh");
if (rt && navigator.onLine) fetch(API + "/auth/logout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: rt }) }).catch(() => {});
try { await pushOff(); } catch (e) {}
localStorage.removeItem("token"); localStorage.removeItem("refresh"); token = null; me = null; await DB.wipe(); location.hash = ""; render();
}) }, "Выйти")));
}
// ---------- запуск ----------
window.addEventListener("hashchange", () => { menu(false); render(); window.scrollTo(0, 0); });
window.addEventListener("online", () => syncAll());
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && me) syncAll(); });
setInterval(() => { if (document.visibilityState === "visible" && me && !editing) syncAll(); }, 30000);
(async () => {
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {});
sync.last = (await DB.kvGet("last")) || null;
await countPending();
await restoreMe();
await render();
if (me) syncAll();
})();
+17
View File
@@ -0,0 +1,17 @@
{
"name": "Чек-листы ремонтной службы",
"short_name": "Чек-листы",
"description": "Обходы и обслуживание оборудования",
"start_url": "/m",
"scope": "/m",
"display": "standalone",
"orientation": "portrait",
"background_color": "#12151c",
"theme_color": "#2563eb",
"lang": "ru",
"icons": [
{ "src": "/static/m/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/m/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/static/m/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+846
View File
@@ -0,0 +1,846 @@
"use strict";
// ---------- Портал: инструмент и склады, заявки, инциденты, суточные задания, карта, сотрудники ----------
const isStore = () => me && (me.is_admin || me.is_storekeeper);
const canStock = () => isStore() || isEng();
const TASK_ST = { assigned: "Назначен", in_progress: "В работе", paused: "Приостановлен", done: "Выполнен", cancelled: "Отменён" };
const TASK_NEXT = { assigned: ["in_progress", "paused", "done", "cancelled"], in_progress: ["paused", "done", "cancelled"],
paused: ["in_progress", "done", "cancelled"], done: ["assigned"], cancelled: ["assigned"] };
const TASK_NEED = ["paused", "done", "cancelled"];
const TASK_BTN = { in_progress: "▶ В работу", paused: "⏸ Приостановить", done: "✔ Выполнено", cancelled: "✕ Отменить", assigned: "↺ Вернуть в работу" };
const REQ_ST = { new: "Новая", approved: "Одобрена", ordered: "Заказано", done: "Получено", rejected: "Отклонена" };
const EQ_STATE = { alarm: "Отказ", fault: "Неисправность", due: "Требуется ТО", soon: "Скоро ТО", ok: "В норме" };
const IS_FAULT = i => i.kind === "fault";
const todayISO = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 10); };
const num = v => (Math.round(v * 1000) / 1000).toLocaleString("ru-RU");
const tbl = (heads, rows) => h("div", { class: "card tablewrap" }, h("table", {}, h("thead", {}, h("tr", {}, ...heads.map(t => h("th", {}, t)))), h("tbody", {}, ...rows)));
const fill = (el, ...k) => el.replaceChildren(...k.flat(9).filter(x => x != null && x !== false));
const empty = text => h("div", { class: "card muted" }, text);
const field = (label, ctl, hint) => h("div", {}, h("label", {}, label), ctl, hint ? h("div", { class: "small muted" }, hint) : null);
function tabs(list, cur) {
return h("div", { class: "tabs" }, ...list.map(([href, t]) => h("a", { href, class: cur === href ? "on" : "" }, t)));
}
// картинки, требующие авторизации (фото сотрудников, своя иконка, карта) — грузим blob-ом и кэшируем
const imgCache = {};
// ---------- масштабирование карт ----------
// Кнопки «+ / − / ⤢», Ctrl + колесо мыши, двойной щелчок, щипок двумя пальцами; при увеличении карту можно двигать
// перетаскиванием (мышью) или одним пальцем. key — запомнить масштаб и положение (карта перерисовывается раз в минуту).
const ZOOM_MEM = {};
function zoomable(stage, opts = {}) {
const wrap = h("div", { class: "mapwrap" + (opts.cls ? " " + opts.cls : "") }, stage);
const label = h("span", { class: "zl" }, "100%");
const btn = (t, title, fn) => h("button", { type: "button", class: "sec sm", title, onclick: e => { e.stopPropagation(); fn(); } }, t);
const box = h("div", { class: "mapbox" }, wrap, h("div", { class: "mapzoom" },
btn("+", "Увеличить", () => zoomTo(z * 1.5)), btn("−", "Уменьшить", () => zoomTo(z / 1.5)), btn("⤢", "Весь план", () => zoomTo(1)), label));
const MAX = opts.max || 6;
let z = 1;
const busy = () => (opts.isEditing ? opts.isEditing() : false);
const remember = () => {
if (!opts.key) return;
const r = wrap.getBoundingClientRect();
ZOOM_MEM[opts.key] = { z, fx: (wrap.scrollLeft + r.width / 2) / (stage.offsetWidth || 1), fy: (wrap.scrollTop + r.height / 2) / (stage.offsetHeight || 1) };
};
// cx, cy — точка в окне карты, которая остаётся на месте
function zoomTo(nz, cx, cy) {
nz = Math.min(MAX, Math.max(1, nz));
const r = wrap.getBoundingClientRect();
cx = cx ?? r.width / 2; cy = cy ?? r.height / 2;
const w0 = stage.offsetWidth || 1, h0 = stage.offsetHeight || 1;
const fx = (wrap.scrollLeft + cx) / w0, fy = (wrap.scrollTop + cy) / h0;
z = nz;
stage.style.width = z === 1 ? "" : (z * 100) + "%";
box.classList.toggle("zoomed", z > 1);
label.textContent = Math.round(z * 100) + "%";
wrap.scrollLeft = fx * stage.offsetWidth - cx;
wrap.scrollTop = fy * stage.offsetHeight - cy;
remember();
}
const local = ev => { const r = wrap.getBoundingClientRect(); return [ev.clientX - r.left, ev.clientY - r.top]; };
wrap.addEventListener("wheel", ev => {
if (!ev.ctrlKey) return; // без Ctrl колесо прокручивает страницу
ev.preventDefault();
const [x, y] = local(ev);
zoomTo(z * Math.exp(-ev.deltaY * 0.0025), x, y);
}, { passive: false });
wrap.addEventListener("dblclick", ev => {
if (busy() || ev.target.closest(".mk, .zone")) return;
const [x, y] = local(ev);
zoomTo(z < MAX ? z * 2 : 1, x, y);
});
// щипок двумя пальцами
let pinch = null;
const dist = t => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
wrap.addEventListener("touchstart", ev => { if (ev.touches.length === 2) { pinch = { d: dist(ev.touches), z }; ev.preventDefault(); } }, { passive: false });
wrap.addEventListener("touchmove", ev => {
if (!pinch || ev.touches.length !== 2) return;
ev.preventDefault();
const r = wrap.getBoundingClientRect();
const cx = (ev.touches[0].clientX + ev.touches[1].clientX) / 2 - r.left, cy = (ev.touches[0].clientY + ev.touches[1].clientY) / 2 - r.top;
zoomTo(pinch.z * dist(ev.touches) / pinch.d, cx, cy);
}, { passive: false });
wrap.addEventListener("touchend", ev => { if (ev.touches.length < 2) pinch = null; });
// перетаскивание мышью, когда карта увеличена
wrap.addEventListener("pointerdown", ev => {
if (z === 1 || ev.pointerType === "touch" || ev.button !== 0 || busy() || ev.target.closest(".mk, .zone, button, a")) return;
const sx = ev.clientX, sy = ev.clientY, l0 = wrap.scrollLeft, t0 = wrap.scrollTop;
let moved = false;
const move = mv => { moved = true; wrap.scrollLeft = l0 - (mv.clientX - sx); wrap.scrollTop = t0 - (mv.clientY - sy); };
const up = () => {
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); box.classList.remove("panning");
if (moved) wrap.addEventListener("click", e => e.stopPropagation(), { capture: true, once: true });
};
box.classList.add("panning");
window.addEventListener("pointermove", move); window.addEventListener("pointerup", up);
});
wrap.addEventListener("scroll", () => { if (z > 1) remember(); });
// восстановить масштаб после перерисовки (когда картинка загрузится и станут известны размеры)
const mem = opts.key && ZOOM_MEM[opts.key];
if (mem && mem.z > 1) {
const restore = () => requestAnimationFrame(() => {
z = mem.z; stage.style.width = (z * 100) + "%"; box.classList.add("zoomed"); label.textContent = Math.round(z * 100) + "%";
const r = wrap.getBoundingClientRect();
wrap.scrollLeft = mem.fx * stage.offsetWidth - r.width / 2; wrap.scrollTop = mem.fy * stage.offsetHeight - r.height / 2;
});
const img = stage.querySelector("img");
if (img && !img.complete) img.addEventListener("load", restore, { once: true }); else restore();
}
return box;
}
function authImg(path, attrs = {}) {
const img = h("img", { alt: "", ...attrs });
(imgCache[path] ||= authFetch(API + path).then(r => r.ok ? r.blob() : Promise.reject(new Error("нет картинки"))).then(b => URL.createObjectURL(b)))
.then(u => img.src = u).catch(() => { delete imgCache[path]; img.classList.add("noimg"); });
return img;
}
function eqIcon(e, cls = "eqicon") {
if (e.icon?.startsWith("builtin:")) return h("img", { class: cls, alt: "", src: `/static/icons/${e.icon.slice(8)}.svg` });
if (e.icon?.startsWith("file:")) return authImg(`/equipment/${e.id}/icon?v=${e.icon.slice(5, 13)}`, { class: cls });
return h("span", { class: cls + " noicon" }, "⚙");
}
function avatar(u, cls = "ava") {
if (u.has_photo) return authImg(`/users/${u.id}/photo?v=${u.id}-${avatar.v[u.id] || 0}`, { class: cls });
const ini = (u.full_name || u.username || "?").split(/\s+/).map(s => s[0]).slice(0, 2).join("").toUpperCase();
return h("span", { class: cls + " ini" }, ini);
}
avatar.v = {};
async function sendFile(method, path, file, max = 1600) {
const blob = file.type === "image/png" && max >= 3000 ? file : await downscale(file, max);
const r = await authFetch(API + path, { method, headers: { "Content-Type": blob.type || "image/jpeg" }, body: blob });
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
return r.status === 204 ? null : r.json();
}
function filePicker(onfile, label = "📷 Загрузить фото", cls = "sec sm") {
const inp = h("input", { type: "file", accept: "image/*", style: "display:none", onchange: guard(async e => { const f = e.target.files[0]; if (f) await onfile(f); }) });
return h("span", {}, inp, h("button", { class: cls, onclick: () => inp.click() }, label));
}
// ======================= Инструмент и склады =======================
async function inventoryPage(tab) {
if (!canStock()) return myToolsPage();
tab = tab || "stock";
const [items, whs, users] = await Promise.all([api("GET", "/inventory/items"), api("GET", "/warehouses"), ref("users", true)]);
const box = h("div"), formBox = h("div");
const activeItems = items.filter(i => i.is_active), activeWh = whs.filter(w => w.is_active), activeUsers = users.filter(u => u.is_active);
const reload = () => inventoryPage(tab);
const opt = (list, name, cur) => [h("option", { value: "" }, "—"), ...list.map(x => h("option", { value: x.id, selected: String(cur) === String(x.id) }, name(x)))];
function opForm(kind, preset = {}) {
const s = { item_id: preset.item_id || "", qty: preset.qty || 1, from_wh: preset.from_wh || "", from_user: preset.from_user || "", to_wh: preset.to_wh || "", to_user: preset.to_user || "", note: "" };
const sel = (k, list, name) => h("select", { onchange: e => s[k] = e.target.value }, ...opt(list, name, s[k]));
const whSel = k => sel(k, activeWh, w => w.name), userSel = k => searchSelect(sel(k, activeUsers, u => u.full_name || u.username));
const itemSel = sel("item_id", activeItems, i => i.name + (i.inventory_no ? ` (${i.inventory_no})` : ""));
const T = { receive: "Поступление на склад", issue: "Выдача сотруднику", return: "Возврат от сотрудника", transfer: "Перемещение между складами", writeoff: "Списание" };
const wo = h("select", { onchange: e => { s.wo = e.target.value; draw(); } }, h("option", { value: "wh" }, "со склада"), h("option", { value: "user", selected: s.wo === "user" }, "у сотрудника"));
const body = h("div", { class: "grid" });
function draw() {
const parts = [field("Позиция *", itemSel), field("Количество *", h("input", { type: "number", min: "0", step: "any", value: s.qty, onchange: e => s.qty = +e.target.value }))];
if (kind === "receive") parts.push(field("На склад *", whSel("to_wh")));
if (kind === "issue") parts.push(field("Со склада *", whSel("from_wh")), field("Сотруднику *", userSel("to_user")));
if (kind === "return") parts.push(field("От сотрудника *", userSel("from_user")), field("На склад *", whSel("to_wh")));
if (kind === "transfer") parts.push(field("Со склада *", whSel("from_wh")), field("На склад *", whSel("to_wh")));
if (kind === "writeoff") parts.push(field("Откуда", wo), s.wo === "user" ? field("Сотрудник *", userSel("from_user")) : field("Склад *", whSel("from_wh")));
parts.push(field("Комментарий", h("input", { value: s.note, onchange: e => s.note = e.target.value })));
body.replaceChildren(...parts);
}
draw();
const place = (w, u) => w ? { warehouse_id: +w } : u ? { user_id: +u } : null;
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, T[kind]), body,
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
if (!s.item_id) throw new Error("Выберите позицию");
const req = { item_id: +s.item_id, qty: s.qty, note: s.note };
if (kind === "receive") Object.assign(req, { kind: "receive", to: place(s.to_wh) });
else if (kind === "writeoff") Object.assign(req, { kind: "writeoff", from: s.wo === "user" ? place(null, s.from_user) : place(s.from_wh) });
else Object.assign(req, { kind: "move", from: place(s.from_wh, s.from_user), to: place(s.to_wh, s.to_user) });
await api("POST", "/inventory/moves", req);
toast("Готово"); reload();
}) }, "Провести"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
formBox.scrollIntoView({ behavior: "smooth" });
}
function itemForm(it) {
const f = formFields([{ k: "name", label: "Название", type: "text", req: 1 }, { k: "category", label: "Категория", type: "text", hint: "Например: электроинструмент, измерительный, расходники" },
{ k: "unit", label: "Единица", type: "text" }, { k: "inventory_no", label: "Инвентарный номер", type: "text", hint: "Для штучного инструмента" },
{ k: "notes", label: "Описание", type: "textarea" }, { k: "is_active", label: "Используется", type: "bool" }], it || { unit: "шт", is_active: true });
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, it ? "Позиция каталога" : "Новая позиция каталога"), f.el,
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
const b = { name: f.vals.name, category: f.vals.category || "", unit: f.vals.unit || "шт", inventory_no: f.vals.inventory_no || "", notes: f.vals.notes || "", is_active: !!f.vals.is_active };
await api(it ? "PATCH" : "POST", "/inventory/items" + (it ? "/" + it.id : ""), b); toast("Сохранено"); reload();
}) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
formBox.scrollIntoView({ behavior: "smooth" });
}
function whForm(w) {
const f = formFields([{ k: "name", label: "Название склада", type: "text", req: 1 }, { k: "notes", label: "Примечание", type: "textarea" }, { k: "is_active", label: "Работает", type: "bool" }], w || { is_active: true });
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, w ? "Склад" : "Новый склад"), f.el,
h("p", { class: "small muted" }, "Зону склада на карте завода отмечают на вкладке «Карта складов»."),
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
await api(w ? "PATCH" : "POST", "/warehouses" + (w ? "/" + w.id : ""), { name: f.vals.name, notes: f.vals.notes || "", is_active: !!f.vals.is_active }); toast("Сохранено"); reload();
}) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
formBox.scrollIntoView({ behavior: "smooth" });
}
const ops = isStore() ? h("div", { class: "row", style: "margin-bottom:12px" },
h("button", { onclick: () => opForm("issue") }, "→ Выдать"), h("button", { class: "sec", onclick: () => opForm("return") }, "← Принять возврат"),
h("button", { class: "sec", onclick: () => opForm("receive") }, "+ Поступление"), h("button", { class: "sec", onclick: () => opForm("transfer") }, "⇄ Переместить"),
h("button", { class: "sec", onclick: () => opForm("writeoff") }, "Списать")) : h("p", { class: "small muted" }, "Просмотр. Выдачу и учёт ведёт администратор склада.");
if (tab === "stock") {
const wid = +(new URLSearchParams(location.hash.split("?")[1] || "").get("wh") || 0);
const list = wid ? activeWh.filter(w => w.id === wid) : activeWh;
const stocks = await Promise.all(list.map(w => api("GET", "/inventory/stock?warehouse_id=" + w.id)));
fill(box,
wid ? h("p", {}, h("a", { href: "#/inventory/stock" }, "← Все склады")) : null,
isStore() ? h("div", { class: "row", style: "margin-bottom:10px" }, h("button", { class: "sec sm", onclick: () => whForm(null) }, "+ Склад")) : null,
...(list.length ? list.map((w, i) => h("div", { class: "card" },
h("div", { class: "row" }, h("h2", { style: "margin:0;flex:1" }, "🏬 " + w.name), h("span", { class: "muted small" }, `${w.positions} поз.`),
isStore() ? h("button", { class: "sec sm", onclick: () => whForm(w) }, "Изменить") : null,
isStore() ? h("button", { class: "sec sm", onclick: guard(async () => { if (confirm(`Удалить склад «${w.name}»?`)) { await api("DELETE", "/warehouses/" + w.id); reload(); } }) }, "✕") : null),
w.notes ? h("div", { class: "small muted" }, w.notes) : null,
stocks[i].length ? h("table", { style: "margin-top:8px" }, h("tbody", {}, ...stocks[i].map(s => h("tr", {},
h("td", {}, s.item_name, s.inventory_no ? h("span", { class: "muted small" }, ` (${s.inventory_no})`) : null, h("div", { class: "small muted" }, s.category)),
h("td", { style: "white-space:nowrap" }, `${num(s.qty)} ${s.unit}`),
h("td", { class: "row end" }, isStore() ? h("button", { class: "sec sm", onclick: () => opForm("issue", { item_id: s.item_id, from_wh: w.id }) }, "Выдать") : null))))) :
h("p", { class: "muted small" }, "Склад пуст"))) : [empty("Складов пока нет")]));
} else if (tab === "items") {
fill(box, isStore() ? h("div", { class: "row", style: "margin-bottom:10px" }, h("button", { class: "sec sm", onclick: () => itemForm(null) }, "+ Позиция каталога")) : null,
items.length ? tbl(["Позиция", "Категория", "Инв. №", "На складах", "Выдано", ""], items.map(i => h("tr", { style: i.is_active ? "" : "opacity:.55" },
h("td", {}, i.name, i.notes ? h("div", { class: "small muted" }, i.notes) : null), h("td", {}, i.category || "—"), h("td", {}, i.inventory_no || "—"),
h("td", {}, `${num(i.in_stock)} ${i.unit}`), h("td", {}, i.issued ? `${num(i.issued)} ${i.unit}` : "—"),
h("td", { class: "row end" }, isStore() ? [h("button", { class: "sec sm", onclick: () => opForm("receive", { item_id: i.id }) }, "+ Поступление"),
h("button", { class: "sec sm", onclick: () => itemForm(i) }, "Изменить"),
h("button", { class: "sec sm", onclick: guard(async () => { if (confirm(`Удалить «${i.name}»?`)) { await api("DELETE", "/inventory/items/" + i.id); reload(); } }) }, "✕")] : null)))) : empty("Каталог пуст"));
} else if (tab === "issued") {
const all = await api("GET", "/inventory/stock");
const issued = all.filter(s => s.user_id);
const by = {};
for (const s of issued) (by[s.user_id] ||= []).push(s);
fill(box, ...(Object.keys(by).length ? Object.entries(by).map(([uid, list]) => h("div", { class: "card" },
h("div", { class: "row" }, h("a", { href: "#/user/" + uid, style: "flex:1;font-weight:700" }, "👤 " + list[0].user_name)),
h("table", { style: "margin-top:6px" }, h("tbody", {}, ...list.map(s => h("tr", {},
h("td", {}, s.item_name, s.inventory_no ? h("span", { class: "muted small" }, ` (${s.inventory_no})`) : null),
h("td", {}, `${num(s.qty)} ${s.unit}`), h("td", { class: "small muted" }, "с " + fmt(s.since)),
h("td", { class: "row end" }, isStore() ? h("button", { class: "sec sm", onclick: () => opForm("return", { item_id: s.item_id, from_user: s.user_id, qty: s.qty }) }, "Вернуть на склад") : null))))))) : [empty("Сотрудникам ничего не выдано")]));
} else {
const moves = await api("GET", "/inventory/moves?limit=300");
const K = { receive: "Поступление", issue: "Выдача", return: "Возврат", transfer: "Перемещение", writeoff: "Списание" };
fill(box, moves.length ? tbl(["Когда", "Операция", "Позиция", "Кол-во", "Откуда", "Куда", "Кто", "Комментарий"], moves.map(m => h("tr", {},
h("td", {}, fmt(m.at)), h("td", {}, K[m.kind] || m.kind), h("td", {}, m.item_name), h("td", {}, `${num(m.qty)} ${m.unit}`),
h("td", {}, m.from_text), h("td", {}, m.to_text), h("td", {}, m.by_name || "—"), h("td", { class: "small" }, m.note || "")))) : empty("Движений пока нет"));
}
fill($app, h("h1", {}, "Инструмент и склады"),
tabs([["#/inventory/stock", "Склады"], ["#/inventory/items", "Каталог"], ["#/inventory/issued", "Выдано сотрудникам"], ["#/inventory/moves", "Журнал"], ["#/map/warehouses", "Карта складов"]], "#/inventory/" + tab),
ops, formBox, box);
}
async function myToolsPage() {
const list = await api("GET", "/me/inventory");
fill($app, h("h1", {}, "Мой инструмент"),
h("p", { class: "small muted" }, "Инструмент и инвентарь, выданные лично вам. Чего-то не хватает — оформите ", h("a", { href: "#/requests" }, "заявку"), "."),
list.length ? tbl(["Позиция", "Категория", "Инв. №", "Количество", "Выдано"], list.map(s => h("tr", {},
h("td", {}, s.item_name), h("td", {}, s.category || "—"), h("td", {}, s.inventory_no || "—"), h("td", {}, `${num(s.qty)} ${s.unit}`), h("td", {}, fmt(s.since))))) :
empty("За вами ничего не числится"));
}
// ======================= Заявки на инструмент =======================
async function requestsPage() {
const handler = isStore() || isMgr();
const st = { what: "", why: "", link: "", qty: 1 };
const list = await api("GET", "/tool-requests");
const form = h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Новая заявка"),
h("div", { class: "grid" },
field("Что купить *", h("input", { placeholder: "Например: мультиметр Fluke 117", onchange: e => st.what = e.target.value })),
field("Количество", h("input", { type: "number", min: "1", step: "any", value: 1, onchange: e => st.qty = +e.target.value })),
field("Ссылка на товар", h("input", { type: "url", placeholder: "https://…", onchange: e => st.link = e.target.value }), "Необязательно: страница конкретного товара в магазине")),
h("div", { style: "margin-top:8px" }, field("Зачем нужно *", h("textarea", { placeholder: "Для чего, почему не хватает имеющегося", onchange: e => st.why = e.target.value }))),
h("div", { class: "row", style: "margin-top:10px" }, h("button", { onclick: guard(async () => {
await api("POST", "/tool-requests", st); toast("Заявка отправлена"); requestsPage();
}) }, "Отправить заявку")));
const card = r => {
const ans = h("textarea", { placeholder: "Ответ / комментарий (обязателен при отклонении)" }, r.answer);
const sel = h("select", {}, ...Object.entries(REQ_ST).map(([k, t]) => h("option", { value: k, selected: r.status === k }, t)));
return h("div", { class: "card" },
h("div", { class: "row" }, h("b", { style: "flex:1;font-size:16px" }, `№${r.id}. ${r.what}`, h("span", { class: "muted", style: "font-weight:400" }, ` — в количестве ${num(r.qty)}`)), h("span", { class: "badge rq-" + r.status }, REQ_ST[r.status])),
h("div", { class: "small muted" }, `${r.author_name || "—"} · ${fmt(r.created_at)}`),
h("div", { style: "margin-top:6px;white-space:pre-wrap" }, r.why),
r.link ? h("div", { style: "margin-top:4px" }, h("a", { href: r.link, target: "_blank", rel: "noopener noreferrer nofollow" }, "🔗 Ссылка на товар")) : null,
r.answer && !handler ? h("div", { class: "sheet", style: "margin-top:8px" }, h("b", {}, "Ответ: "), r.answer, r.handled_by_name ? h("span", { class: "muted" }, " — " + r.handled_by_name) : null) : null,
handler ? h("div", { class: "row", style: "margin-top:10px;align-items:flex-start" }, h("div", { style: "width:170px" }, sel), h("div", { style: "flex:1;min-width:220px" }, ans),
h("button", { onclick: guard(async () => { await api("POST", `/tool-requests/${r.id}/answer`, { status: sel.value, answer: ans.value }); toast("Сохранено"); requestsPage(); }) }, "Сохранить")) : null,
(handler || (r.status === "new" && r.author_id === me.id)) ? h("div", { style: "margin-top:6px" }, h("a", { href: "javascript:void 0", class: "small", onclick: guard(async () => { if (confirm("Удалить заявку?")) { await api("DELETE", "/tool-requests/" + r.id); requestsPage(); } }) }, "удалить")) : null);
};
fill($app, h("h1", {}, "Заявки на инструмент"),
h("p", { class: "small muted" }, handler ? "Вы видите все заявки сотрудников." : "Заявку увидит администратор склада. Здесь же появится ответ."),
form, h("h2", {}, handler ? "Все заявки" : "Мои заявки"), ...(list.length ? list.map(card) : [empty("Заявок пока нет")]));
}
// ======================= Инциденты =======================
function incidentCard(i, onchange) {
const canClose = i.status === "open" && (i.author_id === me.id || isEng());
const f = IS_FAULT(i), fbox = h("div");
const fa = i.escalated_at || i.occurred_at, D = FAIL_DICT;
const hrsTxt = v => v == null ? "" : `${Math.round(v * 10) / 10} ч`;
const down = !f && i.restored_at ? (new Date(i.restored_at) - new Date(fa)) / 3600000 : null;
// сводка записи об отказе (для закрытых): простой, причина, что сделано
const facts = i.status === "closed" && !f ? [down != null ? "простой " + hrsTxt(down) : null, i.parts_wait_hours ? "ожидание запчастей " + hrsTxt(i.parts_wait_hours) : null,
i.hours_at_failure != null ? `наработка ${Math.round(i.hours_at_failure)} м/ч` : null, i.cause_unit_name || i.cause_unit_other || null,
D.cause[i.cause] || null, D.nature[i.nature] || null, D.action[i.action] || null, D.precursor[i.precursor] || null].filter(Boolean) : [];
return h("div", { class: "card inc " + i.status + (f ? " fault" : "") },
h("div", { class: "row" }, h("b", { style: "flex:1;font-size:16px" }, i.status === "open" ? (f ? "⚠ " : "❗ ") : "", i.title),
h("span", { class: "badge kind-" + i.kind }, f ? "неисправность" : "отказ"),
h("span", { class: "badge " + (i.status === "open" ? (f ? "b-fault" : "b-overdue") : "b-completed") }, i.status === "open" ? (f ? "открыта" : "открыт") : (f ? "закрыта" : "закрыт"))),
h("div", { class: "small muted" }, `${fmt(i.occurred_at)} · ${i.author_name || "—"}`, i.equipment_name ? [" · ", h("a", { href: "#/equipment/" + i.equipment_id }, i.equipment_name)] : null),
i.source !== "staff" ? h("div", { class: "sheet", style: "margin-top:6px" }, i.source === "public" ? "📨 Через публичную форму" : "🔌 Из внешней системы",
i.reporter_name ? ": " + i.reporter_name : "", i.reporter_contact ? ", " + i.reporter_contact : "", i.external_id ? ` (№ ${i.external_id})` : "") : null,
i.description ? h("div", { style: "margin-top:6px;white-space:pre-wrap" }, i.description) : null,
i.units.length ? h("div", { class: "small", style: "margin-top:6px" }, "Затронуты узлы: ", i.units.map((u, n) => [n ? ", " : "", u.unit_name, u.replaced ? " (заменён)" : ""])) : null,
i.replaced ? h("div", { class: "small", style: "margin-top:4px" }, "🔧 Была замена", i.replacement_note ? ": " + i.replacement_note : "") : null,
i.escalated_at ? h("div", { class: "small muted", style: "margin-top:4px" }, `Сообщение о неисправности от ${fmt(i.occurred_at)} переведено в отказ ${fmt(i.escalated_at)}`) : null,
i.status === "closed" ? h("div", { class: "small muted", style: "margin-top:4px" }, `${f ? "Закрыта" : "Закрыт"} ${fmt(i.closed_at)}${i.closed_by_name ? ", " + i.closed_by_name : ""}${i.close_note ? ": " + i.close_note : ""}`) : null,
facts.length ? h("div", { class: "small", style: "margin-top:4px" }, "📊 ", facts.join(" · ")) : null,
i.status === "closed" && !f && isEng() ? h("div", { class: "row", style: "margin-top:6px" },
h("button", { class: "sec sm", onclick: guard(() => failureForm(i, fbox, onchange, "edit")) }, "✎ Данные отказа")) : null,
i.status === "open" ? h("div", { class: "row", style: "margin-top:8px" },
canClose ? h("button", { class: "sec sm", onclick: guard(() => failureForm(i, fbox, onchange, "close")) }, f ? "✔ Неисправность устранена" : "✔ Закрыть отказ") : null,
f ? h("button", { class: "bad sm", title: "Неисправность привела к остановке оборудования", onclick: guard(async () => {
const note = prompt("Оборудование остановлено? Перевести сообщение в отказ (машина получит состояние «Отказ»). Что случилось:", ""); if (note === null) return;
await api("POST", `/incidents/${i.id}/escalate`, { note }); toast("Переведено в отказ"); onchange();
}) }, "❗ Перевести в отказ") : null) : null, fbox);
}
// справочники записи об отказе (как в app/reliability.py)
const FAIL_DICT = {
action: { repair: "Ремонт", replace: "Замена узла", adjust: "Регулировка или наладка", restart: "Перезапуск без ремонта (сбой)" },
nature: { sudden: "Внезапный", gradual: "Постепенный" },
cause: { wear: "Износ и старение", operation: "Нарушение условий эксплуатации, ошибка персонала", external: "Внешние воздействия (температура, влага, пыль, вибрация)",
power: "Электропитание или смежные системы", defect: "Дефект изготовления, монтажа или прошлого ремонта", secondary: "Вследствие отказа другого узла", unknown: "Не установлена" },
precursor: { inspection: "Замечено на обходе или в чек-листе", fault_report: "Было сообщение о неисправности", none: "Признаков не было" },
};
const dtIn = v => { if (!v) return ""; const d = new Date(v), p = n => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; };
// Форма закрытия отказа или неисправности (mode=close) и уточнения данных закрытого отказа (mode=edit).
// Всё, что можно, подставлено заранее: дежурному обычно достаточно выбрать «что сделано», «характер» и написать пару слов.
async function failureForm(i, box, done, mode) {
if (box.firstChild) return fill(box);
const f = IS_FAULT(i), edit = mode === "edit", D = FAIL_DICT;
const units = (await api("GET", `/equipment/${i.equipment_id}/service-units`)).filter(u => u.is_active);
const fa = i.escalated_at || i.occurred_at;
const st = { note: edit ? i.close_note : "", repair_started_at: dtIn(i.repair_started_at || (f ? null : fa)), restored_at: dtIn(i.restored_at || (f ? null : new Date())),
parts_wait_hours: i.parts_wait_hours ?? "", hours_at_failure: i.hours_at_failure ?? i.hours_at_open ?? "", meter_at_restore: "",
cause_unit: i.cause_unit_id ? String(i.cause_unit_id) : i.cause_unit_other ? "other" : "", cause_unit_other: i.cause_unit_other || "",
action: i.action || "", nature: i.nature || "", cause: i.cause || (f ? "" : "unknown"), precursor: i.precursor || (i.escalated_at ? "fault_report" : ""),
replaced: new Set() };
const chips = (key, dict) => { const wrap = h("div", { class: "row chipsel" }); const draw = () => wrap.replaceChildren(...Object.entries(dict).map(([k, t]) =>
h("button", { type: "button", class: "chipf" + (st[key] === k ? " on" : ""), onclick: () => { st[key] = st[key] === k ? "" : k; draw(); } }, t))); draw(); return wrap; };
const sel = (key, dict, empty) => h("select", { onchange: e => st[key] = e.target.value },
h("option", { value: "" }, empty), ...Object.entries(dict).map(([k, t]) => h("option", { value: k, selected: st[key] === k }, t)));
const inp = (key, attrs) => h("input", { ...attrs, value: st[key], onchange: e => st[key] = e.target.value });
const other = h("input", { placeholder: "Какой узел или деталь", value: st.cause_unit_other, style: st.cause_unit === "other" ? "" : "display:none", onchange: e => st.cause_unit_other = e.target.value });
const unitSel = h("select", { onchange: e => { st.cause_unit = e.target.value; other.style.display = st.cause_unit === "other" ? "" : "none"; } },
h("option", { value: "" }, "— не указан —"), ...units.map(u => h("option", { value: u.id, selected: st.cause_unit === String(u.id) }, u.name)),
h("option", { value: "other", selected: st.cause_unit === "other" }, "Другое…"));
const repl = units.length ? h("div", {}, ...units.map(u => h("label", { class: "chk" }, h("input", { type: "checkbox", onchange: e => e.target.checked ? st.replaced.add(u.id) : st.replaced.delete(u.id) }),
u.name, h("span", { class: "small muted" }, " — заменён, ресурс сбросится"))) ) : h("p", { class: "small muted" }, "У машины нет заведённых узлов");
const need = !f && !edit; // для закрытия отказа «что сделано» и «характер» обязательны
const details = h("details", { class: "faildet", open: !f },
h("summary", {}, f ? "Подробнее (необязательно): время, причина" : "Время, наработка и причина — подставлено, поправьте при необходимости"),
h("div", { class: "grid" },
field(`Начало работ${f ? "" : " *"}`, inp("repair_started_at", { type: "datetime-local" })),
field(`Ввод в эксплуатацию${f ? "" : " *"}`, inp("restored_at", { type: "datetime-local" })),
field("Ожидание запчастей, ч", inp("parts_wait_hours", { type: "number", min: 0, step: "0.5", placeholder: "0" })),
field("Наработка на момент отказа, м/ч", inp("hours_at_failure", { type: "number", min: 0, step: "0.1" })),
field("Показание счётчика при вводе, м/ч", inp("meter_at_restore", { type: "number", min: 0, step: "0.1", placeholder: "если снимали" })),
field("Узел-причина", h("div", {}, unitSel, other))),
h("div", { class: "grid", style: "margin-top:8px" },
field("Причина", sel("cause", D.cause, "— выберите —")),
field("Были ли признаки заранее", sel("precursor", D.precursor, "— не указано —"))),
edit ? null : h("div", { style: "margin-top:8px" }, h("label", {}, "Заменённые при ремонте узлы"), repl));
fill(box, h("div", { class: "card failform", style: `border-color:var(${f ? "--fault" : "--bad"})` },
h("h2", { style: "margin-top:0" }, edit ? "Данные отказа" : f ? "Неисправность устранена" : "Закрытие отказа"),
h("label", {}, `Что сделано${need ? " *" : ""}`), chips("action", D.action),
f ? null : [h("label", {}, `Характер отказа${need ? " *" : ""}`), chips("nature", D.nature)],
h("label", {}, "Описание устранения"), h("textarea", { placeholder: "Что сделали, что заменили, рекомендации", onchange: e => st.note = e.target.value }, st.note),
details,
h("div", { class: "row", style: "margin-top:12px" },
h("button", { onclick: guard(async () => {
if (need && !st.action) throw new Error("Выберите, что сделано");
if (need && !st.nature) throw new Error("Укажите характер отказа: внезапный или постепенный");
if (st.cause_unit === "other" && !st.cause_unit_other.trim()) throw new Error("Укажите, какой узел — причина");
const num = v => v === "" || v == null ? null : +v;
const body = { note: st.note || "", action: st.action || null, nature: st.nature || null, cause: st.cause || null, precursor: st.precursor || null,
repair_started_at: st.repair_started_at || null, restored_at: st.restored_at || null, parts_wait_hours: num(st.parts_wait_hours),
hours_at_failure: num(st.hours_at_failure), cause_unit_id: /^\d+$/.test(st.cause_unit) ? +st.cause_unit : null,
cause_unit_other: st.cause_unit === "other" ? st.cause_unit_other : "" };
if (num(st.meter_at_restore) != null) body.meter_at_restore = num(st.meter_at_restore);
if (!edit) body.replaced_units = [...st.replaced];
await api(edit ? "PATCH" : "POST", `/incidents/${i.id}/${edit ? "failure" : "close"}`, body);
toast(edit ? "Сохранено" : f ? "Неисправность закрыта" : "Отказ закрыт"); done();
}) }, edit ? "Сохранить" : "Закрыть"),
h("button", { class: "sec", onclick: () => fill(box) }, "Отмена"))));
box.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// kind: incident — авария (оборудование остановлено), fault — сообщение о неисправности (оборудование работает)
function incidentForm(eq, units, box, onsaved, kind = "incident") {
const fault = kind === "fault";
const st = { title: "", description: "", occurred_at: nowInput(), replaced: false, replacement_note: "", units: {} };
const unitRows = units.filter(u => u.is_active).map(u => {
const chk = h("input", { type: "checkbox", onchange: e => { if (e.target.checked) st.units[u.id] = st.units[u.id] || { replaced: false }; else delete st.units[u.id]; rep.disabled = !e.target.checked; } });
const rep = h("input", { type: "checkbox", disabled: true, onchange: e => { if (st.units[u.id]) st.units[u.id].replaced = e.target.checked; } });
return h("div", { class: "unitpick" }, h("label", { class: "chk" }, chk, u.name), fault ? null : h("label", { class: "chk small" }, rep, "заменён (сбросить ресурс)"));
});
fill(box, h("div", { class: "card", style: `border-color:var(${fault ? "--fault" : "--bad"})` },
h("h2", { style: "margin-top:0" }, (fault ? "⚠ Сообщить о неисправности: " : "❗ Зафиксировать отказ: ") + eq.name),
fault ? h("p", { class: "small muted" }, "Неисправность замечена, но оборудование работает. Машина получит отметку «Неисправность» на карте и в карточке; если оборудование остановится — сообщение переводится в отказ.") : null,
h("div", { class: "grid" },
field(fault ? "Что замечено (кратко) *" : "Что произошло (кратко) *", h("input", { placeholder: fault ? "Например: посторонний шум, подтекает сальник, греется подшипник" : "Например: течь масла, заклинил подшипник", onchange: e => st.title = e.target.value })),
field("Когда", h("input", { type: "datetime-local", value: st.occurred_at, onchange: e => st.occurred_at = e.target.value }))),
h("div", { style: "margin-top:8px" }, field("Подробности", h("textarea", { placeholder: "Обстоятельства, причина, что сделано", onchange: e => st.description = e.target.value }))),
h("h2", {}, fault ? "Какой узел (если известно)" : "Затронутые узлы"), unitRows.length ? h("div", {}, ...unitRows) : h("p", { class: "small muted" }, "У машины нет заведённых узлов"),
fault ? null : h("div", { class: "grid", style: "margin-top:8px" },
h("label", { class: "chk" }, h("input", { type: "checkbox", onchange: e => st.replaced = e.target.checked }), "Была замена (деталей, узлов)"),
field("Что заменено", h("input", { onchange: e => st.replacement_note = e.target.value }))),
h("div", { class: "row", style: "margin-top:12px" }, h("button", { class: fault ? "fault" : "bad", onclick: guard(async () => {
await api("POST", "/incidents", { kind, equipment_id: eq.id, title: st.title, description: st.description, occurred_at: st.occurred_at || null,
replaced: st.replaced, replacement_note: st.replacement_note, units: Object.entries(st.units).map(([id, x]) => ({ unit_id: +id, replaced: x.replaced })) });
toast(fault ? "Неисправность зарегистрирована" : "Отказ зафиксирован"); onsaved();
}) }, fault ? "Сообщить" : "Зафиксировать"), h("button", { class: "sec", onclick: () => fill(box, ) }, "Отмена"))));
box.scrollIntoView({ behavior: "smooth" });
}
// #/incidents[/all|/cranes|/cranes-all] — инциденты; #/faults[...] — сообщения о неисправности (kind=fault)
async function incidentsPage(which, kind = "incident") {
// при отдельной службе кранов: без кранов — в «Оборудовании», только краны — в разделе «Краны»
const fault = kind === "fault", root = fault ? "#/faults" : "#/incidents";
const crane = me.crane_map && (which === "cranes" || which === "cranes-all");
const all = which === "all" || which === "cranes-all";
const p = new URLSearchParams({ kind });
if (!all) p.set("status", "open");
if (me.crane_map) p.set("cranes", crane ? "true" : "false");
const list = await api("GET", "/incidents?" + p);
const base = crane ? root + "/cranes" : root, allHref = crane ? root + "/cranes-all" : root + "/all";
const what = fault ? (crane ? "Неисправности по кранам" : "Сообщения о неисправности") : (crane ? "Отказы по кранам" : "Отказы");
fill($app, h("h1", {}, what),
tabs([[base, "Открытые"], [allHref, "Все"]], all ? allHref : base),
h("p", { class: "small muted" }, fault
? `Неисправность — оборудование работает, но замечена проблема. Сообщить можно в карточке ${crane ? "крана" : "машины"}: «⚠ Сообщить о неисправности». Если оборудование остановилось — «Перевести в отказ».`
: `Отказ — оборудование остановлено. Зафиксировать можно в карточке ${crane ? "крана: «Краны» → кран" : "оборудования: «Оборудование» → машина"} → «Зафиксировать отказ».`),
...(list.length ? list.map(i => incidentCard(i, () => incidentsPage(which, kind))) : [empty(all ? (fault ? "Сообщений пока нет" : "Отказов пока нет") : (fault ? "Открытых неисправностей нет" : "Открытых отказов нет"))]));
}
// блоки карточки оборудования: иконка, состояние, инциденты (вызывается из equipmentPage)
async function equipmentPortalBlocks(eq, units, reload) {
const [incs, health] = await Promise.all([api("GET", `/incidents?equipment_id=${eq.id}&limit=50`), api("GET", "/equipment-health")]);
const hs = health[eq.id] || { state: "ok" };
const formBox = h("div"), pick = h("div");
async function iconPicker() {
if (pick.firstChild) return pick.replaceChildren();
const icons = await api("GET", "/icons");
pick.replaceChildren(h("div", { class: "card" }, h("div", { class: "small muted", style: "margin-bottom:8px" }, "Выберите иконку из набора или загрузите своё фото/картинку:"),
h("div", { class: "iconpick" }, ...icons.map(i => h("button", { class: "sec" + (eq.icon === i.id ? " on" : ""), title: i.name, onclick: guard(async () => {
await api("PATCH", "/equipment/" + eq.id, { icon: i.id }); delete cache.equipment; toast("Иконка выбрана"); reload();
}) }, h("img", { src: i.url, alt: "" }), h("span", {}, i.name)))),
h("div", { class: "row", style: "margin-top:10px" }, filePicker(async f => { await sendFile("PUT", `/equipment/${eq.id}/icon`, f, 512); delete cache.equipment; toast("Иконка загружена"); reload(); }, "📷 Загрузить своё фото"),
eq.icon ? h("button", { class: "sec sm", onclick: guard(async () => { await api("PATCH", "/equipment/" + eq.id, { icon: "" }); reload(); }) }, "Убрать иконку") : null)));
}
const head = h("div", { class: "eqhead" }, h("div", { class: "eqbig st-" + hs.state }, eqIcon(eq, "eqicon big"),
hs.state === "alarm" ? h("span", { class: "alarm" }, "!") : hs.state === "fault" ? h("span", { class: "alarm faultmk" }, "⚠") : null),
h("div", { style: "flex:1" }, h("span", { class: "badge es-" + hs.state }, EQ_STATE[hs.state]),
hs.open_incidents ? h("span", { class: "small", style: "margin-left:8px;color:var(--bad)" }, `открытых отказов: ${hs.open_incidents}`) : null,
hs.open_faults ? h("span", { class: "small", style: "margin-left:8px;color:var(--fault)" }, `открытых неисправностей: ${hs.open_faults}`) : null,
h("div", { class: "row", style: "margin-top:8px" },
h("button", { class: "bad", onclick: () => incidentForm(eq, units, formBox, reload) }, "❗ Зафиксировать отказ"),
h("button", { class: "fault", onclick: () => incidentForm(eq, units, formBox, reload, "fault") }, "⚠ Сообщить о неисправности"),
isEng() ? h("button", { class: "sec", onclick: guard(iconPicker) }, "🖼 Иконка") : null,
h("a", { class: "btn sec", href: "#/new-task?eq=" + eq.id }, "+ Задание по машине"))));
const incBox = h("div", {}, h("h2", {}, `Отказы и неисправности (${incs.length})`),
...(incs.length ? incs.slice(0, 10).map(i => incidentCard(i, reload)) : [h("p", { class: "small muted" }, "Отказов и неисправностей не было")]));
if (/[?&]incident=1/.test(location.hash)) setTimeout(() => incidentForm(eq, units, formBox, reload), 50); // из вкладки «Задачи»
if (/[?&]fault=1/.test(location.hash)) setTimeout(() => incidentForm(eq, units, formBox, reload, "fault"), 50);
return { head: h("div", {}, head, pick, formBox), incidents: incBox, history: equipmentHistory(eq) };
}
// история машины: всё, что происходило, с фильтрами
const HIST_IC = { incident: "❗", service: "🔧", meter: "⏱", order: "📋", task: "🗓" };
function equipmentHistory(eq) {
const st = { types: new Set(["incident", "service", "meter", "order", "task"]), from: "", to: "", q: "" };
const list = h("div"), chips = h("div", { class: "row" });
let seq = 0, tm;
async function load() {
const my = ++seq, p = new URLSearchParams({ types: [...st.types].join(",") || "none", limit: "500" });
if (st.from) p.set("from", st.from);
if (st.to) p.set("to", st.to);
if (st.q.trim()) p.set("q", st.q.trim());
const j = await api("GET", `/equipment/${eq.id}/history?${p}`);
if (my !== seq) return;
chips.replaceChildren(...Object.entries(j.types).map(([k, t]) => h("label", { class: "chk hchip" + (st.types.has(k) ? " on" : "") },
h("input", { type: "checkbox", checked: st.types.has(k), onchange: e => { e.target.checked ? st.types.add(k) : st.types.delete(k); guard(load)(); } }),
`${HIST_IC[k]} ${t}`, st.types.has(k) ? h("span", { class: "muted small" }, ` ${j.counts[k]}`) : null)));
let day = "";
const out = [];
for (const r of j.rows) {
let d = new Date(r.at).toLocaleDateString("ru-RU", { weekday: "short", day: "2-digit", month: "long", year: "numeric" });
d = d[0].toUpperCase() + d.slice(1);
if (d !== day) { day = d; out.push(h("div", { class: "hday" }, d)); }
const link = r.order_id ? h("a", { href: "#/order/" + r.order_id }, " открыть наряд") : r.task_id ? h("a", { href: "#/task/" + r.task_id }, " открыть задание") : null;
out.push(h("div", { class: "hrow h-" + (r.severity || "") },
h("span", { class: "hic" }, HIST_IC[r.type]),
h("div", { style: "flex:1" }, h("div", {}, h("b", {}, r.title), link),
r.text ? h("div", { class: "small", style: "white-space:pre-wrap" }, r.text) : null,
h("div", { class: "small muted" }, new Date(r.at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }), " · ", r.type_ru, r.user_name ? " · " + r.user_name : ""))));
}
list.replaceChildren(...(out.length ? out : [h("p", { class: "muted" }, "Событий нет")]),
j.total > j.rows.length ? h("p", { class: "small muted" }, `Показаны последние ${j.rows.length} из ${j.total} — сузьте период`) : null);
}
const dt = (k, label) => h("div", {}, h("label", {}, label), h("input", { type: "date", onchange: e => { st[k] = e.target.value; guard(load)(); } }));
const box = h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "История машины"),
h("p", { class: "small muted" }, "Всё, что происходило: отказы и неисправности, обслуживание и замены узлов, наработка, наряды и задания."),
chips,
h("div", { class: "grid", style: "margin:10px 0" }, dt("from", "С даты"), dt("to", "По дату"),
h("div", {}, h("label", {}, "Поиск"), h("input", { type: "search", placeholder: "текст, исполнитель…", oninput: e => { st.q = e.target.value; clearTimeout(tm); tm = setTimeout(guard(load), 300); } }))),
list);
guard(load)();
return box;
}
// ======================= Суточные задания =======================
const tfilt = { day: "", mine: false, status: "", assignee_id: "" };
async function tasksPage() {
const boss = isEng();
const users = boss ? (await ref("users")).filter(u => u.is_active) : [];
if (!tfilt.day && tfilt.day !== null) tfilt.day = todayISO();
const box = h("div");
async function load() {
const q = new URLSearchParams();
if (tfilt.day) q.set("day", tfilt.day);
if (tfilt.mine) q.set("mine", "true");
if (tfilt.status) q.set("status", tfilt.status);
if (tfilt.assignee_id) q.set("assignee_id", tfilt.assignee_id);
const list = await api("GET", "/tasks?" + q);
fill(box, list.length ? h("div", { class: "tasklist" }, ...list.map(t => h("a", { class: "card taskc ts-" + t.status, href: "#/task/" + t.id },
h("div", { class: "row" }, h("b", { style: "flex:1" }, t.title), h("span", { class: "badge t-" + t.status }, TASK_ST[t.status])),
h("div", { class: "small muted" }, `${new Date(t.day).toLocaleDateString("ru-RU")} · ${t.assignee_name || "—"}`, t.equipment_name ? ` · ⚙ ${t.equipment_name}${t.unit_name ? " / " + t.unit_name : ""}` : "",
t.photos.length ? ` · 📷${t.photos.length}` : ""),
t.last_comment ? h("div", { class: "small", style: "margin-top:4px" }, "💬 " + t.last_comment) : null))) : empty("Заданий нет"));
}
const ch = (k, label) => h("label", { class: "chk" }, h("input", { type: "checkbox", checked: tfilt[k], onchange: e => { tfilt[k] = e.target.checked; guard(load)(); } }), label);
fill($app, h("div", { class: "row" }, h("h1", { style: "flex:1" }, "Задачи и поручения"), h("a", { class: "btn", href: "#/new-task" }, "+ Задание")),
h("div", { class: "row", style: "margin-bottom:12px" },
h("input", { type: "date", value: tfilt.day || "", style: "width:auto", onchange: e => { tfilt.day = e.target.value || null; guard(load)(); } }),
h("button", { class: "sec sm", onclick: () => { tfilt.day = todayISO(); tasksPage(); } }, "Сегодня"),
h("button", { class: "sec sm", onclick: () => { tfilt.day = null; tasksPage(); } }, "Все дни"),
h("select", { style: "width:auto", onchange: e => { tfilt.status = e.target.value; guard(load)(); } }, h("option", { value: "" }, "Все статусы"),
...Object.entries(TASK_ST).map(([k, v]) => h("option", { value: k, selected: tfilt.status === k }, v))),
boss ? h("div", { style: "min-width:260px" }, searchSelect(h("select", { onchange: e => { tfilt.assignee_id = e.target.value; guard(load)(); } }, h("option", { value: "" }, "Все сотрудники"),
...users.map(u => h("option", { value: u.id, selected: String(tfilt.assignee_id) === String(u.id) }, u.full_name || u.username))), "🔍 Сотрудник")) : null,
ch("mine", "Только мои")),
box);
await load();
timer = setInterval(guard(load), 30000);
}
async function unitOptions(eqId) { return eqId ? api("GET", `/equipment/${eqId}/service-units`) : []; }
async function taskForm(t) {
const boss = isEng();
const [eqs, users] = await Promise.all([ref("equipment"), boss ? ref("users") : Promise.resolve([])]);
const pre = +(new URLSearchParams(location.hash.split("?")[1] || "").get("eq") || 0);
const s = t ? { ...t } : { title: "", description: "", day: todayISO(), assignee_id: me.id, equipment_id: pre || null, unit_id: null };
const unitSel = h("select", { onchange: e => s.unit_id = e.target.value ? +e.target.value : null });
async function fillUnits() {
const us = await unitOptions(s.equipment_id);
unitSel.replaceChildren(h("option", { value: "" }, "— вся машина —"), ...us.map(u => h("option", { value: u.id, selected: s.unit_id === u.id }, u.name)));
}
await fillUnits();
fill($app, h("a", { href: t ? "#/task/" + t.id : "#/tasks" }, "← Назад"), h("h1", {}, t ? "Изменить задание" : "Новая задача (поручение)"),
h("div", { class: "card" }, h("div", { class: "grid" },
field("Что сделать *", h("input", { value: s.title, onchange: e => s.title = e.target.value })),
field("День", h("input", { type: "date", value: s.day, onchange: e => s.day = e.target.value })),
boss ? field("Исполнитель", searchSelect(h("select", { onchange: e => s.assignee_id = +e.target.value }, ...users.filter(u => u.is_active).map(u => h("option", { value: u.id, selected: s.assignee_id === u.id }, u.full_name || u.username)))))
: field("Исполнитель", h("div", {}, "я (" + (me.full_name || me.username) + ")")),
field("Оборудование", h("select", { onchange: guard(async e => { s.equipment_id = e.target.value ? +e.target.value : null; s.unit_id = null; await fillUnits(); }) },
h("option", { value: "" }, "—"), ...eqs.map(x => h("option", { value: x.id, selected: s.equipment_id === x.id }, x.name)))),
field("Узел", unitSel),
field("Трудоёмкость (норма), ч", h("input", { type: "number", min: "0", step: "0.25", value: s.labor_hours ?? "", placeholder: "необязательно",
onchange: e => s.labor_hours = e.target.value === "" ? null : +e.target.value }), "Для отчёта по сотрудникам; пусто — учитывается фактическое время в работе")),
h("div", { style: "margin-top:8px" }, field("Описание", h("textarea", { onchange: e => s.description = e.target.value }, s.description))),
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
const body = { title: s.title, description: s.description, day: s.day || null, assignee_id: s.assignee_id, equipment_id: s.equipment_id, unit_id: s.unit_id, labor_hours: s.labor_hours ?? null };
const r = t ? await api("PATCH", "/tasks/" + t.id, body) : await api("POST", "/tasks", body);
toast("Сохранено"); location.hash = "#/task/" + r.id;
}) }, t ? "Сохранить" : "Создать задание"))));
}
async function taskPage(id) {
const t = await api("GET", "/tasks/" + id);
const people = t.can_transfer ? (await api("GET", "/users")).filter(u => u.is_active && u.id !== t.assignee_id) : [];
const trTo = h("select", {}, h("option", { value: "" }, "— кому передать —"),
...people.map(u => h("option", { value: u.id }, (u.full_name || u.username) + (u.position ? " · " + u.position.name : ""))));
const trWhy = h("input", { placeholder: "Причина передачи (обязательно)" });
const reload = () => taskPage(id);
const comment = h("textarea", { placeholder: "Комментарий (обязателен для «приостановлен», «выполнен», «отменён»)" });
const next = t.can_change ? TASK_NEXT[t.status] : [];
const photos = h("div", { class: "photos" }, ...t.photos.map(pid => {
const img = authImg(`/task-photos/${pid}`, { class: "thumb" });
img.onclick = () => img.src && window.open(img.src, "_blank");
return h("div", { class: "ph" }, img, t.can_change || isEng() ? h("button", { class: "x", title: "Удалить", onclick: guard(async () => { if (confirm("Удалить фото?")) { await api("DELETE", "/task-photos/" + pid); reload(); } }) }, "✕") : null);
}), t.can_change || t.can_edit ? filePicker(async f => { toast("Загрузка…"); await sendFile("POST", `/tasks/${id}/photos`, f); reload(); }, "📷 Добавить фото") : null);
fill($app, h("a", { href: "#/tasks" }, "← Задачи и поручения"),
h("div", { class: "card" }, h("div", { class: "row" }, h("h1", { style: "margin:0;flex:1" }, t.title), h("span", { class: "badge t-" + t.status }, TASK_ST[t.status])),
h("div", { class: "grid", style: "margin-top:12px" },
h("div", {}, h("label", {}, "День"), new Date(t.day).toLocaleDateString("ru-RU")),
h("div", {}, h("label", {}, "Исполнитель"), h("a", { href: "#/user/" + t.assignee_id }, t.assignee_name || "—")),
h("div", {}, h("label", {}, "Поставил"), t.created_by_name || "—"),
h("div", {}, h("label", {}, "Оборудование"), t.equipment_id ? h("a", { href: "#/equipment/" + t.equipment_id }, t.equipment_name) : "—", t.unit_name ? " / " + t.unit_name : ""),
h("div", {}, h("label", {}, "Завершено"), fmt(t.finished_at)),
t.labor_hours ? h("div", {}, h("label", {}, "Трудоёмкость (норма)"), `${num(t.labor_hours)} ч`) : null),
t.description ? h("div", { style: "margin-top:10px;white-space:pre-wrap" }, t.description) : null,
t.can_edit ? h("div", { style: "margin-top:10px" }, h("button", { class: "sec sm", onclick: guard(() => taskForm(t)) }, "Изменить"),
" ", h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить задание?")) { await api("DELETE", "/tasks/" + id); location.hash = "#/tasks"; } }) }, "Удалить")) : null),
next.length ? h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Статус"), comment,
h("div", { class: "row", style: "margin-top:10px" }, ...next.map(s => h("button", { class: s === "cancelled" ? "bad" : s === "done" ? "" : "sec", onclick: guard(async () => {
if ((TASK_NEED.includes(s) || ["done", "cancelled"].includes(t.status)) && !comment.value.trim()) { comment.focus(); throw new Error("Напишите комментарий"); }
await api("POST", `/tasks/${id}/status`, { status: s, comment: comment.value }); toast(TASK_ST[s]); reload();
}) }, TASK_BTN[s])))) : null,
t.can_transfer ? h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Передать задание"),
h("div", { class: "grid" }, field("Сотрудник", searchSelect(trTo)), field("Причина", trWhy)),
h("p", { class: "small muted" }, "Передать может исполнитель, тот, кто поставил задание, или инженер. Начатое задание у нового исполнителя станет «Назначен»."),
h("button", { class: "sec", onclick: guard(async () => {
if (!trTo.value) throw new Error("Выберите сотрудника");
if (!trWhy.value.trim()) { trWhy.focus(); throw new Error("Укажите причину передачи"); }
await api("POST", `/tasks/${id}/transfer`, { assignee_id: +trTo.value, comment: trWhy.value });
toast("Задание передано"); reload();
}) }, "⇄ Передать")) : null,
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Фото"), photos),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "История"), ...(t.events || []).map(e => h("div", { class: "cm" },
e.kind === "transfer" ? h("div", { class: "small muted" }, `${fmt(e.at)} · ${e.user_name || "—"} · `, h("b", {}, `⇄ передано: ${e.from_user_name || "—"} → ${e.to_user_name || "—"}`))
: h("div", { class: "small muted" }, `${fmt(e.at)} · ${e.user_name || "—"} · `, e.from_status ? `${TASK_ST[e.from_status]} → ` : "", h("b", {}, TASK_ST[e.to_status])),
e.comment ? h("div", { style: "white-space:pre-wrap" }, e.comment) : null))));
}
// ======================= Карта завода и карта складов =======================
async function mapPage(layer) {
layer = ["warehouses", "cranes"].includes(layer) ? layer : "equipment";
const crane = layer === "cranes", q = crane ? "?kind=crane" : "";
const m = await api("GET", "/map" + q);
const canEdit = layer !== "warehouses" ? m.can_edit_equipment : m.can_edit_warehouses;
let editing = false, placing = null, zoneFor = null;
const side = h("div", { class: "mapside" }), info = h("div");
const stage = h("div", { class: "mapstage" });
const img = m.has_image ? authImg("/map/image?" + (crane ? "kind=crane&" : "") + "v=" + m.image_version, { class: "mapimg", draggable: "false" }) : null;
const pct = ev => { const r = stage.getBoundingClientRect(); return { x: Math.max(0, Math.min(100, (ev.clientX - r.left) / r.width * 100)), y: Math.max(0, Math.min(100, (ev.clientY - r.top) / r.height * 100)) }; };
const round = v => Math.round(v * 100) / 100;
function markers() {
const out = [];
if (layer !== "warehouses") {
for (const e of m.equipment.filter(e => e.x != null)) {
const mk = h("div", { class: `mk st-${e.state}` + (editing ? " edit" : ""), style: `left:${e.x}%;top:${e.y}%`, title: e.name },
eqIcon(e, "eqicon"), e.state === "alarm" ? h("span", { class: "alarm" }, "!") : e.state === "fault" ? h("span", { class: "alarm faultmk" }, "⚠") : null,
h("span", { class: "mkname" }, e.name));
if (editing) dragMarker(mk, e);
else mk.onclick = ev => { ev.stopPropagation(); showEq(e); };
out.push(mk);
}
} else {
for (const w of m.warehouses.filter(w => w.zone)) {
const z = w.zone;
const zone = h("div", { class: "zone", style: `left:${z.x}%;top:${z.y}%;width:${z.w}%;height:${z.h}%` }, h("span", {}, "🏬 " + w.name, h("small", {}, ` ${w.positions} поз.`)));
zone.onclick = ev => { ev.stopPropagation(); if (!editing) showWh(w); };
out.push(zone);
}
}
return out;
}
function draw() {
stage.replaceChildren(...(img ? [img] : []), ...markers());
stage.classList.toggle("placing", !!(placing || zoneFor));
stage.classList.toggle("editing", editing);
drawSide();
}
function showEq(e) {
setTimeout(() => info.scrollIntoView({ behavior: "smooth", block: "nearest" }), 50);
fill(info, h("div", { class: "card" }, h("div", { class: "row" }, eqIcon(e, "eqicon"), h("b", { style: "flex:1" }, e.name), h("span", { class: "badge es-" + e.state }, EQ_STATE[e.state])),
e.open_incidents ? h("div", { class: "small", style: "color:var(--bad);margin-top:6px" }, `❗ Открытых отказов: ${e.open_incidents}`) : null,
e.open_faults ? h("div", { class: "small", style: "color:var(--fault);margin-top:6px" }, `⚠ Открытых неисправностей: ${e.open_faults}`) : null,
e.due || e.soon ? h("div", { class: "small", style: "color:var(--warn);margin-top:4px" }, `Узлы: требуется ТО — ${e.due}, скоро — ${e.soon}`) : null,
h("div", { class: "row", style: "margin-top:8px" }, h("a", { class: "btn sm", href: "#/equipment/" + e.id }, "Открыть карточку"))));
}
async function showWh(w) {
setTimeout(() => info.scrollIntoView({ behavior: "smooth", block: "nearest" }), 300);
if (!m.can_see_stock) return fill(info, h("div", { class: "card" }, h("b", {}, "🏬 " + w.name), h("p", { class: "small muted" }, "Состав склада видит администратор склада и инженеры.")));
const st = await api("GET", "/inventory/stock?warehouse_id=" + w.id);
fill(info, h("div", { class: "card" }, h("div", { class: "row" }, h("b", { style: "flex:1" }, "🏬 " + w.name), h("a", { class: "btn sec sm", href: "#/inventory/stock?wh=" + w.id }, "Открыть склад")),
st.length ? h("table", { style: "margin-top:6px" }, h("tbody", {}, ...st.map(s => h("tr", {}, h("td", {}, s.item_name, s.inventory_no ? h("span", { class: "small muted" }, ` (${s.inventory_no})`) : null), h("td", {}, `${num(s.qty)} ${s.unit}`))))) : h("p", { class: "small muted" }, "Склад пуст")));
}
function dragMarker(mk, e) {
mk.addEventListener("pointerdown", ev => {
ev.preventDefault(); mk.setPointerCapture(ev.pointerId); mk.classList.add("drag");
const move = mv => { const p = pct(mv); mk.style.left = p.x + "%"; mk.style.top = p.y + "%"; };
const up = guard(async uv => {
mk.removeEventListener("pointermove", move); mk.removeEventListener("pointerup", up); mk.classList.remove("drag");
const p = pct(uv); e.x = round(p.x); e.y = round(p.y);
await api("PUT", `/equipment/${e.id}/map`, { x: e.x, y: e.y }); toast("Сохранено: " + e.name);
});
mk.addEventListener("pointermove", move); mk.addEventListener("pointerup", up);
});
}
// размещение оборудования кликом / рисование зоны склада протягиванием
stage.addEventListener("click", guard(async ev => {
if (!placing) return;
const p = pct(ev), e = placing;
e.x = round(p.x); e.y = round(p.y); placing = null;
await api("PUT", `/equipment/${e.id}/map`, { x: e.x, y: e.y }); toast("Размещено: " + e.name); draw();
}));
stage.addEventListener("pointerdown", ev => {
if (!zoneFor) return;
ev.preventDefault();
const a = pct(ev), box = h("div", { class: "zone drawing" });
stage.append(box);
const rect = b => ({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) });
const paint = z => Object.assign(box.style, { left: z.x + "%", top: z.y + "%", width: z.w + "%", height: z.h + "%" });
const move = mv => paint(rect(pct(mv)));
const up = guard(async uv => {
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up);
const z = rect(pct(uv)), w = zoneFor;
if (z.w < 1 || z.h < 1) { box.remove(); return; }
zoneFor = null;
const r = await api("PUT", `/warehouses/${w.id}/zone`, { zone: { x: round(z.x), y: round(z.y), w: round(z.w), h: round(z.h) } });
Object.assign(w, r); toast("Зона склада сохранена"); draw();
});
window.addEventListener("pointermove", move); window.addEventListener("pointerup", up);
});
function drawSide() {
if (!canEdit) return fill(side, );
if (!editing) return fill(side, h("button", { class: "sec", onclick: () => { editing = true; draw(); } }, "✎ Редактировать карту"));
const parts = [h("div", { class: "row" }, h("b", { style: "flex:1" }, "Редактирование"), h("button", { class: "sm", onclick: () => { editing = false; placing = zoneFor = null; draw(); } }, "Готово"))];
if (layer !== "warehouses") {
if (me.is_admin) parts.push(h("div", { style: "margin:8px 0" }, filePicker(async f => {
if (f.size > 20 * 1048576) throw new Error("Файл больше 20 МБ");
toast("Загрузка карты…"); await sendFile("PUT", "/map/image" + q, f, 4000); toast("Карта обновлена"); mapPage(layer);
}, crane ? (m.own_image ? "🗺 Заменить картинку карты кранов" : "🗺 Загрузить свою картинку карты кранов") : m.has_image ? "🗺 Заменить картинку карты" : "🗺 Загрузить картинку карты", "sec")));
if (crane && !m.own_image) parts.push(h("p", { class: "small muted" }, "Пока своей картинки нет, краны ставятся на план завода."));
if (!m.equipment.length) parts.push(h("p", { class: "small" }, crane ? "Кранов нет: отметьте «Кран» в карточке оборудования (Оборудование → Изменить)." : "Оборудования нет."));
parts.push(h("p", { class: "small muted" }, placing ? `Кликните на карте, куда поставить «${placing.name}»` : "Перетаскивайте иконки мышью или пальцем. Чтобы поставить машину, выберите её ниже и кликните по карте."));
parts.push(h("div", { class: "eqpick" }, ...m.equipment.map(e => h("div", { class: "row" + (placing === e ? " on" : "") },
eqIcon(e, "eqicon sm"), h("span", { style: "flex:1" }, e.name),
h("button", { class: "sec sm", onclick: () => { placing = e; zoneFor = null; draw(); } }, e.x == null ? "Поставить" : "Переставить"),
e.x != null ? h("button", { class: "sec sm", title: "Убрать с карты", onclick: guard(async () => { await api("PUT", `/equipment/${e.id}/map`, { x: null, y: null }); e.x = e.y = null; draw(); }) }, "✕") : null))));
} else {
parts.push(h("p", { class: "small muted" }, zoneFor ? `Протяните мышью или пальцем прямоугольник зоны склада «${zoneFor.name}»` : "Выберите склад и протяните на карте прямоугольник его зоны."));
parts.push(h("div", { class: "eqpick" }, ...m.warehouses.map(w => h("div", { class: "row" + (zoneFor === w ? " on" : "") },
h("span", { style: "flex:1" }, "🏬 " + w.name),
h("button", { class: "sec sm", onclick: () => { zoneFor = w; placing = null; draw(); } }, w.zone ? "Перерисовать" : "Отметить зону"),
w.zone ? h("button", { class: "sec sm", title: "Убрать зону", onclick: guard(async () => { await api("PUT", `/warehouses/${w.id}/zone`, { zone: null }); w.zone = null; draw(); }) }, "✕") : null))));
if (!m.warehouses.length) parts.push(h("p", { class: "small" }, h("a", { href: "#/inventory/stock" }, "Сначала создайте склады")));
}
fill(side, h("div", { class: "card" }, ...parts));
}
const legend = layer !== "warehouses" ? h("div", { class: "legend small muted" },
h("span", {}, h("i", { class: "lg alarm" }, "!"), " отказ (оборудование остановлено)"), h("span", {}, h("i", { class: "lg alarm faultmk" }, "⚠"), " неисправность (оборудование работает)"),
h("span", {}, h("i", { class: "lg due" }), " требуется ТО"),
h("span", {}, h("i", { class: "lg soon" }), " приближается лимит")) : h("div", { class: "legend small muted" }, "Нажмите на зону склада, чтобы увидеть, что на нём лежит.");
fill($app, h("h1", {}, crane ? "Карта кранов" : layer !== "warehouses" ? "Карта завода" : "Карта складов"),
tabs([["#/map", "Оборудование"], ...(m.crane_map ? [["#/map/cranes", "Краны"]] : []), ["#/map/warehouses", "Склады"]], layer === "equipment" ? "#/map" : "#/map/" + layer),
m.crane_map && layer === "equipment" ? h("p", { class: "small muted" }, "Краны вынесены на отдельную карту — вкладка «Краны».") : null,
legend, side,
m.has_image ? zoomable(stage, { key: "map-" + layer, isEditing: () => editing && !!(placing || zoneFor) }) : empty(me.is_admin ? "Карта ещё не загружена. Нажмите «Редактировать карту» и загрузите картинку плана завода." : "Администратор ещё не загрузил карту завода."),
info);
if (!m.has_image && me.is_admin && layer !== "warehouses") { editing = true; }
draw();
timer = setInterval(guard(async () => { // состояние оборудования обновляется само
if (editing || layer === "warehouses") return;
const fresh = await api("GET", "/map" + q);
m.equipment = fresh.equipment; draw();
}), 60000);
}
// ======================= Сотрудники =======================
async function peoplePage() {
const users = (await ref("users", true)).filter(u => u.is_active);
const deps = await ref("departments", true);
const depName = Object.fromEntries(deps.map(d => [d.id, d.name]));
// отдел вместе с вложенными отделами
const under = id => { const out = new Set([id]); let grown = true; while (grown) { grown = false; for (const d of deps) if (!out.has(d.id) && out.has(d.parent_id)) { out.add(d.id); grown = true; } } return out; };
let dep = new URLSearchParams(location.hash.split("?")[1] || "").get("dep") || "";
const q = h("input", { type: "search", placeholder: "🔍 Поиск по ФИО, должности, отделу, навыкам", style: "max-width:420px" });
const depSel = h("select", { style: "width:auto", onchange: e => { dep = e.target.value; draw(); } }, h("option", { value: "" }, "Все отделы"),
h("option", { value: "none", selected: dep === "none" }, "— без отдела —"), ...deps.map(d => h("option", { value: d.id, selected: String(d.id) === dep }, d.name + ` (${d.members})`)));
const grid = h("div", { class: "people" }), count = h("div", { class: "small muted", style: "margin-bottom:8px" });
const draw = () => {
const s = q.value.trim().toLowerCase();
const set = dep && dep !== "none" ? under(+dep) : null;
const list = users.filter(u => (!dep || (dep === "none" ? !u.department_id : set.has(u.department_id)))
&& (!s || [u.full_name, u.username, u.position?.name, depName[u.department_id], u.skills].join(" ").toLowerCase().includes(s)));
count.textContent = `Сотрудников: ${list.length}`;
grid.replaceChildren(...list.map(u =>
h("a", { class: "card person", href: "#/user/" + u.id }, avatar(u), h("div", {}, h("b", {}, u.full_name || u.username),
h("div", { class: "small muted" }, u.position?.name || "без должности", u.position ? (u.on_shift ? " · на смене" : " · не на смене") : ""),
u.department_id ? h("div", { class: "small" }, "🏢 " + (depName[u.department_id] || "")) : null,
u.skills ? h("div", { class: "small", style: "margin-top:2px" }, u.skills.length > 80 ? u.skills.slice(0, 80) + "…" : u.skills) : null))));
};
q.oninput = draw;
fill($app, h("h1", {}, "Сотрудники"), h("div", { class: "row", style: "margin-bottom:12px" }, q, depSel,
isMgr() ? h("a", { class: "btn sec sm", href: "#/departments" }, "Отделы") : null), count, grid);
draw();
}
async function userCardPage(id) {
const c = await api("GET", `/users/${id}/card`);
const u = c.user, reload = () => userCardPage(id);
const roles = [u.is_admin && "администратор", u.is_moderator && "модератор", u.is_storekeeper && "администратор склада"].filter(Boolean).join(", ");
const skills = h("textarea", { placeholder: "Например: сварка, электрика до 1000 В, гидравлика, работа на высоте" }, u.skills);
const phone = h("input", { value: u.phone, placeholder: "+7 …" });
fill($app, h("a", { href: "#/people" }, "← Сотрудники"),
h("div", { class: "card ucard" }, h("div", { class: "uphoto" }, avatar(u, "ava big"),
c.can_edit ? h("div", { class: "row", style: "margin-top:8px;justify-content:center" },
filePicker(async f => { await sendFile("PUT", `/users/${id}/photo`, f, 800); avatar.v[id] = (avatar.v[id] || 0) + 1; delete cache.users; if (+id === me.id) me = null; toast("Фото обновлено"); reload(); }, "📷 Фото"),
u.has_photo ? h("button", { class: "sec sm", onclick: guard(async () => { await api("DELETE", `/users/${id}/photo`); avatar.v[id] = (avatar.v[id] || 0) + 1; delete cache.users; reload(); }) }, "✕") : null) : null),
h("div", { style: "flex:1;min-width:240px" }, h("h1", { style: "margin:0 0 4px" }, u.full_name || u.username),
h("div", { class: "muted" }, u.position?.name || "Должность не указана", roles ? " · " + roles : ""),
u.department ? h("div", { style: "margin-top:2px" }, "🏢 ", h("a", { href: "#/people?dep=" + u.department.id }, u.department.name)) : null,
u.position ? h("div", { class: "small", style: "margin-top:4px" }, u.on_shift ? "🟢 Сейчас на смене" : "⚪ Сейчас не на смене") : null,
u.phone && !c.can_edit ? h("div", { style: "margin-top:6px" }, "📞 ", h("a", { href: "tel:" + u.phone.replace(/[^+\d]/g, "") }, u.phone)) : null)),
h("div", { class: "cols" },
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "График"),
c.schedule.name ? h("div", { class: "small muted", style: "margin-bottom:6px" }, c.schedule.name) : null,
c.schedule.always ? h("p", {}, "Без графика — всегда на смене") :
c.schedule.shifts.length ? h("table", {}, h("tbody", {}, ...c.schedule.shifts.map(s => h("tr", {},
h("td", {}, new Date(s.start).toLocaleDateString("ru-RU", { weekday: "short", day: "2-digit", month: "2-digit" })),
h("td", {}, `${new Date(s.start).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}–${new Date(s.end).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}`),
h("td", { class: "small muted" }, s.label || ""))))) : h("p", { class: "muted small" }, u.position ? "Смен в ближайшие 2 недели нет" : "Должность не назначена")),
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Что умею"),
c.can_edit ? [skills, h("div", { style: "margin-top:8px" }, field("Телефон", phone)), h("button", { style: "margin-top:8px", onclick: guard(async () => {
await api("PATCH", `/users/${id}/profile`, { skills: skills.value, phone: phone.value }); delete cache.users; toast("Сохранено");
}) }, "Сохранить")] : h("div", { style: "white-space:pre-wrap" }, u.skills || h("span", { class: "muted" }, "не заполнено")))),
h("div", { class: "cols" },
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, `Текущие задания (${c.tasks.length})`),
...(c.tasks.length ? c.tasks.map(t => h("div", { class: "cm" }, h("a", { href: "#/task/" + t.id }, t.title), " ", h("span", { class: "badge t-" + t.status }, TASK_ST[t.status]),
h("span", { class: "small muted" }, " · " + new Date(t.day).toLocaleDateString("ru-RU")))) : [h("p", { class: "muted small" }, "Нет")]),
c.orders.length ? [h("h2", {}, `Наряды (${c.orders.length})`), ...c.orders.map(o => h("div", { class: "cm" }, h("a", { href: "#/order/" + o.id }, `№${o.id} ${o.title}`), " ",
h("span", { class: "badge b-" + o.status }, STATUS[o.status]), o.due_at ? h("span", { class: "small muted" }, " · срок " + fmt(o.due_at)) : null))] : null),
c.inventory ? h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, `Выданный инструмент (${c.inventory.length})`),
c.inventory.length ? h("table", {}, h("tbody", {}, ...c.inventory.map(s => h("tr", {}, h("td", {}, s.item_name, s.inventory_no ? h("span", { class: "small muted" }, ` (${s.inventory_no})`) : null),
h("td", {}, `${num(s.qty)} ${s.unit}`), h("td", { class: "small muted" }, "с " + fmt(s.since)))))) : h("p", { class: "muted small" }, "Ничего не выдано")) : null));
}
+150
View File
@@ -0,0 +1,150 @@
"use strict";
// ---------- Анализ надёжности (инженеры и руководители) ----------
// Методика: «Надёжность и безопасность» (ИАТЭ, 2005), ГОСТ 27.002. Отказы — пуассоновский поток, λ ≈ const на участке
// нормальной эксплуатации; оценки по малоотказному опыту консервативные (доверие 90 %). Расчёты — на сервере (app/reliability.py).
const REL_KEY = "reliability_filters";
async function reliabilityPage(tab) {
tab = tab === "drift" ? "drift" : tab === "method" ? "method" : "summary";
const deps = await ref("departments");
let st;
try { st = JSON.parse(localStorage.getItem(REL_KEY) || "null"); } catch (e) { st = null; }
const today = new Date();
const ago = m => { const d = new Date(today); d.setMonth(d.getMonth() - m); return d; };
st = Object.assign({ from: dtIn(ago(12)), to: dtIn(today), department_id: "", cranes: "", confidence: "0.9", horizon: "30", days: "180" }, st || {});
const save = () => { try { localStorage.setItem(REL_KEY, JSON.stringify(st)); } catch (e) {} };
const form = h("div", { class: "card" }), result = h("div");
const params = extra => {
const q = new URLSearchParams(extra || {});
if (st.department_id) q.set("department_id", st.department_id);
if (st.cranes) q.set("cranes", st.cranes);
return q;
};
const periodQ = () => params({ from: st.from, to: st.to });
async function download(kind) {
const q = periodQ(); q.set("kind", kind);
const r = await authFetch(`${API}/reliability/export?${q}`);
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob());
a.download = `${kind === "failures" ? "otkazy" : "obsluzhivanie_uzlov"}_${st.from.slice(0, 10)}_${st.to.slice(0, 10)}.csv`;
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
function drawForm() {
const dt = (key, label) => field(label, h("input", { type: "datetime-local", value: st[key], onchange: e => { st[key] = e.target.value; save(); } }));
const quick = (label, m) => h("button", { class: "sec sm", onclick: () => { st.from = dtIn(ago(m)); st.to = dtIn(new Date()); save(); drawForm(); guard(show)(); } }, label);
form.replaceChildren(
tab === "drift" ? h("div", { class: "grid" },
field("За сколько дней смотреть значения", h("select", { onchange: e => { st.days = e.target.value; save(); } },
...[["90", "3 месяца"], ["180", "6 месяцев"], ["365", "год"], ["730", "2 года"]].map(([v, t]) => h("option", { value: v, selected: st.days === v }, t))))) :
h("div", { class: "grid" }, dt("from", "С"), dt("to", "По"),
field("Доверие оценок", h("select", { onchange: e => { st.confidence = e.target.value; save(); } },
...[["0.9", "90 % (консервативно)"], ["0.8", "80 %"], ["0.5", "50 % («средневероятно»)"]].map(([v, t]) => h("option", { value: v, selected: st.confidence === v }, t)))),
field("Прогноз на", h("select", { onchange: e => { st.horizon = e.target.value; save(); } },
...[["7", "неделю"], ["30", "месяц"], ["90", "квартал"], ["365", "год"]].map(([v, t]) => h("option", { value: v, selected: st.horizon === v }, t))))),
h("div", { class: "grid", style: "margin-top:8px" },
field("Отдел (с вложенными)", h("select", { onchange: e => { st.department_id = e.target.value; save(); } }, h("option", { value: "" }, "Все"),
...deps.map(d => h("option", { value: d.id, selected: String(d.id) === String(st.department_id) }, d.name)))),
me.crane_map ? field("Оборудование", h("select", { onchange: e => { st.cranes = e.target.value; save(); } },
...[["", "Всё"], ["false", "Без кранов"], ["true", "Только краны"]].map(([v, t]) => h("option", { value: v, selected: st.cranes === v }, t)))) : null),
tab === "drift" ? null : h("div", { class: "row", style: "margin-top:10px" }, h("span", { class: "small muted" }, "Период:"),
quick("3 месяца", 3), quick("6 месяцев", 6), quick("год", 12), quick("2 года", 24)),
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(show) }, "Показать"),
tab === "drift" ? null : h("button", { class: "sec", title: "Одна строка на отказ или неисправность: времена, наработки, причины", onclick: guard(() => download("failures")) }, "⬇ CSV: отказы и неисправности"),
tab === "drift" ? null : h("button", { class: "sec", title: "Плановые и внеплановые замены узлов — «цензурированные» наблюдения для прогноза ресурса", onclick: guard(() => download("service")) }, "⬇ CSV: обслуживание узлов")));
}
const n1 = v => v == null ? "—" : (Math.round(v * 10) / 10).toLocaleString("ru-RU");
const n0 = v => v == null ? "—" : Math.round(v).toLocaleString("ru-RU");
const pct = v => v == null ? "—" : (Math.round(v * 1000) / 10).toLocaleString("ru-RU") + " %";
const per1000 = v => v == null ? "—" : (Math.round(v * 1000 * 100) / 100).toLocaleString("ru-RU");
const bars = (title, list, hint) => h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, title), hint ? h("p", { class: "small muted" }, hint) : null,
list.length ? h("div", { class: "relbars" }, ...list.map(x => h("div", { class: "relbar" }, h("span", { class: "rl" }, x.name),
h("span", { class: "rb" }, h("i", { style: `width:${Math.max(3, x.pct ?? 0)}%` })), h("b", {}, x.n, x.pct != null ? h("span", { class: "small muted" }, ` · ${x.pct}%`) : null))))
: h("p", { class: "small muted" }, "Нет данных за период"));
async function show() {
if (tab === "method") return;
if (tab === "drift") return showDrift();
const q = periodQ(); q.set("confidence", st.confidence); q.set("horizon_days", st.horizon);
const j = await api("GET", "/reliability?" + q);
const F = j.fleet, conf = Math.round(j.confidence * 100);
const tile = (n, label, cls, title) => h("div", { class: "stat " + (cls || ""), title }, h("b", {}, n), h("span", {}, label));
const stats = h("div", { class: "stats" },
tile(F.machines, "машин в анализе"), tile(F.failures, "отказов", F.failures ? "relbad" : ""), tile(F.faults, "сообщений о неисправности"),
tile(F.escalated, "неисправность → отказ", F.escalated ? "relwarn" : "", "Сколько сообщений о неисправности переросли в отказ"),
tile(n0(F.theta), "наработка на отказ, ч (оценка)"), tile(n0(F.theta_lower), `не менее, ч (доверие ${conf} %)`),
tile(n1(F.mttr), "среднее восстановление, ч"), tile(n1(F.downtime), "простой всего, ч"), tile(F.without_failures, "без отказов"));
const help = h("p", { class: "small muted" }, `θ — средняя наработка на отказ (наработка ÷ число отказов). «θ не менее» — консервативная нижняя граница при доверии ${conf} %: `,
"даже без отказов даёт оценку по накопленной наработке (в 2,3 раза меньше её при 90 %). Kг — коэффициент готовности θ / (θ + Tв). ",
`Прогноз — вероятность хотя бы одного отказа за ${j.horizon_days} дн. при текущей интенсивности использования (поток Пуассона). `,
"«*» — нет показаний счётчика, взято календарное время.");
const tableRows = j.rows.map(r => h("tr", { class: "click", onclick: () => location.hash = "#/equipment/" + r.equipment_id },
h("td", {}, h("b", {}, r.equipment), h("div", { class: "small muted" }, [r.inventory_no, r.location, r.department].filter(Boolean).join(" · "))),
h("td", {}, n0(r.hours), r.basis === "calendar" ? h("span", { class: "muted", title: "Нет показаний счётчика — календарное время" }, "*") : null),
h("td", {}, r.failures ? h("b", { class: "s-due" }, r.failures) : "0"),
h("td", {}, r.faults || "—", r.escalated ? h("div", { class: "small", style: "color:var(--bad)" }, `→ отказ: ${r.escalated}`) : null),
h("td", {}, n0(r.theta)), h("td", {}, n0(r.theta_lower)), h("td", { title: "отказов на 1000 ч (верхняя граница)" }, per1000(r.lambda), h("div", { class: "small muted" }, "≤ " + per1000(r.lambda_upper))),
h("td", {}, n1(r.mttr), r.wait_mean ? h("div", { class: "small muted", title: "из них ожидание запчастей" }, "запч. " + n1(r.wait_mean)) : null),
h("td", {}, pct(r.availability ?? r.availability_period)), h("td", {}, n1(r.downtime)),
h("td", {}, h("b", { class: r.forecast_p >= 0.5 ? "s-due" : r.forecast_p >= 0.2 ? "s-soon" : "s-ok" }, pct(r.forecast_p)))));
const table = h("div", { class: "card tablewrap" }, h("h2", { style: "margin-top:0" }, "По машинам"), help,
j.rows.length ? h("table", { class: "reltable" }, h("thead", {}, h("tr", {}, ...["Машина", "Наработка, ч", "Отказы", "Неиспр.", "θ, ч", `θ ≥ (${conf} %)`, "λ на 1000 ч",
"Tв, ч", "Kг", "Простой, ч", `Прогноз ${j.horizon_days} дн.`].map(t => h("th", {}, t)))), h("tbody", {}, ...tableRows))
: h("p", { class: "muted" }, "Нет оборудования по фильтру"));
const maxM = Math.max(1, ...j.months.map(m => m.n));
const months = h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Отказы по месяцам"),
j.months.length ? h("div", { class: "relmonths" }, ...j.months.map(m => h("div", { class: "rm", title: `${m.month}: ${m.n}` },
h("i", { style: `height:${Math.round(100 * m.n / maxM)}%` }), h("b", {}, m.n), h("span", {}, m.month.slice(5) + "." + m.month.slice(2, 4)))))
: h("p", { class: "small muted" }, "Отказов за период не было"));
const topMax = Math.max(1, ...j.cause_units.map(y => y.n));
fill(result, stats, table,
h("div", { class: "relgrid" },
bars("Причины отказов", j.causes, "Если много «не установлена» — уточняйте причину при закрытии отказа"),
bars("Что сделано", j.actions), bars("Характер", j.natures),
bars("Были ли признаки заранее", j.precursors, "«Было сообщение о неисправности» и «замечено на обходе» — отказы, которые можно было предотвратить"),
bars("Узлы-причины (топ)", j.cause_units.map(x => ({ ...x, pct: Math.round(100 * x.n / topMax) }))),
months));
}
async function showDrift() {
const q = params({ days: st.days });
const list = await api("GET", "/reliability/drift?" + q);
const risky = list.filter(r => r.risk);
const fmtB = r => r.bound === "max" ? `↑ к максимуму ${r.max}` : r.bound === "min" ? `↓ к минимуму ${r.min}` : "—";
fill(result, h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, `Параметры под наблюдением: ${risky.length} из ${list.length}`),
h("p", { class: "small muted" }, "Модель «параметр — граница работоспособности»: по числовым пунктам чек-листов с нормой строится линейный тренд, ",
"считается, через сколько дней значение дойдёт до границы, и сколько раз оно уже выходило за неё (ν — выходов за 30 дней). ",
"Так можно поймать постепенный отказ до того, как он случится. Нужно хотя бы 3 значения."),
list.length ? h("div", { class: "tablewrap" }, h("table", { class: "reltable" },
h("thead", {}, h("tr", {}, ...["Машина", "Пункт", "Норма", "Последнее", "Тренд за 30 дн.", "Куда", "До границы, дн.", "Выходы за норму", "Замеров"].map(t => h("th", {}, t)))),
h("tbody", {}, ...list.map(r => h("tr", { class: r.risk ? "relrisk" : "" },
h("td", {}, h("a", { href: "#/equipment/" + r.equipment_id }, r.equipment)), h("td", {}, r.item),
h("td", {}, `${r.min ?? "…"} – ${r.max ?? "…"} ${r.unit}`), h("td", {}, `${r.last} ${r.unit}`, h("div", { class: "small muted" }, fmt(r.last_at))),
h("td", {}, (r.trend_per_30d > 0 ? "+" : "") + r.trend_per_30d), h("td", {}, fmtB(r)),
h("td", {}, r.days_to_bound == null ? "—" : h("b", { class: r.days_to_bound <= 30 ? "s-due" : r.days_to_bound <= 90 ? "s-soon" : "" }, n0(r.days_to_bound))),
h("td", {}, r.out_of_range ? h("b", { class: "s-due" }, r.out_of_range, h("span", { class: "small muted" }, ` · ν=${r.nu_per_30d}`)) : "—"),
h("td", {}, r.n))))))
: h("p", { class: "muted" }, "Нет числовых пунктов с нормой и достаточным числом замеров")));
}
function method() {
fill(result, h("div", { class: "card relmethod" }, h("h2", { style: "margin-top:0" }, "Как считается"),
h("ul", {},
h("li", {}, h("b", {}, "Отказ"), " — оборудование остановлено (запись «Отказ»). «Неисправность» — проблема есть, но машина работает; в показатели надёжности не входит, но учитывается, если перешла в отказ."),
h("li", {}, h("b", {}, "Наработка"), " — по показаниям счётчика моточасов за период; если их нет, берётся календарное время (машина считается работавшей круглосуточно — оценка консервативная)."),
h("li", {}, h("b", {}, "Интенсивность отказов"), " λ = n / T и ", h("b", {}, "наработка на отказ"), " θ = T / n (поток отказов — пуассоновский, участок нормальной эксплуатации, λ ≈ const)."),
h("li", {}, h("b", {}, "Оценка по малоотказному опыту:"), " верхняя граница λ при доверии Pα — χ²(Pα; 2n+2) / 2T. Без отказов при 90 %: λ ≤ 2,3 / T, то есть θ ≥ T / 2,3. При 50 % — «средневероятная» оценка θ ≈ T / 0,69."),
h("li", {}, h("b", {}, "Время восстановления"), " Tв — от отказа (или перевода неисправности в отказ) до ввода в эксплуатацию; отдельно видно ожидание запчастей. ",
h("b", {}, "Коэффициент готовности"), " Kг = θ / (θ + Tв)."),
h("li", {}, h("b", {}, "Прогноз:"), " ожидаемое число отказов μ = λ × наработка за горизонт (по средней загрузке машины), вероятность хотя бы одного отказа 1 − e^(−μ). Если отказов не было, берётся «средневероятная» λ."),
h("li", {}, h("b", {}, "Дрейф параметров:"), " модель «параметр — граница работоспособности»: линейный тренд числового пункта чек-листа, прогноз дня выхода за норму и частота выходов ν."),
h("li", {}, h("b", {}, "Выгрузки CSV"), " — для собственных расчётов: строка на каждый отказ и неисправность и история обслуживания узлов (плановые замены — «цензурированные» наблюдения: узел заменили, не дождавшись отказа)."),
h("li", {}, "Абсолютные цифры приблизительны (мало данных, допущения в запас). Главная польза — ", h("b", {}, "сравнение машин"), " и поиск слабых мест на одной методике."))));
}
fill($app, h("h1", {}, "Анализ надёжности"),
tabs([["#/reliability", "Показатели"], ["#/reliability/drift", "Дрейф параметров"], ["#/reliability/method", "Как считается"]], "#/reliability" + (tab === "summary" ? "" : "/" + tab)),
tab === "method" ? null : form, result);
if (tab === "method") return method();
drawForm();
await show();
}
+104
View File
@@ -0,0 +1,104 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Сообщить о неисправности</title>
<style>
:root{--bg:#f4f5f8;--card:#fff;--text:#1d2330;--muted:#6b7385;--line:#dfe3ea;--accent:#2563eb;--bad:#c62828;--ok:#16803c}
@media (prefers-color-scheme:dark){:root{--bg:#12151c;--card:#1b2029;--text:#e6e9f0;--muted:#98a1b3;--line:#2c3340;--accent:#6c9bff;--bad:#f87171;--ok:#4ade80}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
main{max-width:560px;margin:0 auto;padding:24px 16px 40px}
h1{font-size:24px;margin:0 0 4px}
.muted{color:var(--muted)}.small{font-size:13px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;margin-top:16px}
label{display:block;font-size:14px;font-weight:600;margin:14px 0 6px}
label:first-child{margin-top:0}
label span{color:var(--bad)}
input,select,textarea{width:100%;font:inherit;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--text);min-height:48px}
textarea{min-height:120px;resize:vertical}
select:disabled{opacity:.55}
.opts{display:flex;flex-direction:column;gap:6px}.opt{display:flex;gap:8px;align-items:center;font-weight:400;margin:0}
.hint{font-size:13px;color:var(--muted);margin-top:4px}
button{font:inherit;font-weight:600;min-height:52px;width:100%;margin-top:18px;border:0;border-radius:12px;background:#c62828;color:#fff;cursor:pointer}
button:disabled{opacity:.5}
.err{color:var(--bad);margin-top:12px}
.done{text-align:center}.done b{font-size:44px;display:block;color:var(--ok)}
.trap{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}
</style>
</head>
<body>
<main id="app"><p class="muted">Загрузка…</p></main>
<script>
"use strict";
const $ = document.getElementById("app");
const h = (t, a, ...k) => { const e = document.createElement(t); for (const [n, v] of Object.entries(a || {})) { if (v == null || v === false) continue; if (n.startsWith("on")) e.addEventListener(n.slice(2), v); else if (n === "class") e.className = v; else if (v === true) e.setAttribute(n, ""); else e.setAttribute(n, v); } for (const c of k.flat()) if (c != null && c !== false) e.append(c.nodeType ? c : document.createTextNode(c)); return e; };
async function start() {
let o;
try { o = await (await fetch("/api/v1/public/report-options")).json(); }
catch (e) { $.replaceChildren(h("p", { class: "err" }, "Нет связи с сервером. Попробуйте позже.")); return; }
if (!o.enabled) { $.replaceChildren(h("h1", {}, "Сообщить о неисправности"), h("p", { class: "muted" }, "Приём обращений через эту форму сейчас отключён. Свяжитесь с технической службой по телефону.")); return; }
// место выбирается из дерева: показываем путь «Цех №1 / Компрессорная»
const byId = Object.fromEntries(o.locations.map(l => [l.id, l]));
const path = l => { const p = []; let x = l, guard = 0; while (x && guard++ < 20) { p.unshift(x.name); x = byId[x.parent_id]; } return p.join(" / "); };
const under = id => { const out = new Set([id]); let grown = true; while (grown) { grown = false; for (const l of o.locations) if (!out.has(l.id) && out.has(l.parent_id)) { out.add(l.id); grown = true; } } return out; };
const locs = o.locations.map(l => ({ id: l.id, label: path(l) })).filter(l => o.equipment.some(e => under(l.id).has(e.location_id))).sort((a, b) => a.label.localeCompare(b.label, "ru"));
const loc = h("select", { required: true }, h("option", { value: "" }, "— выберите место —"), ...locs.map(l => h("option", { value: l.id }, l.label)));
const eq = h("select", { required: true, disabled: true }, h("option", { value: "" }, "— сначала выберите место —"));
const desc = h("textarea", { required: true, maxlength: 4000, placeholder: "Что случилось: шум, течь, не включается, искрит… Когда заметили?" });
const name = h("input", { required: true, maxlength: 150, autocomplete: "name", placeholder: "Иванов Иван" });
const contact = h("input", { required: true, maxlength: 200, autocomplete: "tel", placeholder: "+7 900 000-00-00 или почта" });
const trap = h("input", { tabindex: "-1", autocomplete: "off" });
// оборудование остановлено — инцидент (авария); работает, но есть проблема — сообщение о неисправности
const kindRadio = (v, t, on) => h("label", { class: "opt" }, h("input", { type: "radio", name: "kind", value: v, checked: on }), t);
const kinds = h("div", { class: "opts" }, kindRadio("fault", "Работает, но есть неисправность", true), kindRadio("incident", "Остановлено, авария"));
const kind = () => (kinds.querySelector("input:checked") || {}).value || "fault";
const err = h("div", { class: "err" }), btn = h("button", { type: "submit" }, "Отправить сообщение");
loc.onchange = () => {
const set = loc.value ? under(+loc.value) : new Set();
const list = o.equipment.filter(e => set.has(e.location_id));
eq.replaceChildren(h("option", { value: "" }, list.length ? "— выберите машину —" : "— в этом месте нет оборудования —"),
...list.map(e => h("option", { value: e.id }, e.name + (e.inventory_no ? ` (${e.inventory_no})` : ""))));
eq.disabled = !list.length;
};
const form = h("form", { class: "card", novalidate: true, onsubmit: async ev => {
ev.preventDefault(); err.textContent = "";
if (!loc.value) return fail("Выберите место", loc);
if (!eq.value) return fail("Выберите машину", eq);
if (desc.value.trim().length < 5) return fail("Опишите, что произошло", desc);
if (name.value.trim().length < 2) return fail("Укажите, как к вам обращаться", name);
if (contact.value.trim().length < 5) return fail("Укажите телефон или почту для связи", contact);
btn.disabled = true;
try {
const r = await fetch("/api/v1/public/incidents", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
location_id: +loc.value, equipment_id: +eq.value, kind: kind(), description: desc.value, reporter_name: name.value, reporter_contact: contact.value, website: trap.value }) });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(typeof d.detail === "string" ? d.detail : "Проверьте заполнение полей");
$.replaceChildren(h("div", { class: "card done" }, h("b", {}, "✓"), h("h1", {}, "Сообщение отправлено"),
h("p", {}, d.number ? `Номер обращения: ${d.number}.` : "", " Техническая служба уже получила уведомление и свяжется с вами при необходимости."),
h("p", {}, h("a", { href: "/report" }, "Сообщить ещё об одной неисправности"))));
} catch (e) { btn.disabled = false; err.textContent = e instanceof TypeError ? "Нет связи с сервером. Попробуйте ещё раз." : e.message; }
} },
h("label", {}, "Место ", h("span", {}, "*")), loc, h("div", { class: "hint" }, "Список машин ниже покажет только то, что стоит в этом месте."),
h("label", {}, "Машина ", h("span", {}, "*")), eq,
h("label", {}, "Оборудование ", h("span", {}, "*")), kinds,
h("label", {}, "Что произошло ", h("span", {}, "*")), desc,
h("label", {}, "Ваше имя ", h("span", {}, "*")), name,
h("label", {}, "Телефон или почта ", h("span", {}, "*")), contact,
h("div", { class: "trap", "aria-hidden": "true" }, h("label", {}, "Сайт"), trap),
err, btn);
function fail(msg, el) { err.textContent = msg; el.focus(); }
$.replaceChildren(h("h1", {}, "Сообщить о неисправности"),
h("p", { class: "muted" }, "Заметили аварию, течь, посторонний шум или другую проблему с оборудованием? Заполните форму — техническая служба получит сообщение сразу."), form,
h("p", { class: "small muted", style: "margin-top:14px" }, "Контакты нужны только для уточнения деталей и видны сотрудникам технической службы."));
}
start();
</script>
</body>
</html>
+227
View File
@@ -0,0 +1,227 @@
"use strict";
// ---------- Отчёты ----------
const REPORTS = {
"completed-orders": { title: "Выполненные наряды", desc: "Список: машина, чек-лист, кто выполнил, должность, когда." },
"equipment-state": { title: "Текущее состояние", desc: "Какие узлы машин требуют обслуживания сейчас и что потребуется скоро; остаток и прогноз." },
"people": { title: "По сотрудникам", desc: "Сколько и как качественно выполнено нарядов и задач, трудоёмкость; списком и календарём." },
"execution": { title: "Выполнение работ", desc: "По оборудованию: % выполнения чек-листов, суточных осмотров, ТО и заданий; отклонения и комментарии." },
};
const pad = n => String(n).padStart(2, "0");
const toInput = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
async function reportsPage(id) {
if (!id) {
$app.replaceChildren(h("h1", {}, "Отчёты"), h("div", { class: "tiles" },
...Object.entries(REPORTS).map(([k, r]) => h("a", { href: "#/reports/" + k, class: "tile" }, h("b", {}, r.title), h("span", { class: "muted small" }, r.desc)))));
return;
}
if (id === "equipment-state") return stateReportPage();
if (id === "execution") return executionReportPage();
if (id === "people") return peopleReportPage();
if (id !== "completed-orders") throw new Error("Отчёт не найден");
const [eq, pos, users] = await Promise.all([ref("equipment"), ref("positions"), ref("users")]);
const st = { from: "", to: "", equipment_id: "", position_id: "", user_id: "", include_review: false };
const today = new Date(); today.setHours(0, 0, 0, 0);
const setRange = (a, b) => { st.from = toInput(a); st.to = toInput(b); };
setRange(today, new Date());
const result = h("div"), form = h("div", { class: "card" });
const sel = (key, label, list, name) => h("div", {}, h("label", {}, label), h("select", { onchange: e => st[key] = e.target.value },
h("option", { value: "" }, "Все"), ...list.map(x => h("option", { value: x.id, selected: String(st[key]) === String(x.id) }, name(x)))));
function params(format) {
if (!st.from || !st.to) throw new Error("Укажите начало и конец периода (дата и время)");
if (st.to <= st.from) throw new Error("Конец периода должен быть позже начала");
const q = new URLSearchParams({ from: st.from, to: st.to + ":59", format }); // "по" включает всю указанную минуту
for (const k of ["equipment_id", "position_id", "user_id"]) if (st[k]) q.set(k, st[k]);
if (st.include_review) q.set("include_review", "true");
return q;
}
function drawForm() {
const dt = (key, label) => h("div", {}, h("label", {}, label + " *"), h("input", { type: "datetime-local", value: st[key], required: true, onchange: e => st[key] = e.target.value }));
const quick = (label, fn) => h("button", { class: "sec sm", onclick: () => { fn(); drawForm(); } }, label);
const ago = days => { const d = new Date(); d.setDate(d.getDate() - days); return d; };
form.replaceChildren(h("div", { class: "grid" }, dt("from", "С"), dt("to", "По"),
sel("equipment_id", "Машина", eq, x => x.name + (x.inventory_no ? ` (${x.inventory_no})` : "")),
sel("position_id", "Должность исполнителя", pos, x => x.name),
(() => { const d = sel("user_id", "Исполнитель", users, x => x.full_name || x.username); d.append(searchSelect(d.querySelector("select"))); return d; })(),
h("div", {}, h("label", {}, "Дополнительно"), h("label", { style: "display:flex;gap:6px;align-items:center;color:var(--text);font-size:14px" },
h("input", { type: "checkbox", checked: st.include_review, onchange: e => st.include_review = e.target.checked }), "Включать наряды на проверке"))),
h("div", { class: "row", style: "margin-top:10px" }, h("span", { class: "small muted" }, "Быстрый выбор:"),
quick("Сегодня", () => setRange(today, new Date())),
quick("Вчера", () => { const y = new Date(today); y.setDate(y.getDate() - 1); setRange(y, today); }),
quick("7 дней", () => setRange(ago(7), new Date())), quick("30 дней", () => setRange(ago(30), new Date())),
quick("Этот месяц", () => setRange(new Date(today.getFullYear(), today.getMonth(), 1), new Date()))),
h("div", { class: "row", style: "margin-top:14px" },
h("button", { onclick: guard(show) }, "Показать"),
h("button", { class: "sec", onclick: guard(() => download("pdf")) }, "⬇ PDF"),
h("button", { class: "sec", onclick: guard(() => download("csv")) }, "⬇ CSV (Excel)")));
}
async function download(format) {
const r = await authFetch(`${API}/reports/completed-orders?${params(format)}`);
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
const blob = await r.blob(), a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `completed_orders_${st.from.slice(0, 10)}_${st.to.slice(0, 10)}.${format}`;
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
async function show() {
const j = await api("GET", `/reports/completed-orders?${params("json")}`);
result.replaceChildren(h("div", { class: "card tablewrap" },
h("div", { class: "row", style: "margin-bottom:8px" }, h("b", {}, `Найдено нарядов: ${j.total}`), j.truncated ? h("span", { class: "warnline" }, "Показана только часть — сузьте период") : null),
j.rows.length ? h("table", {}, h("thead", {}, h("tr", {}, ...["№", "Когда", "Машина", "Чек-лист", "Выполнил", "Должность", "Статус", "Откл."].map(t => h("th", {}, t)))),
h("tbody", {}, ...j.rows.map(r => h("tr", { class: "click", onclick: () => location.hash = "#/order/" + r.order_id },
h("td", { class: "muted" }, r.order_id), h("td", {}, fmtFull(r.finished_at)),
h("td", {}, r.equipment, r.inventory_no ? h("span", { class: "muted small" }, ` (${r.inventory_no})`) : null, r.location ? h("div", { class: "small muted" }, r.location) : null),
h("td", {}, r.checklist), h("td", {}, r.executor), h("td", {}, r.position),
h("td", {}, r.status === "completed" ? "Выполнен" : "На проверке"),
h("td", {}, r.deviations ? h("span", { class: "badge b-overdue" }, r.deviations) : "—"))))) : h("p", { class: "muted" }, "За выбранный период выполненных нарядов нет.")));
}
$app.replaceChildren(h("a", { href: "#/reports" }, "← Отчёты"), h("h1", {}, REPORTS[id].title), form, result);
drawForm();
}
const fmtFull = s => s ? new Date(s).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—";
// ---------- Отчёт «Выполнение работ» ----------
async function executionReportPage() {
const [eq, locs] = await Promise.all([ref("equipment"), ref("locations")]);
const st = { from: "", to: "", equipment_id: "", location_id: "" };
const today = new Date(); today.setHours(0, 0, 0, 0);
const setRange = (a, b) => { st.from = toInput(a); st.to = toInput(b); };
const ago = days => { const d = new Date(today); d.setDate(d.getDate() - days); return d; };
setRange(ago(7), new Date());
const result = h("div"), form = h("div", { class: "card" });
const params = format => {
if (!st.from || !st.to) throw new Error("Укажите период");
const q = new URLSearchParams({ from: st.from, to: st.to + ":59", format });
for (const k of ["equipment_id", "location_id"]) if (st[k]) q.set(k, st[k]);
return q;
};
const sel = (key, label, list, name) => h("div", {}, h("label", {}, label), h("select", { onchange: e => st[key] = e.target.value },
h("option", { value: "" }, "Все"), ...list.map(x => h("option", { value: x.id, selected: String(st[key]) === String(x.id) }, name(x)))));
const pc = c => !c || !c.planned ? h("span", { class: "muted" }, "—") :
h("div", { class: "pc" }, h("b", { class: c.percent >= 90 ? "s-ok" : c.percent >= 60 ? "s-soon" : "s-due" }, c.percent + "%"),
h("div", { class: "bar " + (c.percent >= 90 ? "" : c.percent >= 60 ? "soon" : "due") }, h("i", { style: `width:${c.percent}%` })),
h("span", { class: "small muted" }, `${c.done} из ${c.planned}`));
async function download(format) {
const r = await authFetch(`${API}/reports/execution?${params(format)}`);
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob());
a.download = `execution_${st.from.slice(0, 10)}_${st.to.slice(0, 10)}.${format}`;
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
async function show() {
const j = await api("GET", `/reports/execution?${params("json")}`);
const cats = Object.entries(j.categories);
result.replaceChildren(
h("div", { class: "stats" }, ...cats.map(([k, t]) => h("div", { class: "stat" }, h("b", {}, j.summary[k].percent == null ? "—" : j.summary[k].percent + "%"),
h("span", {}, `${t}: ${j.summary[k].done}/${j.summary[k].planned}`))),
h("div", { class: "stat" }, h("b", {}, j.total.percent == null ? "—" : j.total.percent + "%"), h("span", {}, `Всего: ${j.total.done}/${j.total.planned}`))),
j.rows.length ? h("div", { class: "card tablewrap" }, h("table", {},
h("thead", {}, h("tr", {}, h("th", {}, "Оборудование"), ...cats.map(([, t]) => h("th", {}, t)), h("th", {}, "Итого"), h("th", {}, "Пункты"), h("th", {}, "Откл."))),
h("tbody", {}, ...j.rows.map(r => {
const det = h("tr", { style: "display:none" }, h("td", { colspan: cats.length + 4 }, r.deviations.length ? h("div", {}, ...r.deviations.map(d => h("div", { class: "cm small" },
h("span", { class: "muted" }, `${fmt(d.at)} · ${d.category} · `),
d.order_id ? h("a", { href: "#/order/" + d.order_id }, `наряд №${d.order_id}`) : h("a", { href: "#/task/" + d.task_id }, `задание №${d.task_id}`),
` «${d.title}»: `, h("span", { class: d.kind === "out_of_range" ? "s-due" : "" }, d.text)))) : h("span", { class: "muted small" }, "Отклонений нет")));
return [h("tr", { class: "click", onclick: () => det.style.display = det.style.display ? "" : "none" },
h("td", {}, r.equipment, r.inventory_no ? h("span", { class: "muted small" }, ` (${r.inventory_no})`) : null, r.location ? h("div", { class: "small muted" }, r.location) : null),
...cats.map(([k]) => h("td", {}, pc(r.cats[k]))), h("td", {}, pc(r.total)),
h("td", {}, r.items_percent == null ? "—" : r.items_percent + "%"),
h("td", {}, r.deviations.length ? h("span", { class: "badge b-overdue" }, "▾ " + r.deviations.length) : "—")), det];
})))) : h("div", { class: "card muted" }, "За период работ нет"),
h("p", { class: "small muted" }, "Нажмите на строку, чтобы увидеть отклонения: значения вне нормы, возвраты с проверки, комментарии, отмены и приостановки."));
}
function draw() {
const dt = (key, label) => h("div", {}, h("label", {}, label + " *"), h("input", { type: "datetime-local", value: st[key], onchange: e => st[key] = e.target.value }));
const quick = (label, fn) => h("button", { class: "sec sm", onclick: () => { fn(); draw(); } }, label);
form.replaceChildren(h("div", { class: "grid" }, dt("from", "С"), dt("to", "По"),
sel("equipment_id", "Машина", eq, x => x.name), sel("location_id", "Место", locs, x => x.name)),
h("div", { class: "row", style: "margin-top:10px" }, h("span", { class: "small muted" }, "Быстрый выбор:"),
quick("Сегодня", () => setRange(today, new Date())), quick("7 дней", () => setRange(ago(7), new Date())),
quick("30 дней", () => setRange(ago(30), new Date())), quick("Этот месяц", () => setRange(new Date(today.getFullYear(), today.getMonth(), 1), new Date()))),
h("div", { class: "row", style: "margin-top:14px" }, h("button", { onclick: guard(show) }, "Показать"),
h("button", { class: "sec", onclick: guard(() => download("pdf")) }, "⬇ PDF"), h("button", { class: "sec", onclick: guard(() => download("csv")) }, "⬇ CSV (Excel)")));
}
$app.replaceChildren(h("a", { href: "#/reports" }, "← Отчёты"), h("h1", {}, "Выполнение работ"),
h("p", { class: "small muted" }, "Наряды по чек-листу «регламентное обслуживание» — ТО; наряды из ежедневного расписания — суточные осмотры; остальные — чек-листы. Процент — выполнено из запланированного за период."),
form, result);
draw();
await show();
}
// ---------- Отчёт «По сотрудникам» ----------
async function peopleReportPage() {
const [deps, pos, users] = await Promise.all([ref("departments"), ref("positions"), ref("users")]);
const today = new Date(); today.setHours(0, 0, 0, 0);
const st = { from: toInput(new Date(today.getFullYear(), today.getMonth(), 1)), to: toInput(new Date()), department_id: "", position_id: "", user_id: "", view: "list" };
const ago = days => { const d = new Date(today); d.setDate(d.getDate() - days); return d; };
const setRange = (a, b) => { st.from = toInput(a); st.to = toInput(b); };
const result = h("div"), form = h("div", { class: "card" });
const params = format => {
if (!st.from || !st.to) throw new Error("Укажите период");
const q = new URLSearchParams({ from: st.from, to: st.to + ":59", format });
for (const k of ["department_id", "position_id", "user_id"]) if (st[k]) q.set(k, st[k]);
return q;
};
const sel = (key, label, list, name) => { const s = h("select", { onchange: e => st[key] = e.target.value }, h("option", { value: "" }, "Все"),
...list.map(x => h("option", { value: x.id, selected: String(st[key]) === String(x.id) }, name(x)))); return h("div", {}, h("label", {}, label), key === "user_id" ? searchSelect(s) : s); };
async function download(format) {
const r = await authFetch(`${API}/reports/people?${params(format)}`);
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob());
a.download = `people_${st.from.slice(0, 10)}_${st.to.slice(0, 10)}.${format === "pdf" ? "pdf" : "csv"}`;
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
const pct = p => p == null ? h("span", { class: "muted" }, "—") : h("b", { class: p >= 90 ? "s-ok" : p >= 70 ? "s-soon" : "s-due" }, p + "%");
async function show() {
const j = await api("GET", `/reports/people?${params("json")}`);
const T = j.totals;
const stats = h("div", { class: "stats" }, ...[[T.done, "выполнено всего"], [T.orders_done, "нарядов"], [T.tasks_done, "задач и поручений"],
[T.hours, "трудоёмкость, ч"], [T.overdue, "просрочено"], [T.returns, "возвратов с проверки"]].map(([n, l]) => h("div", { class: "stat" }, h("b", {}, n), h("span", {}, l))));
const tabs = h("div", { class: "tabs" }, ...[["list", "Список"], ["cal", "Календарь"]].map(([k, t]) =>
h("a", { href: "javascript:void 0", class: st.view === k ? "on" : "", onclick: () => { st.view = k; guard(show)(); } }, t)));
let body;
if (!j.rows.length) body = h("div", { class: "card muted" }, "За период выполненных работ нет");
else if (st.view === "list") body = h("div", { class: "card tablewrap" }, h("table", {},
h("thead", {}, h("tr", {}, ...["Сотрудник", "Выполнено", "Нарядов", "Задач", "В срок", "Просрочено", "Возвраты", "Отклонения", "Отмены / приост.", "Трудоёмкость, ч"].map(t => h("th", {}, t)))),
h("tbody", {}, ...j.rows.map(r => h("tr", { class: "click", onclick: () => location.hash = "#/user/" + r.user_id },
h("td", {}, h("b", {}, r.name), h("div", { class: "small muted" }, [r.position, r.department].filter(Boolean).join(" · "))),
h("td", {}, h("b", {}, r.done), r.tasks_open ? h("div", { class: "small muted" }, `ещё открыто: ${r.tasks_open}`) : null),
h("td", {}, r.orders_done), h("td", {}, `${r.tasks_done} из ${r.tasks_total}`), h("td", {}, pct(r.on_time_pct)),
h("td", {}, r.overdue || "—"), h("td", {}, r.returns || "—"), h("td", {}, r.deviations || "—"),
h("td", {}, `${r.tasks_cancelled || 0} / ${r.tasks_paused || 0}`), h("td", {}, h("b", {}, r.hours)))))));
else {
const maxH = Math.max(1, ...j.rows.flatMap(r => Object.values(r.calendar).map(c => c.h)));
const wd = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
body = h("div", { class: "card tablewrap" }, h("table", { class: "pcal" },
h("thead", {}, h("tr", {}, h("th", {}, "Сотрудник"), ...j.days.map(d => { const dt = new Date(d + "T00:00"); return h("th", { class: [0, 6].includes(dt.getDay()) ? "we" : "" }, d.slice(8, 10), h("div", {}, wd[dt.getDay()])); }),
h("th", {}, "Итого"))),
h("tbody", {}, ...j.rows.map(r => h("tr", {}, h("td", {}, h("b", {}, r.name)),
...j.days.map(d => { const c = r.calendar[d]; return h("td", { class: "pc", style: c ? `background:color-mix(in srgb,var(--accent) ${Math.round(12 + 50 * c.h / maxH)}%,transparent)` : "",
title: c ? `${c.n} задач(и), ${c.h} ч` : "" }, c ? [h("b", {}, c.n), h("div", {}, c.h + "ч")] : ""); }),
h("td", { class: "pc" }, h("b", {}, r.done), h("div", {}, r.hours + "ч")))))),
h("p", { class: "small muted" }, "В клетке: сколько нарядов и задач выполнено в этот день и их трудоёмкость, ч. Чем темнее — тем больше часов."));
}
result.replaceChildren(stats, tabs, body,
h("p", { class: "small muted" }, "В срок — доля выполненного без просрочки. Трудоёмкость задачи — норма, если указана, иначе фактическое время «в работе»; наряда — время от начала до сдачи."));
}
function draw() {
const dt = (key, label) => h("div", {}, h("label", {}, label + " *"), h("input", { type: "datetime-local", value: st[key], onchange: e => st[key] = e.target.value }));
const quick = (label, fn) => h("button", { class: "sec sm", onclick: () => { fn(); draw(); guard(show)(); } }, label);
form.replaceChildren(h("div", { class: "grid" }, dt("from", "С"), dt("to", "По"),
sel("department_id", "Отдел", deps, x => x.name), sel("position_id", "Должность", pos, x => x.name), sel("user_id", "Сотрудник", users.filter(u => u.is_active), x => x.full_name || x.username)),
h("div", { class: "row", style: "margin-top:10px" }, h("span", { class: "small muted" }, "Быстрый выбор:"),
quick("Этот месяц", () => setRange(new Date(today.getFullYear(), today.getMonth(), 1), new Date())),
quick("Прошлый месяц", () => setRange(new Date(today.getFullYear(), today.getMonth() - 1, 1), new Date(today.getFullYear(), today.getMonth(), 1))),
quick("7 дней", () => setRange(ago(7), new Date())), quick("30 дней", () => setRange(ago(30), new Date()))),
h("div", { class: "row", style: "margin-top:14px" }, h("button", { onclick: guard(show) }, "Показать"),
h("button", { class: "sec", onclick: guard(() => download("pdf")) }, "⬇ PDF"),
h("button", { class: "sec", onclick: guard(() => download("csv")) }, "⬇ CSV: список"),
h("button", { class: "sec", onclick: guard(() => download("csv_calendar")) }, "⬇ CSV: календарь")));
}
$app.replaceChildren(h("a", { href: "#/reports" }, "← Отчёты"), h("h1", {}, "По сотрудникам"),
h("p", { class: "small muted" }, "Наряды — выполненные в периоде (по дате завершения), задачи и поручения — по их дню."), form, result);
draw();
await show();
}
+123
View File
@@ -0,0 +1,123 @@
"use strict";
// ---------- Графики работы: конструктор ----------
const PRESETS = [
{ name: "3 дня · отсыпной · 3 ночи · отсыпной · 3 выходных", steps: [
{ type: "work", days: 3, start_time: "08:00", end_time: "20:00", label: "день" }, { type: "off", days: 1, label: "отсыпной" },
{ type: "work", days: 3, start_time: "20:00", end_time: "08:00", label: "ночь" }, { type: "off", days: 1, label: "отсыпной" },
{ type: "off", days: 3, label: "выходные" }] },
{ name: "Сутки через трое", steps: [
{ type: "work", days: 1, start_time: "08:00", end_time: "08:00", label: "сутки" }, { type: "off", days: 3, label: "выходные" }] },
{ name: "2 дня / 2 ночи / 4 выходных", steps: [
{ type: "work", days: 2, start_time: "08:00", end_time: "20:00", label: "день" }, { type: "work", days: 2, start_time: "20:00", end_time: "08:00", label: "ночь" },
{ type: "off", days: 4, label: "выходные" }] },
];
const hm = t => String(t || "").slice(0, 5);
const dayWord = n => n === 1 ? "день" : (n >= 2 && n <= 4 ? "дня" : "дней");
function describeSchedule(r) {
if (r.kind === "pattern") {
const steps = (r.pattern || []).map(x => x.type === "work"
? `${x.days}×${x.label || "смена"} ${hm(x.start_time)}–${hm(x.end_time)}` : `${x.days}×${x.label || "вых."}`);
const total = (r.pattern || []).reduce((a, x) => a + x.days, 0);
return `Цикл ${total} дн.: ` + steps.join(" → ");
}
if (r.kind === "cycle") return `${r.work_days} через ${r.off_days}, ${hm(r.start_time)}–${hm(r.end_time)}`;
return `${(r.weekdays || []).map(i => DAYS[i]).join(" ")}, ${hm(r.start_time)}–${hm(r.end_time)}`;
}
async function schedulesPage() {
const box = h("div"), formBox = h("div");
$app.replaceChildren(h("div", { class: "row" }, h("h1", { style: "flex:1" }, "Графики работы"),
h("button", { onclick: () => edit(null) }, "+ Добавить")), formBox, box);
async function load() {
const [rows, pos] = await Promise.all([api("GET", "/schedules"), api("GET", "/positions")]);
box.replaceChildren(rows.length ? h("div", { class: "card tablewrap" }, h("table", {},
h("thead", {}, h("tr", {}, ...["Название", "Расписание", "Должности"].map(t => h("th", {}, t)), h("th"))),
h("tbody", {}, ...rows.map(r => h("tr", {}, h("td", {}, r.name), h("td", { class: "small" }, describeSchedule(r)),
h("td", { class: "small muted" }, pos.filter(p => p.schedule_id === r.id).map(p => p.name).join(", ") || "—"),
h("td", { class: "row end" }, h("button", { class: "sec sm", onclick: () => edit(r) }, "Изменить"),
h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить график?")) { await api("DELETE", "/schedules/" + r.id); load(); } }) }, "✕"))))))) : h("div", { class: "card muted" }, "Пока пусто"));
}
function edit(row) {
const st = row ? JSON.parse(JSON.stringify(row)) : { name: "", kind: "pattern", weekdays: [0, 1, 2, 3, 4], work_days: 2, off_days: 2, start_time: "08:00", end_time: "20:00",
pattern: JSON.parse(JSON.stringify(PRESETS[0].steps)), cycle_start: new Date().toLocaleDateString("sv") };
st.pattern = st.pattern || []; st.cycle_start = st.cycle_start || new Date().toLocaleDateString("sv");
for (const x of st.pattern) { x.start_time = hm(x.start_time); x.end_time = hm(x.end_time); }
st.start_time = hm(st.start_time); st.end_time = hm(st.end_time);
const body = h("div"), preview = h("div");
const payload = () => ({
name: st.name, kind: st.kind, weekdays: st.weekdays, work_days: st.work_days, off_days: st.off_days,
cycle_start: st.kind === "weekly" ? null : st.cycle_start || null, start_time: st.start_time, end_time: st.end_time,
pattern: st.kind === "pattern" ? st.pattern.map(x => ({ type: x.type, days: +x.days || 1, start_time: x.start_time || "08:00", end_time: x.end_time || "20:00", label: x.label || "" })) : null,
});
let timer;
const refresh = () => { clearTimeout(timer); timer = setTimeout(guard(drawPreview), 250); };
async function drawPreview() {
if (st.kind === "pattern" && !st.pattern.some(x => x.type === "work")) { preview.replaceChildren(h("p", { class: "muted small" }, "Добавьте хотя бы одну рабочую смену")); return; }
const from = st.kind === "weekly" ? new Date().toLocaleDateString("sv") : st.cycle_start;
let days;
try { days = await api("POST", "/schedules/preview", { schedule: payload(), from_date: from, days: 42 }); }
catch (e) { preview.replaceChildren(h("p", { class: "warnline" }, e.message)); return; }
const first = (new Date(days[0].date + "T00:00").getDay() + 6) % 7; // сдвиг до понедельника
const cells = [...Array(first).fill(null), ...days];
const grid = h("div", { class: "cal" }, ...DAYS.map(d => h("div", { class: "calh" }, d)),
...cells.map(d => {
if (!d) return h("div");
const night = d.work && d.end.slice(0, 10) !== d.start.slice(0, 10);
return h("div", { class: "calc " + (d.work ? (night ? "night" : "work") : "off") }, h("b", {}, new Date(d.date + "T00:00").getDate()),
d.work ? h("span", {}, d.start.slice(11, 16) + "–" + d.end.slice(11, 16)) : h("span", {}, "—"), d.label ? h("i", {}, d.label) : null);
}));
preview.replaceChildren(h("h2", {}, "Как это выглядит (6 недель с " + new Date(from + "T00:00").toLocaleDateString("ru-RU") + ")"), grid,
h("p", { class: "small muted" }, "Ночные смены показаны в день начала и заканчиваются утром следующего дня."));
}
function stepRow(x, n) {
const mv = d => () => { const j = n + d; [st.pattern[j], st.pattern[n]] = [st.pattern[n], st.pattern[j]]; draw(); };
return h("div", { class: "step " + x.type },
h("select", { onchange: e => { x.type = e.target.value; draw(); } }, h("option", { value: "work", selected: x.type === "work" }, "Смена"), h("option", { value: "off", selected: x.type === "off" }, "Выходной")),
h("input", { type: "number", min: 1, max: 60, value: x.days, title: "Сколько дней подряд", onchange: e => { x.days = +e.target.value || 1; draw(); } }),
h("span", { class: "small muted" }, dayWord(x.days)),
x.type === "work" ? [h("input", { type: "time", value: x.start_time, onchange: e => { x.start_time = e.target.value; draw(); } }), "–",
h("input", { type: "time", value: x.end_time, onchange: e => { x.end_time = e.target.value; draw(); } })] : h("span", { class: "spacer" }),
h("input", { placeholder: x.type === "work" ? "метка: день/ночь" : "метка: отсыпной", value: x.label || "", onchange: e => { x.label = e.target.value; draw(); } }),
h("button", { class: "sec sm", disabled: n === 0, onclick: mv(-1) }, "↑"), h("button", { class: "sec sm", disabled: n === st.pattern.length - 1, onclick: mv(1) }, "↓"),
h("button", { class: "sec sm", onclick: () => { st.pattern.splice(n, 1); draw(); } }, "✕"));
}
function draw() {
const name = h("input", { value: st.name, onchange: e => st.name = e.target.value });
const kind = h("select", { onchange: e => { st.kind = e.target.value; draw(); } },
...[["pattern", "Цикл со своими шагами (конструктор)"], ["cycle", "Простой цикл N через M"], ["weekly", "По дням недели"]].map(([k, t]) => h("option", { value: k, selected: st.kind === k }, t)));
const inp = (key, type, label) => h("div", {}, h("label", {}, label), h("input", { type, value: st[key] ?? "", onchange: e => { st[key] = type === "number" ? +e.target.value : e.target.value; draw(); } }));
const parts = [h("div", { class: "grid" }, h("div", {}, h("label", {}, "Название *"), name), h("div", {}, h("label", {}, "Тип графика"), kind))];
if (st.kind === "pattern") {
const total = st.pattern.reduce((a, x) => a + (+x.days || 1), 0);
parts.push(h("h2", {}, "Шаги цикла"),
h("p", { class: "small muted" }, "Шаги идут по кругу. Для ночной смены поставьте конец раньше начала (20:00–08:00), для суток — одинаковое время (08:00–08:00). «Отсыпной» — это просто выходной шаг с меткой."),
h("div", { class: "row", style: "margin-bottom:8px" }, h("span", { class: "small muted" }, "Шаблоны:"),
...PRESETS.map(p => h("button", { class: "sec sm", onclick: () => { st.pattern = JSON.parse(JSON.stringify(p.steps)); draw(); } }, p.name))),
...st.pattern.map(stepRow),
h("div", { class: "row", style: "margin-top:8px" },
h("button", { class: "sec", onclick: () => { st.pattern.push({ type: "work", days: 1, start_time: "08:00", end_time: "20:00", label: "" }); draw(); } }, "+ Смена"),
h("button", { class: "sec", onclick: () => { st.pattern.push({ type: "off", days: 1, label: "" }); draw(); } }, "+ Выходной"),
h("span", { class: "small muted" }, `Длина цикла: ${total} ${dayWord(total)}`)),
h("div", { class: "grid", style: "margin-top:12px;max-width:320px" }, h("div", {}, h("label", {}, "Первый день цикла (с какой даты начинается шаг №1)"),
h("input", { type: "date", value: st.cycle_start, onchange: e => { st.cycle_start = e.target.value; draw(); } }))));
} else if (st.kind === "cycle") {
parts.push(h("div", { class: "grid", style: "margin-top:12px" }, inp("work_days", "number", "Рабочих дней подряд"), inp("off_days", "number", "Выходных подряд"),
inp("cycle_start", "date", "Первый рабочий день цикла"), inp("start_time", "time", "Начало смены"), inp("end_time", "time", "Конец смены")));
} else {
parts.push(h("div", { style: "margin-top:12px" }, h("label", {}, "Дни недели"), h("div", { class: "days" }, ...DAYS.map((d, i) => h("label", {},
h("input", { type: "checkbox", checked: st.weekdays.includes(i), onchange: e => { st.weekdays = DAYS.map((_, j) => j).filter(j => j === i ? e.target.checked : st.weekdays.includes(j)); draw(); } }), d)))),
h("div", { class: "grid", style: "margin-top:12px;max-width:440px" }, inp("start_time", "time", "Начало смены"), inp("end_time", "time", "Конец смены")));
}
body.replaceChildren(...parts); refresh();
}
draw();
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, row ? "Изменение графика" : "Новый график"), body, preview,
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
await api(row ? "PATCH" : "POST", "/schedules" + (row ? "/" + row.id : ""), payload());
for (const k in cache) delete cache[k]; formBox.replaceChildren(); toast("Сохранено"); load();
}) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
formBox.scrollIntoView({ behavior: "smooth" });
}
await load();
}
+172
View File
@@ -0,0 +1,172 @@
"use strict";
// ---------- Регламентное обслуживание: карточка машины (узлы, наработка) и отчёт «Текущее состояние» ----------
const SVC_STATE = { due: "Требуется", soon: "Скоро", ok: "В норме", nodata: "Нет данных" };
const SVC_MODE = [["hours", "По наработке (моточасы)"], ["calendar", "По календарю"], ["both", "И то и другое — что наступит раньше"]];
const SVC_UNITS = [["day", "дней"], ["week", "недель"], ["month", "месяцев"]];
const fmtDate = s => s ? new Date(s).toLocaleDateString("ru-RU") : "—";
const nowInput = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); };
const hrs = v => v == null ? "—" : (Math.round(v * 10) / 10).toLocaleString("ru-RU");
const stateBadge = u => h("span", { class: "badge s-" + u.state }, SVC_STATE[u.state] || u.state);
const progress = u => h("div", { class: "bar " + u.state, title: u.percent + "% ресурса" }, h("i", { style: `width:${Math.min(100, u.percent)}%` }));
function remainingLine(u) {
const bits = [];
if (u.remaining_hours != null) bits.push(u.remaining_hours > 0 ? `осталось ${hrs(u.remaining_hours)} м/ч` : `просрочено на ${hrs(-u.remaining_hours)} м/ч`);
if (u.remaining_days != null) bits.push(u.remaining_days > 0 ? `осталось ${u.remaining_days} дн.` : u.remaining_days < 0 ? `просрочено на ${-u.remaining_days} дн.` : "срок — сегодня");
return bits.join(" · ") || "—";
}
async function equipmentPage(id) {
const [eq, units, meter] = await Promise.all([api("GET", "/equipment/" + id), api("GET", `/equipment/${id}/service-units`), api("GET", `/equipment/${id}/meter?limit=8`)]);
const locs = await ref("locations");
const loc = locs.find(l => l.id === eq.location_id);
const formBox = h("div"), canEdit = isEng();
const reload = () => equipmentPage(id);
const portal = await equipmentPortalBlocks(eq, units, reload);
// ----- наработка -----
const mh = h("input", { type: "number", step: "any", min: "0", placeholder: "Показание счётчика, м/ч" }), note = h("input", { placeholder: "Комментарий (необязательно)" });
const corr = h("input", { type: "checkbox" });
const meterCard = h("div", { class: "card" },
h("div", { class: "row" }, h("div", { style: "flex:1" }, h("div", { class: "small muted" }, "Текущая наработка"),
h("div", { style: "font-size:28px;font-weight:700" }, `${hrs(eq.meter_hours)} м/ч`),
h("div", { class: "small muted" }, eq.meter_updated_at ? "обновлено " + fmt(eq.meter_updated_at) : "показаний ещё не было"))),
canEdit ? h("div", { class: "grid", style: "margin-top:10px" }, h("div", {}, h("label", {}, "Новое показание"), mh), h("div", {}, h("label", {}, "Комментарий"), note),
h("div", {}, h("label", {}, "Коррекция"), h("label", { style: "display:flex;gap:6px;align-items:center;color:var(--text);font-size:14px;margin:0" }, corr,
"счётчик заменён / обнулён (допускает значение меньше текущего)"))) : null,
canEdit ? h("div", { class: "row", style: "margin-top:10px" }, h("button", { onclick: guard(async () => {
if (mh.value === "") throw new Error("Введите показание счётчика");
await api("POST", `/equipment/${id}/meter`, { hours: +mh.value, correction: corr.checked, note: note.value });
delete cache.equipment; toast("Показание сохранено"); reload();
}) }, "Внести показание")) : null,
meter.length ? h("details", { style: "margin-top:10px" }, h("summary", { class: "small muted" }, "История показаний"),
h("table", {}, h("tbody", {}, ...meter.map(r => h("tr", {}, h("td", {}, fmt(r.at)), h("td", {}, hrs(r.hours) + " м/ч"),
h("td", { class: "muted small" }, { manual: "вручную", order: "по наряду №" + r.order_id, correction: "коррекция" }[r.source] || r.source, r.note ? " · " + r.note : ""))))) ) : null);
// ----- форма узла -----
function unitForm(u) {
const v = u ? { ...u } : { name: "", notes: "", mode: "hours", interval_hours: null, interval_value: 1, interval_unit: "month", warn_percent: 10, is_active: true, last_service_at: nowInput(), last_service_hours: eq.meter_hours };
const fields = formFields([
{ k: "name", label: "Название узла (например, «Масло двигателя»)", type: "text", req: 1 },
{ k: "mode", label: "Как обслуживается", type: "enum", options: SVC_MODE, reload: 1 },
{ k: "interval_hours", label: "Через сколько моточасов", type: "number", show: x => x.mode !== "calendar" },
{ k: "interval_value", label: "Календарный интервал", type: "number", show: x => x.mode !== "hours" },
{ k: "interval_unit", label: "Единица интервала", type: "enum", options: SVC_UNITS.map(([a, b]) => [a, b]), show: x => x.mode !== "hours" },
{ k: "warn_percent", label: "Предупреждать «Скоро», когда осталось, % интервала", type: "number" },
{ k: "notes", label: "Примечание (марка масла, артикул...)", type: "textarea" },
...(u ? [{ k: "is_active", label: "Отслеживается", type: "bool" }] : [
{ k: "last_service_at", label: "Когда обслуживался в последний раз", type: "datetime", hint: "Начальное состояние: от этой даты считается календарный срок" },
{ k: "last_service_hours", label: "Наработка машины при последнем обслуживании, м/ч", type: "number", hint: "От этого значения считаются моточасы" }]),
], v);
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, u ? "Узел: " + u.name : "Добавить обслуживаемый узел"), fields.el,
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
const x = fields.vals, body = { name: x.name, notes: x.notes || "", mode: x.mode, warn_percent: x.warn_percent ?? 10,
interval_hours: x.mode === "calendar" ? null : x.interval_hours, interval_value: x.mode === "hours" ? null : x.interval_value,
interval_unit: x.mode === "hours" ? null : x.interval_unit };
if (u) { body.is_active = x.is_active; await api("PATCH", "/service-units/" + u.id, body); }
else { body.last_service_at = x.last_service_at || null; body.last_service_hours = x.last_service_hours ?? null; await api("POST", `/equipment/${id}/service-units`, body); }
toast("Сохранено"); reload();
}) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
formBox.scrollIntoView({ behavior: "smooth" });
}
// ----- сброс узла -----
function resetForm(u) {
const at = h("input", { type: "datetime-local", value: nowInput() }), hv = h("input", { type: "number", step: "any", value: eq.meter_hours, min: "0" }), nt = h("input", { placeholder: "Например: заменили вне наряда, внеплановая замена" });
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Сброс: " + u.name),
h("p", { class: "small muted" }, "Используйте, если обслуживание или замена выполнены отдельно от наряда либо при внештатной ситуации. Остаток по узлу начнёт отсчитываться заново."),
h("div", { class: "grid" }, h("div", {}, h("label", {}, "Когда обслужено"), at),
h("div", {}, h("label", {}, "Наработка машины в этот момент, м/ч"), hv), h("div", {}, h("label", {}, "Комментарий"), nt)),
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
await api("POST", `/service-units/${u.id}/reset`, { at: at.value || null, hours: hv.value === "" ? null : +hv.value, note: nt.value });
toast("Узел сброшен"); reload();
}) }, "Сбросить узел"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
formBox.scrollIntoView({ behavior: "smooth" });
}
const evBox = {};
async function toggleHistory(u, box) {
if (box.firstChild) return box.replaceChildren();
const ev = await api("GET", `/service-units/${u.id}/events`);
box.replaceChildren(h("table", {}, h("tbody", {}, ...ev.map(e => h("tr", {}, h("td", {}, fmt(e.at)), h("td", {}, hrs(e.hours) + " м/ч"),
h("td", { class: "small" }, { initial: "начальное состояние", order: "наряд №" + e.order_id, reset: "сброс", incident: "замена при отказе" }[e.kind] || e.kind, e.user_name ? " · " + e.user_name : "", e.note && e.kind !== "order" ? " · " + e.note : ""))))));
}
const unitCard = u => {
const hist = h("div");
return h("div", { class: "card", style: u.is_active ? "" : "opacity:.6" },
h("div", { class: "row" }, h("b", { style: "flex:1;font-size:16px" }, u.name, u.is_active ? "" : h("span", { class: "muted small" }, " (не отслеживается)")), stateBadge(u)),
h("div", { class: "small muted" }, u.rule, u.notes ? " · " + u.notes : ""),
h("div", { style: "margin:8px 0" }, progress(u)),
h("div", { class: "small" }, h("b", {}, remainingLine(u)),
h("span", { class: "muted" }, ` · обслужено ${fmtDate(u.last_service_at)} при ${hrs(u.last_service_hours)} м/ч`)),
u.due_date || u.forecast_date ? h("div", { class: "small muted" }, u.due_date ? "Срок по календарю: " + fmtDate(u.due_date) : "", u.due_date && u.forecast_date ? " · " : "", u.forecast_date ? "Прогноз: " + fmtDate(u.forecast_date) : "") : null,
h("div", { class: "row", style: "margin-top:8px" },
canEdit ? h("button", { class: "sec sm", onclick: () => resetForm(u) }, "↺ Сбросить") : null,
h("button", { class: "sec sm", onclick: guard(() => toggleHistory(u, hist)) }, "История"),
canEdit ? h("button", { class: "sec sm", onclick: () => unitForm(u) }, "Изменить") : null,
canEdit ? h("button", { class: "sec sm", onclick: guard(async () => { if (confirm(`Удалить узел «${u.name}» вместе с историей?`)) { await api("DELETE", "/service-units/" + u.id); reload(); } }) }, "✕") : null),
hist);
};
$app.replaceChildren(me.crane_map && eq.is_crane ? h("a", { href: "#/cranes" }, "← Краны") : h("a", { href: "#/equipment" }, "← Оборудование"),
h("h1", {}, eq.name, eq.inventory_no ? h("span", { class: "muted", style: "font-size:16px" }, " № " + eq.inventory_no) : null),
h("div", { class: "small muted", style: "margin-bottom:10px" }, loc ? "Место: " + loc.name : "", eq.notes ? " · " + eq.notes : ""),
portal.head,
meterCard,
h("div", { class: "row", style: "margin:8px 0" }, h("h2", { style: "flex:1;margin:0" }, "Обслуживаемые узлы"),
canEdit ? h("button", { onclick: () => unitForm(null) }, "+ Добавить обслуживаемый узел") : null),
formBox,
units.length ? h("div", {}, ...units.map(unitCard)) : h("div", { class: "card muted" }, "Узлов пока нет. Добавьте то, что нужно обслуживать: масло, фильтры, ремни — укажите, по наработке или по календарю."),
portal.incidents, portal.history);
}
// ---------- Отчёт «Текущее состояние» ----------
async function stateReportPage() {
const [eq, locs] = await Promise.all([ref("equipment"), ref("locations")]);
const st = { equipment_id: "", location_id: "", state: "attention", include_inactive: false };
const result = h("div"), form = h("div", { class: "card" });
const q = format => { const p = new URLSearchParams({ format, state: st.state === "all" ? "" : st.state });
for (const k of ["equipment_id", "location_id"]) if (st[k]) p.set(k, st[k]);
if (st.include_inactive) p.set("include_inactive", "true"); return p; };
const sel = (key, label, list, name) => h("div", {}, h("label", {}, label), h("select", { onchange: e => st[key] = e.target.value },
h("option", { value: "" }, "Все"), ...list.map(x => h("option", { value: x.id, selected: String(st[key]) === String(x.id) }, name(x)))));
async function download(format) {
const r = await authFetch(`${API}/reports/equipment-state?${q(format)}`);
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob());
a.download = `equipment_state_${new Date().toISOString().slice(0, 10)}.${format}`;
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
async function show() {
const j = await api("GET", `/reports/equipment-state?${q("json")}`);
const tile = (k, label) => h("div", { class: "stat", onclick: () => { st.state = k; draw(); guard(show)(); } }, h("b", { class: "s-" + k }, j.summary[k] ?? 0), h("span", {}, label));
result.replaceChildren(h("div", { class: "stats", style: "margin-bottom:10px" }, tile("due", "Требуется обслуживание"), tile("soon", "Скоро"), tile("ok", "В норме")),
h("div", { class: "card tablewrap" }, h("div", { class: "small muted", style: "margin-bottom:8px" }, `Узлов в отчёте: ${j.total} · на ${fmt(j.generated_at)}`),
j.rows.length ? h("table", {}, h("thead", {}, h("tr", {}, ...["Машина", "Узел", "Правило", "Состояние", "Обслужен", "Остаток", "Срок / прогноз"].map(t => h("th", {}, t)))),
h("tbody", {}, ...j.rows.map(r => h("tr", { class: "click", onclick: () => location.hash = "#/equipment/" + r.equipment_id },
h("td", {}, r.equipment, r.inventory_no ? h("span", { class: "muted small" }, ` (${r.inventory_no})`) : null, r.location ? h("div", { class: "small muted" }, r.location) : null),
h("td", {}, r.unit), h("td", { class: "small" }, r.rule),
h("td", {}, h("span", { class: "badge s-" + r.state }, r.state_ru), h("div", { class: "bar " + r.state, style: "margin-top:4px" }, h("i", { style: `width:${Math.min(100, r.percent)}%` }))),
h("td", { class: "small" }, fmtDate(r.last_service_at), h("div", { class: "muted" }, `${hrs(r.last_service_hours)} м/ч (сейчас ${hrs(r.meter_hours)})`)),
h("td", { class: "small" }, remainingLine(r)),
h("td", { class: "small" }, r.due_date ? "срок " + fmtDate(r.due_date) : "", r.due_date && r.forecast_date ? h("br") : null, r.forecast_date ? "прогноз " + fmtDate(r.forecast_date) : (r.due_date ? "" : "—")))))) :
h("p", { class: "muted" }, "Нет узлов, подходящих под условия. Узлы добавляются в карточке машины («Оборудование» → «Узлы»).")));
}
function draw() {
form.replaceChildren(h("div", { class: "grid" }, sel("equipment_id", "Машина", eq, x => x.name + (x.inventory_no ? ` (${x.inventory_no})` : "")),
sel("location_id", "Место", locs, x => x.name),
h("div", {}, h("label", {}, "Показать"), h("select", { onchange: e => st.state = e.target.value },
...[["attention", "Требуют внимания (требуется + скоро)"], ["due", "Только «Требуется»"], ["soon", "Только «Скоро»"], ["ok", "Только «В норме»"], ["all", "Все узлы"]]
.map(([k, t]) => h("option", { value: k, selected: st.state === k }, t)))),
h("div", {}, h("label", {}, "Дополнительно"), h("label", { style: "display:flex;gap:6px;align-items:center;color:var(--text);font-size:14px" },
h("input", { type: "checkbox", checked: st.include_inactive, onchange: e => st.include_inactive = e.target.checked }), "Включая отключённые узлы"))),
h("div", { class: "row", style: "margin-top:14px" }, h("button", { onclick: guard(show) }, "Показать"),
h("button", { class: "sec", onclick: guard(() => download("pdf")) }, "⬇ PDF"), h("button", { class: "sec", onclick: guard(() => download("csv")) }, "⬇ CSV (Excel)")));
}
$app.replaceChildren(h("a", { href: "#/reports" }, "← Отчёты"), h("h1", {}, "Текущее состояние"),
h("p", { class: "small muted" }, "Что требует обслуживания сейчас и что потребуется скоро — по правилам обслуживания узлов каждой машины."), form, result);
draw();
await show();
}
+227
View File
@@ -0,0 +1,227 @@
"use strict";
// ---------- Мастер первичной настройки ----------
// Порядок шагов повторяет зависимости данных: график → должность → сотрудник; место → оборудование → карта; склад → инструмент.
const TR = { а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts", ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya" };
const translit = s => s.toLowerCase().split("").map(c => TR[c] ?? (/[a-z0-9]/.test(c) ? c : "")).join("");
function makeLogin(fio, taken) {
const p = fio.trim().split(/\s+/);
let base = translit(p[0] || "user") + (p[1] ? "." + translit(p[1])[0] : "") + (p[2] ? translit(p[2])[0] : "");
base = base.replace(/[^a-z0-9.]/g, "") || "user";
let l = base, n = 2;
while (taken.has(l)) l = base + n++;
taken.add(l);
return l;
}
function makePassword() {
const abc = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const a = new Uint32Array(12); crypto.getRandomValues(a);
return [...a].map(x => abc[x % abc.length]).join("");
}
const norm = s => (s || "").trim().toLowerCase();
const cells = line => line.split(/[;\t]/).map(x => x.trim());
const SCHEDULE_PRESETS = [
["Пятидневка 08:00–17:00", { kind: "weekly", weekdays: [0, 1, 2, 3, 4], start_time: "08:00", end_time: "17:00" }],
["Сутки через трое", { kind: "pattern", pattern: [{ type: "work", days: 1, start_time: "08:00", end_time: "08:00", label: "сутки" }, { type: "off", days: 3, label: "выходные" }] }],
["2 дня / 2 ночи (12 ч)", { kind: "pattern", pattern: [{ type: "work", days: 2, start_time: "08:00", end_time: "20:00", label: "день" }, { type: "work", days: 2, start_time: "20:00", end_time: "08:00", label: "ночь" }, { type: "off", days: 4, label: "выходные" }] }],
["Круглосуточно (дежурная смена)", { kind: "weekly", weekdays: [0, 1, 2, 3, 4, 5, 6], start_time: "00:00", end_time: "00:00" }],
];
async function setupPage(stepKey) {
if (!me.is_admin) throw new Error("Мастер доступен администратору");
const G = p => api("GET", p).catch(() => []);
const [sch, pos, deps, locs, eqs, users, map, whs, items, cls, tpls, settings, keys, bk] = await Promise.all([
G("/schedules"), G("/positions"), G("/departments"), G("/locations"), G("/equipment"), G("/users"), G("/map"), G("/warehouses"),
G("/inventory/items"), G("/checklists"), G("/order-templates"), G("/settings"), G("/api-keys"), G("/backups")]);
const done = new Set((settings.setup_done || "").split(",").filter(Boolean));
const markDone = async (k, on = true) => { on ? done.add(k) : done.delete(k); await api("PUT", "/settings", { setup_done: [...done].join(",") }); };
for (const k in cache) delete cache[k];
const reload = k => setupPage(k || stepKey);
const byName = (list, name) => list.find(x => norm(x.name) === norm(name));
const placed = (map.equipment || []).filter(e => e.x != null).length;
/** Пакетный ввод: одна строка — одна запись; поля через «;» (или табуляцию — можно вставить столбцы из Excel). */
function bulk({ hint, example, run, after }) {
const ta = h("textarea", { style: "min-height:140px;font-family:ui-monospace,Consolas,monospace", placeholder: example });
const out = h("div");
return h("div", {}, h("p", { class: "small muted" }, hint), ta,
h("div", { class: "row", style: "margin-top:8px" }, h("button", { onclick: guard(async () => {
const lines = ta.value.split("\n").map(s => s.trim()).filter(s => s && !s.startsWith("#"));
if (!lines.length) throw new Error("Введите хотя бы одну строку");
const ok = [], bad = [];
for (const [i, line] of lines.entries()) {
try { ok.push(await run(cells(line), line)); } catch (e) { bad.push(`Строка ${i + 1} «${line}»: ${e.message}`); }
}
for (const k in cache) delete cache[k];
if (after) await after(ok);
out.replaceChildren(h("div", { class: "sheet", style: "margin-top:8px" }, `Добавлено: ${ok.length}`, bad.length ? `, с ошибками: ${bad.length}` : "",
...bad.map(b => h("div", { class: "small", style: "color:var(--bad)" }, b))));
if (!bad.length) { ta.value = ""; toast(`Добавлено: ${ok.length}`); if (!after) setTimeout(() => reload(), 700); }
}) }, "Добавить"), h("button", { class: "sec sm", onclick: () => { ta.value = example; ta.focus(); } }, "Вставить пример")), out);
}
const list = (rows, name) => rows.length ? h("div", { class: "small", style: "margin:6px 0 10px" }, h("b", {}, "Уже есть: "),
rows.slice(0, 30).map(name).join(", "), rows.length > 30 ? ` и ещё ${rows.length - 30}` : "") : null;
function usersResult(res, ok) {
if (!ok.length) return;
const tbl = h("table", {}, h("thead", {}, h("tr", {}, ...["ФИО", "Логин", "Временный пароль", "Должность", "Отдел"].map(t => h("th", {}, t)))),
h("tbody", {}, ...ok.map(r => h("tr", {}, h("td", {}, r.fio), h("td", {}, h("code", {}, r.username)), h("td", {}, h("code", {}, r.password)), h("td", {}, r.position), h("td", {}, r.dep)))));
const print = () => {
const w = window.open("", "_blank"), d = w.document;
d.title = "Учётные записи";
const st = d.createElement("style"); st.textContent = "body{font:14px sans-serif}td,th{border:1px solid #999;padding:6px 10px}table{border-collapse:collapse}";
const h2 = d.createElement("h2"); h2.textContent = "Учётные записи портала";
const p = d.createElement("p"); p.textContent = "Адрес: " + location.origin;
d.head.append(st); d.body.append(h2, p, d.importNode(tbl, true));
w.print();
};
const csv = () => {
const text = "ФИО;Логин;Пароль;Должность;Отдел\n" + ok.map(r => [r.fio, r.username, r.password, r.position, r.dep].join(";")).join("\n");
const a = h("a", { href: URL.createObjectURL(new Blob(["" + text], { type: "text/csv" })), download: "users.csv" });
document.body.append(a); a.click(); a.remove();
};
res.replaceChildren(h("div", { class: "card", style: "border-color:var(--warn)" }, h("h2", { style: "margin-top:0" }, "Логины и пароли новых сотрудников"),
h("p", { class: "small" }, "Пароли показываются только сейчас. Распечатайте или сохраните таблицу и раздайте сотрудникам; сменить пароль можно в «Пользователях»."),
tbl, h("div", { class: "row", style: "margin-top:8px" }, h("button", { class: "sec", onclick: print }, "🖨 Печать"), h("button", { class: "sec", onclick: csv }, "⬇ CSV"))));
}
const STEPS = [
{ k: "server", t: "Сервер и организация", n: () => null, why: "Часовой пояс, хранение фото, уведомления. Проверьте один раз.",
body: () => h("div", {},
h("ul", { class: "small" }, h("li", {}, `Часовой пояс сервера: ${settings.timezone} (задаётся переменной APP_TZ при развёртывании).`),
h("li", {}, `Каталог фото: ${settings.photo_dir}, лимит снимка ${settings.max_photo_mb} МБ.`),
h("li", {}, `Время сервера сейчас: ${fmt(settings.now)} — должно совпадать с часами на стене.`)),
h("a", { class: "btn sec", href: "#/server" }, "Открыть настройки сервера")) },
{ k: "schedules", t: "Графики работы", n: () => sch.length, why: "По графику система понимает, кто сейчас на смене и кому показывать наряды должности.",
body: () => h("div", {}, list(sch, x => x.name),
h("p", { class: "small muted" }, "Добавьте типовые графики одним нажатием, а сложные циклы соберите в конструкторе."),
h("div", { class: "row" }, ...SCHEDULE_PRESETS.map(([name, body]) => h("button", { class: "sec", disabled: !!byName(sch, name), onclick: guard(async () => {
await api("POST", "/schedules", { name, ...body }); toast("Добавлен график «" + name + "»"); reload();
}) }, (byName(sch, name) ? "✔ " : "+ ") + name)), h("a", { class: "btn sec", href: "#/schedules" }, "Конструктор графиков"))) },
{ k: "positions", t: "Должности", n: () => pos.length, why: "Тип должности: техник выполняет наряды, инженер планирует и проверяет. У должности — график.",
body: () => h("div", {}, list(pos, x => x.name), bulk({
hint: "Формат: Название; техник или инженер; название графика (необязательно).",
example: "Слесарь-ремонтник; техник; Сутки через трое\nЭлектромонтёр; техник; Сутки через трое\nИнженер-механик; инженер; Пятидневка 08:00–17:00",
run: async ([name, kind, s]) => {
if (!name) throw new Error("нет названия");
const sc = s ? byName(sch, s) : null;
if (s && !sc) throw new Error(`график «${s}» не найден`);
const p = await api("POST", "/positions", { name, kind: norm(kind).startsWith("инж") ? "engineer" : "technician", schedule_id: sc ? sc.id : null });
pos.push(p); return p;
} })) },
{ k: "departments", t: "Отделы", n: () => deps.length, why: "Службы и участки. Отделы можно вкладывать друг в друга.",
body: () => h("div", {}, list(deps, x => x.name), bulk({
hint: "Формат: Название; входит в отдел (необязательно). Вышестоящий отдел пишите раньше вложенного.",
example: "Служба главного инженера\nМеханическая служба; Служба главного инженера\nЭлектрослужба; Служба главного инженера",
run: async ([name, parent]) => {
const p = parent ? byName(deps, parent) : null;
if (parent && !p) throw new Error(`отдел «${parent}» не найден`);
const d = await api("POST", "/departments", { name, parent_id: p ? p.id : null }); deps.push(d); return d;
} }), h("p", { class: "small muted" }, "Руководителей отделов назначите в разделе «Отделы» после заведения сотрудников.")) },
{ k: "locations", t: "Места", n: () => locs.length, why: "Цеха, участки, помещения. По месту публичная форма сужает список машин.",
body: () => h("div", {}, list(locs, x => x.name), bulk({
hint: "Формат: Название; входит в (необязательно). Сначала крупные места, затем вложенные.",
example: "Цех №1\nКомпрессорная; Цех №1\nКотельная\nНасосная станция",
run: async ([name, parent]) => {
const p = parent ? byName(locs, parent) : null;
if (parent && !p) throw new Error(`место «${parent}» не найдено`);
const l = await api("POST", "/locations", { name, parent_id: p ? p.id : null }); locs.push(l); return l;
} })) },
{ k: "equipment", t: "Оборудование", n: () => eqs.length, why: "Машины, по которым ведутся наряды, ТО, отказы и история.",
body: () => h("div", {}, list(eqs, x => x.name), bulk({
hint: "Формат: Название; инвентарный номер; место; текущая наработка, м/ч; отдел; кран (последние три — необязательно). "
+ "Отдел — за кем закреплена машина: её увидят только сотрудники этого отдела. «кран» — машина уйдёт в раздел «Краны».",
example: "Насос Н-1; НС-401; Насосная станция; 12650; Механическая служба\nКомпрессор ВК-30; КМ-301; Компрессорная; 4180\nКран мостовой 10 т; КМ-702; Цех №2; ; Крановое хозяйство; кран",
run: async ([name, inv, place, hours, dname, crane]) => {
if (!name) throw new Error("нет названия");
const l = place ? byName(locs, place) : null;
if (place && !l) throw new Error(`место «${place}» не найдено`);
const d = dname ? byName(deps, dname) : null;
if (dname && !d) throw new Error(`отдел «${dname}» не найден`);
const e = await api("POST", "/equipment", { name, inventory_no: inv || "", location_id: l ? l.id : null, is_active: true,
department_id: d ? d.id : null, is_crane: /^(кран|да|1|\+)$/i.test((crane || "").trim()) });
const hv = +(hours || "").replace(",", ".");
if (hv > 0) await api("POST", `/equipment/${e.id}/meter`, { hours: hv, note: "начальная наработка" });
return e;
} }), h("p", { class: "small muted" }, "Иконку и обслуживаемые узлы (масло, фильтры, ремни) задайте в карточке машины: «Оборудование» → «Карточка»."),
h("a", { class: "btn sec", href: "#/equipment" }, "Открыть оборудование")) },
{ k: "users", t: "Сотрудники", n: () => users.filter(u => !u.is_admin).length, why: "Учётные записи: должность определяет график и права, отдел — принадлежность.",
body: () => {
const res = h("div");
return h("div", {}, list(users.filter(u => !u.is_admin), x => x.full_name || x.username), bulk({
hint: "Формат: ФИО; логин (пусто — придумается сам); должность; отдел; роль (склад / модератор, необязательно). Пароли создаются автоматически — распечатайте таблицу и раздайте.",
example: "Иванов Иван Петрович; ; Слесарь-ремонтник; Механическая служба\nПетров Сергей Алексеевич; ; Электромонтёр; Электрослужба\nКозлов Дмитрий Олегович; ; Слесарь-ремонтник; Механическая служба; склад",
run: async ([fio, login, p, d, role]) => {
if (!fio || fio.length < 3) throw new Error("нет ФИО");
const taken = new Set(users.map(u => u.username));
const position = p ? byName(pos, p) : null, dep = d ? byName(deps, d) : null;
if (p && !position) throw new Error(`должность «${p}» не найдена`);
if (d && !dep) throw new Error(`отдел «${d}» не найден`);
const password = makePassword(), username = login || makeLogin(fio, taken);
const u = await api("POST", "/users", { username, full_name: fio, password, position_id: position ? position.id : null, department_id: dep ? dep.id : null,
is_storekeeper: norm(role).startsWith("скл"), is_moderator: norm(role).startsWith("мод"), is_active: true });
users.push(u);
return { fio, username, password, position: position ? position.name : "", dep: dep ? dep.name : "" };
},
after: async ok => usersResult(res, ok) }), res,
h("p", { class: "small muted" }, "Роли администратора и модератора, телефоны и навыки можно поправить в «Пользователях» и карточках сотрудников."));
} },
{ k: "map", t: "Карта завода", n: () => (map.has_image ? placed : 0), why: "План предприятия: состояние машин видно с одного взгляда.",
body: () => h("div", {}, h("p", {}, map.has_image ? `Картинка загружена, на карте ${placed} из ${(map.equipment || []).length} машин.` : "Картинка плана ещё не загружена."),
h("div", { class: "row" }, filePicker(async f => { toast("Загрузка карты…"); await sendFile("PUT", "/map/image", f, 4000); toast("Карта загружена"); reload(); },
map.has_image ? "🗺 Заменить картинку" : "🗺 Загрузить картинку плана", "sec"),
h("a", { class: "btn sec", href: "#/map" }, "Расставить машины на карте")),
h("p", { class: "small muted" }, "На карте нажмите «Редактировать карту», выберите машину и кликните, где она стоит. Зоны складов отмечаются на вкладке «Склады»."),
h("p", { class: "small muted" }, settings.crane_map === "1"
? ["Краны — отдельная служба: у них своя ", h("a", { href: "#/map/cranes" }, "карта кранов"), " (можно загрузить свою картинку, иначе — план завода)."]
: ["Если кранов много и за них отвечает отдельная служба, включите «Краны — отдельная служба» в ", h("a", { href: "#/server" }, "настройках сервера"), " — у кранов будет свой раздел и своя карта."])) },
{ k: "stock", t: "Склады и инструмент", n: () => whs.length, why: "Учёт инструмента: что лежит на складах и что выдано лично сотрудникам.",
body: () => h("div", {}, list(whs, x => x.name), h("h2", {}, "Склады"), bulk({
hint: "Формат: Название склада; примечание (необязательно).", example: "Инструментальная кладовая; Ручной и электроинструмент\nСклад расходников; Смазки, СИЗ, крепёж",
run: async ([name, notes]) => { const w = await api("POST", "/warehouses", { name, notes: notes || "" }); whs.push(w); return w; } }),
h("h2", {}, "Каталог и начальные остатки"), bulk({
hint: "Формат: Название; категория; единица; инв. № (для штучного); количество; склад. Количество и склад — чтобы сразу оприходовать остаток.",
example: "Дрель аккумуляторная Makita; Электроинструмент; шт; ИН-001; 3; Инструментальная кладовая\nПерчатки х/б; СИЗ; пар; ; 200; Склад расходников",
run: async ([name, cat, unit, inv, qty, wname]) => {
const it = await api("POST", "/inventory/items", { name, category: cat || "", unit: unit || "шт", inventory_no: inv || "" });
if (qty && +qty > 0) {
const w = byName(whs, wname || "");
if (!w) throw new Error(`позиция создана, но склад «${wname || ""}» не найден — остаток не оприходован`);
await api("POST", "/inventory/moves", { kind: "receive", item_id: it.id, qty: +qty, to: { warehouse_id: w.id }, note: "Начальные остатки" });
}
return it;
} }),
h("p", { class: "small muted" }, `В каталоге позиций: ${items.length}. Администратора склада назначьте в «Пользователях» (роль «Администратор склада»).`)) },
{ k: "work", t: "Чек-листы и расписание", n: () => (cls.length && tpls.length ? cls.length : 0), why: "Что проверять (чек-листы) и как часто создавать наряды (расписание).",
body: () => h("div", {}, h("p", {}, `Чек-листов: ${cls.length}, расписаний нарядов: ${tpls.length}.`),
h("ul", { class: "small" }, h("li", {}, "Чек-лист — список задач с типом ответа (статус, выбор, число с нормой, текст) и фото."),
h("li", {}, "Для ТО по узлам машины создайте чек-лист типа «Регламентное обслуживание»."),
h("li", {}, "Расписание создаёт наряды автоматически: ежедневно (суточные осмотры), по дням недели, раз в месяц, каждые N дней.")),
h("div", { class: "row" }, h("a", { class: "btn sec", href: "#/checklists/new" }, "+ Чек-лист"), h("a", { class: "btn sec", href: "#/templates" }, "Расписание нарядов"))) },
{ k: "external", t: "Внешние обращения", n: () => null, why: "Публичная форма для подрядчиков и смежников, ключи для внешних систем.",
body: () => h("div", {}, h("p", {}, "Публичная форма: ", h("a", { href: "/report", target: "_blank" }, location.origin + "/report"),
settings.public_incidents === "0" ? " — сейчас выключена." : " — включена."),
h("p", { class: "small muted" }, `Ключей доступа к API: ${keys.filter(k => k.is_active).length}. Ссылку на форму удобно разместить на QR-кодах у оборудования.`),
h("a", { class: "btn sec", href: "#/server" }, "Настроить форму и ключи")) },
{ k: "backup", t: "Резервные копии", n: () => (bk.items || []).length, why: "Копия базы и конфигурации — защита от потери данных.",
body: () => h("div", {}, h("p", {}, (bk.auto_hours ? `Автокопии каждые ${bk.auto_hours} ч, хранится ${bk.keep}. ` : "Автокопии выключены. ") + `Копий сейчас: ${(bk.items || []).length}.`),
h("div", { class: "row" }, h("button", { onclick: guard(async () => { await api("POST", "/backups", { note: "после первичной настройки" }); toast("Копия создана"); reload(); }) }, "Создать копию сейчас"),
h("a", { class: "btn sec", href: "#/backups" }, "Резервные копии"))) },
];
const state = s => done.has(s.k) || s.n() ? "done" : "todo";
const cur = STEPS.find(s => s.k === stepKey) || STEPS.find(s => state(s) === "todo") || STEPS[0];
const idx = STEPS.indexOf(cur);
const ready = STEPS.filter(s => state(s) === "done").length;
fill($app, h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "🧭 Мастер первичной настройки"),
h("div", { class: "row", style: "margin-bottom:10px" }, h("div", { class: "bar", style: "flex:1;max-width:420px;height:10px" }, h("i", { style: `width:${ready * 100 / STEPS.length}%` })),
h("span", { class: "small muted" }, `готово ${ready} из ${STEPS.length}`)),
h("div", { class: "wizard" },
h("div", { class: "wsteps" }, ...STEPS.map((s, i) => h("a", { href: "#/setup/" + s.k, class: (s === cur ? "on " : "") + state(s) },
h("span", { class: "wn" }, state(s) === "done" ? "✓" : String(i + 1)), h("span", {}, s.t, s.n() ? h("small", {}, ` · ${s.n()}`) : null)))),
h("div", { class: "card wbody" }, h("div", { class: "small muted" }, `Шаг ${idx + 1} из ${STEPS.length}`), h("h2", { style: "margin-top:2px" }, cur.t),
h("p", { class: "muted" }, cur.why), cur.body(),
h("div", { class: "row", style: "margin-top:18px;border-top:1px solid var(--line);padding-top:12px" },
idx > 0 ? h("a", { class: "btn sec", href: "#/setup/" + STEPS[idx - 1].k }, "← Назад") : null, h("span", { style: "flex:1" }),
h("label", { class: "chk" }, h("input", { type: "checkbox", checked: done.has(cur.k), onchange: guard(async e => { await markDone(cur.k, e.target.checked); reload(cur.k); }) }), "шаг выполнен"),
idx < STEPS.length - 1 ? h("a", { class: "btn", href: "#/setup/" + STEPS[idx + 1].k }, "Далее →") : h("a", { class: "btn", href: "#/admin" }, "Завершить")))));
}
+278
View File
@@ -0,0 +1,278 @@
:root{--bg:#f5f6f8;--card:#fff;--text:#1d2330;--muted:#6b7385;--line:#dfe3ea;--accent:#2563eb;--accent-text:#fff;
--ok:#16803c;--warn:#b45309;--bad:#c62828;--info:#1d4ed8}
@media (prefers-color-scheme:dark){:root{--bg:#12151c;--card:#1b2029;--text:#e6e9f0;--muted:#98a1b3;--line:#2c3340;--accent:#4c8dff;--accent-text:#0b0f17;
--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;--info:#7aa7ff}}
*{box-sizing:border-box}
a{color:var(--accent)}a.btn{color:var(--accent-text)}
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
header{background:var(--card);border-bottom:1px solid var(--line);display:flex;flex-wrap:wrap;align-items:center;gap:4px 14px;padding:8px 16px;position:sticky;top:0;z-index:5}
header .brand{font-weight:700;margin-right:8px}
header a{color:var(--muted);text-decoration:none;padding:6px 2px;border-bottom:2px solid transparent}
header a.on{color:var(--text);border-color:var(--accent)}
header .sp{flex:1}
header .who{color:var(--muted);font-size:13px}
main{max-width:1100px;margin:0 auto;padding:16px}
h1{font-size:22px;margin:4px 0 14px}h2{font-size:17px;margin:18px 0 8px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px;margin-bottom:14px}
.row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.row.end{justify-content:flex-end}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px 14px}
label{display:block;font-size:13px;color:var(--muted);margin-bottom:3px}
input,select,textarea{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:var(--bg);color:var(--text);font:inherit}
input[type=checkbox]{width:auto}
textarea{min-height:60px;resize:vertical}
button,.btn{background:var(--accent);color:var(--accent-text);border:0;border-radius:8px;padding:8px 14px;font:inherit;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block}
button.sec{background:transparent;color:var(--text);border:1px solid var(--line)}
button.bad{background:var(--bad);color:#fff}
button.sm{padding:4px 9px;font-size:13px}
button:disabled{opacity:.5;cursor:default}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:600}
tr.click{cursor:pointer}tr.click:hover{background:var(--bg)}
.tablewrap{overflow-x:auto}
.badge{display:inline-block;padding:2px 9px;border-radius:99px;font-size:12px;font-weight:600;border:1px solid currentColor;white-space:nowrap}
.b-new{color:var(--info)}.b-in_progress{color:var(--warn)}.b-review{color:#9333ea}.b-completed{color:var(--ok)}.b-cancelled{color:var(--muted)}.b-overdue{color:var(--bad)}
.muted{color:var(--muted)}.small{font-size:13px}
.bar{height:6px;background:var(--line);border-radius:4px;overflow:hidden;min-width:70px}
.bar>i{display:block;height:100%;background:var(--ok)}
.stats{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:8px 14px;cursor:pointer;min-width:100px}
.stat b{display:block;font-size:22px}.stat span{font-size:12px;color:var(--muted)}
.item{border-top:1px solid var(--line);padding:12px 0}
.item:first-child{border-top:0}
.item .q{font-weight:600;margin-bottom:6px}
.item.oor{background:color-mix(in srgb,var(--bad) 10%,transparent)}
.warnline{color:var(--bad);font-size:13px;margin-top:4px}
.edrow{display:grid;grid-template-columns:2fr 120px 1.4fr 70px 70px 70px auto;gap:6px;align-items:center;margin-bottom:6px}
@media(max-width:800px){.edrow{grid-template-columns:1fr 1fr}}
.days{display:flex;gap:8px;flex-wrap:wrap}.days label{display:flex;gap:4px;align-items:center;color:var(--text);font-size:14px;margin:0}
.login{max-width:340px;margin:12vh auto}
#toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:10px 16px;border-radius:8px;opacity:0;transition:.2s;pointer-events:none;max-width:90vw}
#toast.show{opacity:1}#toast.err{background:var(--bad);color:#fff}
.photos{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.ph{position:relative}.thumb{width:84px;height:84px;object-fit:cover;border-radius:8px;border:1px solid var(--line);cursor:zoom-in;background:var(--bg);display:block}
.ph .x{position:absolute;top:-6px;right:-6px;width:22px;height:22px;padding:0;border-radius:50%;background:var(--bad);color:#fff;font-size:11px}
.cm{border-top:1px solid var(--line);padding:8px 0}.cm:first-of-type{border-top:0}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:12px}
.tile{display:block;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px;text-decoration:none;color:var(--text)}
.tile:hover{border-color:var(--accent)}.tile b{display:block;font-size:16px;margin-bottom:3px}
.grid.form>div{display:flex;flex-direction:column}
.grid.form .lh{flex:1 0 auto;margin-bottom:3px}.grid.form .lh label{margin-bottom:1px}.hint{line-height:1.3;margin-bottom:2px}
.grid.one{grid-template-columns:minmax(0,640px)}
.step{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin-bottom:6px;padding:6px 8px;border-radius:8px;border:1px solid var(--line);border-left:4px solid var(--accent)}
.step.off{border-left-color:var(--muted)}.step input,.step select{width:auto;max-width:150px}.step input[type=number]{width:64px}.step .spacer{width:0}
.cal{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:4px}.calh{font-size:12px;color:var(--muted);text-align:center}
.calc{border-radius:6px;padding:4px;font-size:11px;min-height:54px;display:flex;flex-direction:column;border:1px solid var(--line)}
.calc b{font-size:13px}.calc i{font-style:normal;opacity:.75}
.calc.work{background:color-mix(in srgb,var(--accent) 22%,transparent)}.calc.night{background:color-mix(in srgb,#9333ea 28%,transparent)}.calc.off{color:var(--muted)}
header{gap:2px 12px}header a{font-size:14px}header .who{font-size:12px}
.demobar{flex:1 0 calc(100% + 32px);display:flex;flex-wrap:wrap;gap:8px 14px;align-items:center;background:#f59e0b;color:#1d1300;margin:-8px -16px 4px;padding:6px 16px;font-size:13px;font-weight:600}
.demobar a{color:#1d1300;font-weight:700}.demobar button{background:#1d1300;color:#fff}
a.btn.sec{background:transparent;color:var(--text);border:1px solid var(--line)}
.mprompt{display:flex;flex-wrap:wrap;gap:8px 12px;align-items:center;background:var(--accent);color:var(--accent-text);padding:10px 16px;font-size:14px;font-weight:600}.mprompt .btn{background:var(--accent-text);color:var(--accent)}.mprompt .sec{border-color:currentColor;color:inherit}
.s-due{color:var(--bad)}.s-soon{color:var(--warn)}.s-ok{color:var(--ok)}.s-nodata{color:var(--muted)}
.bar.due>i{background:var(--bad)}.bar.soon>i{background:var(--warn)}.bar.nodata>i{background:var(--muted)}
.stat b.s-due,.stat b.s-soon,.stat b.s-ok{border:0}
/* ================= Портал: боковое меню ================= */
body{display:block}
#side{position:fixed;left:0;top:0;bottom:0;width:240px;background:var(--card);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:30;overflow-y:auto}
#side:empty{display:none}
#side .brand{font-weight:800;font-size:17px;padding:16px 16px 8px}
#side nav{display:flex;flex-direction:column;padding:0 8px;flex:1}
#side nav a{display:flex;gap:10px;align-items:center;color:var(--text);text-decoration:none;padding:8px 10px;border-radius:8px;font-size:14px}
#side nav a i{font-style:normal;width:20px;text-align:center}
#side nav a:hover{background:var(--bg)}
#side nav a.on{background:color-mix(in srgb,var(--accent) 14%,transparent);color:var(--accent);font-weight:600}
#side .navh{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:12px 10px 4px}
#side .navsep{height:10px}
#side .meblock{display:flex;gap:10px;align-items:center;margin:4px 8px 6px;padding:8px;border-radius:10px;text-decoration:none;color:var(--text);background:var(--bg)}
#side .meblock b{font-size:14px}
#side .sidefoot{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--line)}
#page{margin-left:240px;min-height:100vh}
#side:empty~#page{margin-left:0}
#burger{display:none;position:fixed;left:10px;top:10px;z-index:40;width:44px;height:44px;padding:0;border-radius:12px;font-size:22px;box-shadow:0 2px 10px rgba(0,0,0,.25)}
#shade{display:none}
#banner:empty{display:none}
#banner .demobar{margin:0;flex:none}
@media (max-width:900px){
#side{transform:translateX(-100%);transition:transform .2s;width:min(300px,85vw);box-shadow:0 0 30px rgba(0,0,0,.3)}
body.menu-open #side{transform:none}
body.menu-open #shade{display:block;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:25}
#page{margin-left:0}
#burger{display:block}
#side:empty~#burger,body:has(#side:empty) #burger{display:none}
main{padding-top:62px}
h1{font-size:20px}
}
.tabs{display:flex;gap:4px;flex-wrap:wrap;margin:-4px 0 14px;border-bottom:1px solid var(--line)}
.tabs a{padding:8px 12px;text-decoration:none;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px}
.tabs a.on{color:var(--text);border-color:var(--accent);font-weight:600}
.chk{display:flex;gap:6px;align-items:center;color:var(--text);font-size:14px;margin:0}
.sheet{background:var(--bg);border-radius:8px;padding:8px 10px;font-size:14px}
.ava{width:40px;height:40px;border-radius:50%;object-fit:cover;flex:0 0 auto;background:var(--line);display:inline-flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted)}
.ava.sm{width:34px;height:34px;font-size:13px}.ava.big{width:132px;height:132px;font-size:40px}
.ava.noimg{visibility:hidden}
.people{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px}
.person{display:flex;gap:12px;align-items:center;text-decoration:none;color:var(--text);margin:0}.person:hover{border-color:var(--accent)}
.ucard{display:flex;gap:20px;flex-wrap:wrap;align-items:center}.uphoto{text-align:center}
.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:0 14px}
/* иконки оборудования */
.eqicon{width:40px;height:40px;border-radius:10px;object-fit:cover;flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center}
.eqicon.sm{width:28px;height:28px;border-radius:7px;font-size:15px}.eqicon.big{width:84px;height:84px;border-radius:18px;font-size:40px}
.eqicon.noicon{background:var(--line);color:var(--muted)}
.eqhead{display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
.eqbig{position:relative;border-radius:20px;padding:3px}
.iconpick{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}
.iconpick button{display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 4px;font-weight:400;font-size:12px}
.iconpick button.on{border-color:var(--accent);outline:2px solid var(--accent)}
.iconpick img{width:44px;height:44px}
.badge.es-alarm{color:var(--bad)}.badge.es-due{color:#d97706}.badge.es-soon{color:var(--warn)}.badge.es-ok{color:var(--ok)}
.alarm{position:absolute;top:-6px;right:-8px;background:var(--bad);color:#fff;font-weight:900;width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:14px;box-shadow:0 0 0 2px var(--card);animation:pulse 1.2s infinite}
@keyframes pulse{50%{transform:scale(1.2)}}
.st-soon,.st-due{box-shadow:0 0 0 3px #facc15,0 0 16px 6px rgba(250,204,21,.75)}
.st-due{animation:glow 1.6s infinite}
@keyframes glow{50%{box-shadow:0 0 0 3px #facc15,0 0 26px 12px rgba(250,204,21,.95)}}
.inc.open{border-left:4px solid var(--bad)}.inc.closed{opacity:.8}
.unitpick{display:flex;gap:16px;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--line)}
/* задания */
.tasklist .taskc{display:block;text-decoration:none;color:var(--text);margin-bottom:8px;border-left:4px solid var(--info)}
.taskc:hover{border-color:var(--accent)}
.ts-in_progress{border-left-color:var(--warn)!important}.ts-paused{border-left-color:#9333ea!important}.ts-done{border-left-color:var(--ok)!important;opacity:.85}.ts-cancelled{border-left-color:var(--muted)!important;opacity:.7}
.t-assigned{color:var(--info)}.t-in_progress{color:var(--warn)}.t-paused{color:#9333ea}.t-done{color:var(--ok)}.t-cancelled{color:var(--muted)}
.rq-new{color:var(--info)}.rq-approved{color:#9333ea}.rq-ordered{color:var(--warn)}.rq-done{color:var(--ok)}.rq-rejected{color:var(--muted)}
/* карта */
.mapwrap{overflow:auto;border:1px solid var(--line);border-radius:10px;background:var(--card);margin-bottom:12px}
.mapstage{position:relative;min-width:600px;user-select:none;touch-action:none}
.mapstage.placing{cursor:crosshair}
.mapimg{display:block;width:100%;height:auto;pointer-events:none}
.mk{position:absolute;transform:translate(-50%,-50%);cursor:pointer;border-radius:12px;display:flex;flex-direction:column;align-items:center;z-index:2}
.mk .eqicon{width:38px;height:38px;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.mk .mkname{font-size:11px;background:rgba(0,0,0,.65);color:#fff;padding:1px 5px;border-radius:5px;margin-top:2px;white-space:nowrap;max-width:140px;overflow:hidden;text-overflow:ellipsis}
.mk.st-soon,.mk.st-due{box-shadow:none}.mk.st-soon .eqicon,.mk.st-due .eqicon{box-shadow:0 0 0 3px #facc15,0 0 16px 6px rgba(250,204,21,.85)}
.mk.st-due .eqicon{animation:glow 1.6s infinite}
.mk .alarm{top:-8px;right:-10px}
.mk.edit{cursor:grab}.mk.drag{cursor:grabbing;z-index:5;opacity:.85}
.zone{position:absolute;border:2px solid #2563eb;background:rgba(37,99,235,.18);border-radius:4px;cursor:pointer;display:flex;align-items:flex-start}
.zone span{background:#2563eb;color:#fff;font-size:12px;padding:1px 6px;border-radius:0 0 4px 0}.zone small{opacity:.8}
.zone:hover{background:rgba(37,99,235,.3)}.zone.drawing{border-style:dashed;pointer-events:none}
.legend{display:flex;gap:16px;flex-wrap:wrap;margin-bottom:10px}
.lg{display:inline-block;width:14px;height:14px;border-radius:4px;vertical-align:middle;font-style:normal}
.lg.alarm{position:static;width:16px;height:16px;font-size:11px;animation:none;display:inline-flex}
.lg.due,.lg.soon{background:#facc15;box-shadow:0 0 6px 2px rgba(250,204,21,.8)}
.eqpick{max-height:260px;overflow:auto;margin-top:6px}.eqpick .row{padding:4px;border-radius:8px;flex-wrap:nowrap}.eqpick .row.on{background:color-mix(in srgb,var(--accent) 15%,transparent)}
.mapside{margin-bottom:10px}
.pc{min-width:90px}.pc b{font-size:15px}
body.menu-open #burger{display:none}
/* история машины */
.hchip{border:1px solid var(--line);border-radius:99px;padding:4px 12px}.hchip.on{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent)}
.hday{font-weight:700;font-size:13px;color:var(--muted);margin:14px 0 4px;border-bottom:1px solid var(--line);padding-bottom:3px}
.hrow{display:flex;gap:10px;padding:7px 0 7px 8px;border-left:3px solid var(--line);margin-left:6px}
.hrow.h-bad{border-left-color:var(--bad)}.hrow.h-ok{border-left-color:var(--ok)}.hrow.h-warn{border-left-color:var(--warn)}.hrow.h-info{border-left-color:var(--info)}
.hic{width:22px;text-align:center}
/* ячейка с кнопками: остаётся ячейкой таблицы (flex у td рвал линии строк) */
td.row{display:table-cell;text-align:right;white-space:nowrap}
td.row>*{margin:2px 0 2px 6px;vertical-align:middle}
/* поиск в выпадающем списке сотрудников */
.pick{display:flex;flex-direction:column;gap:4px}
.pick input{padding:6px 10px;font-size:14px}
/* мастер первичной настройки */
.wizard{display:grid;grid-template-columns:260px minmax(0,1fr);gap:14px;align-items:start}
.wsteps{display:flex;flex-direction:column;gap:2px;position:sticky;top:10px}
.wsteps a{display:flex;gap:10px;align-items:center;padding:8px 10px;border-radius:8px;text-decoration:none;color:var(--text);font-size:14px;border:1px solid transparent}
.wsteps a:hover{background:var(--card)}.wsteps a.on{background:var(--card);border-color:var(--accent)}
.wsteps small{color:var(--muted)}
.wn{width:24px;height:24px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;background:var(--line);flex:0 0 auto}
.wsteps a.done .wn{background:var(--ok);color:#fff}
.wbody{margin:0}
@media(max-width:800px){.wizard{grid-template-columns:1fr}.wsteps{position:static}}
.banner-setup{border-color:var(--accent);display:flex;gap:12px;align-items:center;flex-wrap:wrap}
#side .sidelinks{display:flex;flex-direction:column;padding:6px 8px;border-top:1px solid var(--line)}
#side .sidelinks a{display:flex;gap:10px;align-items:center;color:var(--muted);text-decoration:none;padding:6px 10px;border-radius:8px;font-size:13px}
#side .sidelinks a:hover{background:var(--bg);color:var(--text)}
#side .sidelinks i{font-style:normal;width:20px;text-align:center}
/* вкладка «Задачи»: четыре четверти */
.quads{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
.quad{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px;display:flex;flex-direction:column;min-height:300px}
.quad.q-orders{border-top:4px solid var(--info)}.quad.q-tasks{border-top:4px solid #9333ea}.quad.q-inc{border-top:4px solid var(--bad)}.quad.q-act{border-top:4px solid var(--ok)}
.qhead{display:flex;align-items:center;gap:8px}.qhead h2{margin:0;font-size:17px}
.qcount{background:var(--bg);border-radius:99px;padding:1px 9px;font-size:13px;font-weight:700;color:var(--muted)}
.qfilters{display:flex;flex-direction:column;gap:6px;margin:8px 0}.qfilters .row{gap:6px}
.qfilters select,.qfilters input{width:auto;flex:1;min-width:120px;padding:5px 8px;font-size:13px}
.chipf{background:none;border:1px solid var(--line);color:var(--text);border-radius:99px;padding:3px 10px;font-size:13px;font-weight:500}
.chipf.on{background:var(--accent);border-color:var(--accent);color:var(--accent-text);font-weight:700}
.qlist{display:flex;flex-direction:column;gap:6px;overflow-y:auto;max-height:430px}
.qitem{display:block;text-decoration:none;color:var(--text);border:1px solid var(--line);border-left:3px solid var(--info);border-radius:8px;padding:7px 10px}
.qitem:hover{border-color:var(--accent)}.qitem.inc.open{border-left-color:var(--bad)}.qitem.inc.closed{border-left-color:var(--ok);opacity:.85}
.qempty{padding:20px;text-align:center}.qmore{align-self:flex-end;margin-top:4px}
.qactions{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px}.qactions .btn,.qactions button{text-align:center}
.qstats{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin-top:4px}
.qstat{text-decoration:none;color:var(--text);margin:0}.qstat.warnt b{color:var(--warn)}.qstat.badt b{color:var(--bad)}
@media(max-width:1000px){.quads{grid-template-columns:1fr}}
/* календарь отчёта по сотрудникам */
.pcal th,.pcal td{padding:4px 5px;text-align:center;font-size:12px;border:1px solid var(--line)}
.pcal th:first-child,.pcal td:first-child{text-align:left;white-space:nowrap;position:sticky;left:0;background:var(--card);z-index:1}
.pcal th.we{color:var(--bad)}.pcal th div{font-weight:400;font-size:10px}
.pcal td.pc{min-width:34px}.pcal td.pc b{display:block;font-size:13px}.pcal td.pc div{font-size:10px;color:var(--muted)}
/* карта инцидентов во вкладке «Задачи» */
.qmap{margin:0 0 6px}.qmap .mapstage{min-width:0;touch-action:auto}
.qmap .mk{text-decoration:none}.qmap .mk .eqicon{width:30px;height:30px}
/* сообщения о неисправности (оборудование работает) */
:root{--fault:#ea580c}
button.fault{background:var(--fault);border-color:var(--fault);color:#fff}
.badge.es-fault,.badge.b-fault{color:var(--fault)}
.badge.kind-fault{color:var(--fault);border-color:var(--fault)}.badge.kind-incident{color:var(--bad)}
.inc.fault.open{border-left-color:var(--fault)}
.alarm.faultmk{background:var(--fault);animation:none;font-size:12px}
.mk.st-fault .eqicon{box-shadow:0 0 0 3px var(--fault),0 1px 4px rgba(0,0,0,.4)}
.qitem.inc.fault.open{border-left-color:var(--fault)}
/* шапка вкладки «Задачи»: быстрые действия и сводка */
.qtop{background:var(--card);border:1px solid var(--line);border-top:4px solid var(--ok);border-radius:12px;padding:12px;margin-bottom:14px}
.qtop .qactions{display:flex;flex-wrap:wrap;gap:8px}.qtop .qactions>*{flex:0 0 auto}
.qtop .qstats{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}.qtop .qstat{flex:1 1 130px;min-width:120px}
.quad.q-fault{border-top:4px solid var(--fault)}
.qstat.faultt b{color:var(--fault)}
/* форма закрытия отказа */
.failform{margin-top:10px}.failform label{display:block;margin:10px 0 4px}
.failform .chipsel{gap:6px}.faildet{margin-top:10px}.faildet summary{cursor:pointer;color:var(--accent);margin-bottom:6px}
/* анализ надёжности */
.stat.relbad b{color:var(--bad)}.stat.relwarn b{color:var(--fault)}
.reltable td,.reltable th{white-space:nowrap}.reltable td:first-child,.reltable td:nth-child(2){white-space:normal}
.reltable tr.relrisk td{background:color-mix(in srgb,var(--warn) 10%,transparent)}
.relgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px}.relgrid .card{margin:0}
.relbars{display:flex;flex-direction:column;gap:6px}.relbar{display:grid;grid-template-columns:minmax(120px,1.2fr) 2fr auto;gap:8px;align-items:center;font-size:13px}
.relbar .rb{height:10px;background:var(--line);border-radius:5px;overflow:hidden}.relbar .rb i{display:block;height:100%;background:var(--accent);border-radius:5px}
.relmonths{display:flex;align-items:flex-end;gap:6px;height:150px;padding-top:16px}
.relmonths .rm{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;font-size:11px;position:relative}
.relmonths .rm i{display:block;width:70%;background:var(--bad);border-radius:4px 4px 0 0;min-height:2px}
.relmonths .rm b{position:absolute;top:0;font-size:12px}.relmonths .rm span{color:var(--muted);margin-top:4px}
.relmethod li{margin-bottom:8px;line-height:1.45}
/* масштабирование карт */
.mapbox{position:relative;margin-bottom:12px}.mapbox .mapwrap{margin-bottom:0}
.mapbox.zoomed .mapwrap{max-height:78vh;cursor:grab}.mapbox.panning .mapwrap{cursor:grabbing}
.mapbox .mapstage{touch-action:pan-x pan-y}.mapbox .mapstage.editing{touch-action:none}
.mapzoom{position:absolute;top:8px;right:8px;display:flex;gap:4px;align-items:center;z-index:6;background:color-mix(in srgb,var(--card) 85%,transparent);
padding:4px;border-radius:10px;box-shadow:0 1px 4px rgba(0,0,0,.25)}
.mapzoom button{min-width:34px;padding:4px 8px;font-size:16px;line-height:1}.mapzoom .zl{font-size:12px;color:var(--muted);min-width:40px;text-align:center}
.mapbox.qmapbox{margin:0 0 6px}
/* обратная связь */
#appfoot{padding:18px 0 26px;text-align:center}
.fblink{font-size:12px;color:var(--muted);text-decoration:none;opacity:.75}.fblink:hover{opacity:1;text-decoration:underline}
.fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:16px}
.fbmodal .card{width:min(560px,100%);margin:0}.fbmodal textarea{width:100%}
.fbcard.fb-new{border-left:4px solid var(--accent)}.fbcard.fb-taken{border-left:4px solid var(--warn)}.fbcard.fb-done{opacity:.8}
+52
View File
@@ -0,0 +1,52 @@
// Service worker: push-уведомления + офлайн-оболочка мобильной версии (/m).
const CACHE = "shell-m-v3";
const SHELL = ["/m", "/static/m/m.js", "/static/m/m.css", "/static/m/manifest.webmanifest",
"/static/m/icon-192.png", "/static/m/icon-512.png", "/static/m/apple-touch-icon.png", "/static/assistant.js"];
self.addEventListener("install", e => {
e.waitUntil(caches.open(CACHE).then(c => Promise.allSettled(SHELL.map(u => c.add(u)))).then(() => self.skipWaiting()));
});
self.addEventListener("activate", e => {
e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim()));
});
const isShell = url => url.origin === self.location.origin && (url.pathname === "/m" || url.pathname === "/m/" || url.pathname.startsWith("/static/m/") || url.pathname === "/static/assistant.js");
// Сеть в приоритете (чтобы обновления приходили сразу), при отсутствии связи — кэш. API не перехватываем.
self.addEventListener("fetch", e => {
const req = e.request, url = new URL(req.url);
if (req.method !== "GET" || !isShell(url)) return;
e.respondWith((async () => {
const cache = await caches.open(CACHE);
try {
const net = await Promise.race([fetch(req), new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 4000))]);
if (net && net.ok) cache.put(url.pathname === "/m/" ? "/m" : req, net.clone());
return net;
} catch (err) {
const hit = await cache.match(url.pathname === "/m/" ? "/m" : req, { ignoreSearch: true });
if (hit) return hit;
throw err;
}
})());
});
self.addEventListener("push", e => {
let d = {};
try { d = e.data.json(); } catch (_) { d = { title: "Чек-листы", body: e.data ? e.data.text() : "" }; }
e.waitUntil(self.registration.showNotification(d.title || "Чек-листы", {
body: d.body || "", data: { url: d.url || "/" }, tag: d.url || undefined, icon: "/static/m/icon-192.png", badge: "/static/m/icon-192.png",
}));
});
self.addEventListener("notificationclick", e => {
e.notification.close();
let target = (e.notification.data && e.notification.data.url) || "/";
// на телефоне (по User-Agent) открываем мобильную версию: /#/order/5 -> /m#/o/5
const m = target.match(/#\/order\/(\d+)/);
if (m && /Android|iPhone|iPad|iPod/i.test(self.navigator.userAgent)) target = "/m#/o/" + m[1];
const url = new URL(target, self.location.origin).href;
e.waitUntil(self.clients.matchAll({ type: "window", includeUncontrolled: true }).then(list => {
for (const c of list) if ("focus" in c) { c.navigate(url); return c.focus(); }
return self.clients.openWindow(url);
}));
});
+210
View File
@@ -0,0 +1,210 @@
"use strict";
// ---------- Единая вкладка «Задачи»: всё, что нужно сотруднику, на одном экране ----------
// Шапка — быстрые действия и сводка; ниже четыре четверти: наряды, задачи и поручения, инциденты, сообщения о неисправности.
// У каждой четверти свои фильтры (запоминаются в браузере). Остальные страницы не меняются.
const WORK_KEY = "work_filters";
function workFilters() {
const def = { o: { scope: "mine", overdue: false, status: "active", q: "" },
t: { scope: "mine", day: "today", state: "active", who: "" },
i: { state: "open", mine: false, source: "", q: "", view: "list", layer: "", kind: "" } };
def.f = { ...def.i };
try { const s = JSON.parse(localStorage.getItem(WORK_KEY) || "{}"); return { o: { ...def.o, ...s.o }, t: { ...def.t, ...s.t }, i: { ...def.i, ...s.i }, f: { ...def.f, ...s.f } }; }
catch (e) { return def; }
}
const saveWork = f => { try { localStorage.setItem(WORK_KEY, JSON.stringify(f)); } catch (e) {} };
async function workPage() {
const F = workFilters(), boss = isEng();
const users = boss ? (await ref("users")).filter(u => u.is_active) : [];
const chip = (on, label, fn, title) => h("button", { class: "chipf" + (on ? " on" : ""), title, onclick: fn }, label);
const quad = (cls, title, icon, link, linkText) => {
const count = h("span", { class: "qcount" }), filters = h("div", { class: "qfilters" }), list = h("div", { class: "qlist" });
const el = h("section", { class: "quad " + cls }, h("div", { class: "qhead" }, h("h2", {}, icon, " ", title), count, h("span", { style: "flex:1" }),
link ? h("a", { href: link, class: "small" }, linkText || "все →") : null), filters, list);
return { el, count, filters, list };
};
const Q1 = quad("q-orders", "Наряды", "📋", "#/", "все наряды →");
const Q2 = quad("q-tasks", "Задачи и поручения", "🗓", "#/tasks", "все задачи →");
const Q3 = quad("q-inc", "Отказы", "❗", "#/incidents/all", "все отказы →");
const Q4 = quad("q-fault", "Неисправности", "⚠", "#/faults/all", "все сообщения →");
const top = h("section", { class: "qtop" }), topActions = h("div", { class: "qactions" }), topStats = h("div", { class: "qstats" }), topBox = h("div");
top.append(topActions, topStats, topBox);
const empty = t => h("div", { class: "muted small qempty" }, t);
const more = (n, shown, href) => n > shown ? h("a", { class: "small qmore", href }, `ещё ${n - shown} →`) : null;
const MAX = 12;
let tm;
const debounce = fn => { clearTimeout(tm); tm = setTimeout(guard(fn), 300); };
// ----- наряды -----
async function loadOrders() {
const f = F.o, p = new URLSearchParams({ limit: "200" });
if (f.scope === "mine") p.set("mine", "true");
if (f.scope === "review") p.set("to_review", "true");
if (f.overdue) p.set("overdue", "true");
if (f.status !== "active" && f.status !== "all") p.set("status", f.status);
if (f.q.trim()) p.set("q", f.q.trim());
let list = await api("GET", "/orders?" + p);
if (f.status === "active") list = list.filter(o => ["new", "in_progress", "review"].includes(o.status));
Q1.count.textContent = list.length;
fill(Q1.filters,
h("div", { class: "row" },
chip(f.scope === "mine", "Мне", () => { f.scope = "mine"; redraw("o"); }, "Назначены мне или выполняю я"),
boss ? chip(f.scope === "review", "Проверить", () => { f.scope = "review"; redraw("o"); }, "Ждут моей проверки") : null,
chip(f.scope === "all", "Все доступные", () => { f.scope = "all"; redraw("o"); }),
chip(f.overdue, "⏰ Просроченные", () => { f.overdue = !f.overdue; redraw("o"); })),
h("div", { class: "row" },
h("select", { onchange: e => { f.status = e.target.value; redraw("o"); } },
...[["active", "Активные"], ["new", "Новые"], ["in_progress", "В работе"], ["review", "На проверке"], ["completed", "Выполненные"], ["all", "Все статусы"]]
.map(([k, t]) => h("option", { value: k, selected: f.status === k }, t))),
h("input", { type: "search", placeholder: "🔍 Наряд, машина, №", value: f.q, oninput: e => { f.q = e.target.value; saveWork(F); debounce(loadOrders); } })));
fill(Q1.list, ...(list.length ? list.slice(0, MAX).map(o => h("a", { class: "qitem", href: "#/order/" + o.id },
h("div", { class: "row" }, h("b", { style: "flex:1" }, `№${o.id} ${o.title}`), badge(o)),
h("div", { class: "small muted" }, [o.equipment_name, o.executor_name || o.assignee_name, o.due_at ? "срок " + fmt(o.due_at) : ""].filter(Boolean).join(" · "),
o.total_count ? ` · ${o.done_count}/${o.total_count}` : ""),
o.can_execute ? h("div", { class: "small", style: "color:var(--accent)" }, "▶ можно выполнять") : o.can_review ? h("div", { class: "small", style: "color:#9333ea" }, "✔ ждёт вашей проверки") : null))
: [empty(f.scope === "review" ? "Проверять нечего" : "Нарядов нет")]), more(list.length, MAX, "#/"));
return list;
}
// ----- задачи и поручения -----
async function loadTasks() {
const f = F.t, p = new URLSearchParams({ limit: "300" });
if (f.scope === "mine" || !boss && f.scope === "all") p.set("mine", "true");
if (f.scope === "byme") p.set("created_by_me", "true");
if (f.day === "today") p.set("day", todayISO());
if (f.state === "active") p.set("active", "true");
if (boss && f.scope === "all" && f.who) p.set("assignee_id", f.who);
const list = await api("GET", "/tasks?" + p);
Q2.count.textContent = list.length;
const who = boss && f.scope === "all" ? searchSelect(h("select", { onchange: e => { f.who = e.target.value; redraw("t"); } }, h("option", { value: "" }, "Все сотрудники"),
...users.map(u => h("option", { value: u.id, selected: String(f.who) === String(u.id) }, u.full_name || u.username))), "🔍 Сотрудник") : null;
fill(Q2.filters,
h("div", { class: "row" },
chip(f.scope === "mine", "Мои", () => { f.scope = "mine"; redraw("t"); }, "Где я исполнитель"),
chip(f.scope === "byme", "Поставил я", () => { f.scope = "byme"; redraw("t"); }),
boss ? chip(f.scope === "all", "Все", () => { f.scope = "all"; redraw("t"); }) : null),
h("div", { class: "row" },
chip(f.day === "today", "Сегодня", () => { f.day = "today"; redraw("t"); }), chip(f.day === "all", "Все дни", () => { f.day = "all"; redraw("t"); }),
chip(f.state === "active", "Активные", () => { f.state = "active"; redraw("t"); }), chip(f.state === "all", "+ завершённые", () => { f.state = "all"; redraw("t"); })),
who);
fill(Q2.list, ...(list.length ? list.slice(0, MAX).map(t => h("a", { class: "qitem ts-" + t.status, href: "#/task/" + t.id },
h("div", { class: "row" }, h("b", { style: "flex:1" }, t.title), h("span", { class: "badge t-" + t.status }, TASK_ST[t.status])),
h("div", { class: "small muted" }, [new Date(t.day).toLocaleDateString("ru-RU"), F.t.scope !== "mine" ? t.assignee_name : (t.created_by_name ? "от " + t.created_by_name : ""),
t.equipment_name ? "⚙ " + t.equipment_name : "", t.labor_hours ? `${num(t.labor_hours)} ч` : ""].filter(Boolean).join(" · ")),
t.last_comment ? h("div", { class: "small" }, "💬 " + t.last_comment) : null))
: [empty(f.day === "today" ? "На сегодня задач нет" : "Задач нет")]), more(list.length, MAX, "#/tasks"));
return list;
}
// ----- инциденты (авария, машина стоит) и сообщения о неисправности (машина работает): одинаковые четверти -----
const KINDS = {
i: { kind: "incident", Q: Q3, ic: "❗", by: "Зафиксированы мной", all: "#/incidents/all", st: "st-alarm", mk: "!",
open: "открыт", closed: "закрыт", none: ["Открытых отказов нет", "Отказов нет"], what: "отказами" },
f: { kind: "fault", Q: Q4, ic: "⚠", by: "Сообщены мной", all: "#/faults/all", st: "st-fault", mk: "⚠",
open: "открыта", closed: "закрыта", none: ["Открытых неисправностей нет", "Сообщений о неисправности нет"], what: "неисправностями" } };
async function loadInc(key) {
const K = KINDS[key], f = F[key], Q = K.Q, p = new URLSearchParams({ limit: "100", kind: K.kind });
if (f.state === "open") p.set("status", "open");
if (f.mine) p.set("mine", "true");
if (f.source) p.set("source", f.source);
if (f.q.trim()) p.set("q", f.q.trim());
if (me.crane_map && f.kind) p.set("cranes", f.kind === "cranes" ? "true" : "false");
const list = await api("GET", "/incidents?" + p);
Q.count.textContent = list.length;
fill(Q.filters,
h("div", { class: "row" },
chip(f.state === "open", "Открытые", () => { f.state = "open"; redraw(key); }), chip(f.state === "all", "Все", () => { f.state = "all"; redraw(key); }),
chip(f.mine, K.by, () => { f.mine = !f.mine; redraw(key); }),
h("span", { style: "flex:1" }),
chip(f.view !== "map", "☰ Список", () => { f.view = "list"; redraw(key); }), chip(f.view === "map", "🗺 На карте", () => { f.view = "map"; redraw(key); }, `Машины с ${K.what} на карте завода`)),
h("div", { class: "row" },
h("select", { onchange: e => { f.source = e.target.value; redraw(key); } },
...[["", "Любой источник"], ["staff", "Сотрудники"], ["public", "Публичная форма"], ["api", "Внешние системы"]].map(([k, t]) => h("option", { value: k, selected: f.source === k }, t))),
me.crane_map ? h("select", { onchange: e => { f.kind = e.target.value; f.layer = ""; redraw(key); } },
...[["", "Оборудование и краны"], ["equipment", "Только оборудование"], ["cranes", "Только краны"]].map(([k, t]) => h("option", { value: k, selected: f.kind === k }, t))) : null,
h("input", { type: "search", placeholder: "🔍 Машина или описание", value: f.q, oninput: e => { f.q = e.target.value; saveWork(F); debounce(() => loadInc(key)); } })));
const SRC = { public: "📨 форма", api: "🔌 система" };
if (f.view === "map") { await incMap(key, list); return list; }
fill(Q.list, ...(list.length ? list.slice(0, MAX).map(i => h("a", { class: "qitem inc " + i.status + (K.kind === "fault" ? " fault" : ""), href: "#/equipment/" + i.equipment_id },
h("div", { class: "row" }, h("b", { style: "flex:1" }, (i.status === "open" ? K.ic + " " : "") + i.title),
h("span", { class: "badge " + (i.status === "open" ? (K.kind === "fault" ? "b-fault" : "b-overdue") : "b-completed") }, i.status === "open" ? K.open : K.closed)),
h("div", { class: "small muted" }, [i.equipment_name, fmt(i.occurred_at), i.author_name || i.reporter_name, SRC[i.source] || ""].filter(Boolean).join(" · "))))
: [empty(f.state === "open" ? K.none[0] : K.none[1])]), more(list.length, MAX, K.all));
return list;
}
// Карта: машины из отфильтрованного списка на карте завода (и на карте кранов, если краны — отдельная служба)
async function incMap(key, list) {
const K = KINDS[key], f = F[key], byEq = {};
for (const i of list) (byEq[i.equipment_id] ||= []).push(i);
const plant = await api("GET", "/map");
const cranes = plant.crane_map ? await api("GET", "/map?kind=crane") : null;
const layers = [["plant", "Завод", plant], ...(cranes ? [["crane", "Краны", cranes]] : [])];
// слой не выбран — первая карта, где есть нужные машины
const cur = layers.find(l => l[0] === f.layer) || layers.find(l => l[2].equipment.some(e => byEq[e.id])) || layers[0];
const m = cur[2], lk = cur[0];
const hit = m.equipment.filter(e => byEq[e.id]);
const placed = hit.filter(e => e.x != null), unplaced = hit.filter(e => e.x == null);
const known = new Set(layers.flatMap(l => l[2].equipment.map(e => e.id)));
const stage = h("div", { class: "mapstage" }, m.has_image ? authImg("/map/image?" + (lk === "crane" ? "kind=crane&" : "") + "v=" + m.image_version, { class: "mapimg", draggable: "false" }) : null,
...placed.map(e => {
const inc = byEq[e.id], open = inc.filter(i => i.status === "open").length;
return h("a", { class: "mk " + (open ? K.st : "st-ok"), href: "#/equipment/" + e.id, style: `left:${e.x}%;top:${e.y}%`,
title: e.name + "\n" + inc.map(i => (i.status === "open" ? K.ic + " " : "✓ ") + i.title).join("\n") },
eqIcon(e, "eqicon"), h("span", { class: "alarm" + (K.kind === "fault" ? " faultmk" : ""), style: open ? "" : "background:var(--muted);animation:none" },
inc.length > 1 ? String(inc.length) : open ? K.mk : "✓"),
h("span", { class: "mkname" }, e.name));
}));
fill(K.Q.list,
layers.length > 1 ? h("div", { class: "row", style: "margin-bottom:6px" }, ...layers.map(([k, t, lm]) =>
chip(lk === k, `${t} · ${lm.equipment.filter(e => byEq[e.id]).length}`, () => { f.layer = k; redraw(key); }))) : null,
m.has_image ? zoomable(stage, { cls: "qmap", key: "work-" + key + "-" + lk }) : empty(lk === "crane" ? "Картинка карты кранов не загружена" : "Карта завода ещё не загружена"),
!hit.length ? empty(`На этой карте нет машин с ${K.what} по фильтру`) : null,
unplaced.length ? h("div", { class: "small muted" }, "Не размещены на карте: ", ...unplaced.map((e, n) => [n ? ", " : "", h("a", { href: "#/equipment/" + e.id }, e.name)])) : null,
list.some(i => !known.has(i.equipment_id)) ? h("div", { class: "small muted" }, "Часть машин не показана: они выведены из работы.") : null,
h("a", { class: "small qmore", href: lk === "crane" ? "#/map/cranes" : "#/map" }, "открыть большую карту →"));
}
// ----- шапка: быстрые действия и сводка -----
async function loadSummary() {
const [st, mine, review, tasksMine, incOpen, faultOpen] = await Promise.all([api("GET", "/orders/stats"), api("GET", "/orders?mine=true&limit=500"),
boss ? api("GET", "/orders?to_review=true&limit=500") : Promise.resolve([]), api("GET", "/tasks?mine=true&active=true&limit=500"),
api("GET", "/incidents?status=open&kind=incident&limit=500"), api("GET", "/incidents?status=open&kind=fault&limit=500")]);
const tile = (n, label, href, cls) => h("a", { class: "stat qstat " + (cls || ""), href }, h("b", {}, n), h("span", {}, label));
const pickEq = kind => guard(async () => {
const fault = kind === "fault", eqs = await ref("equipment");
const sel = h("select", {}, h("option", { value: "" }, "— машина —"), ...eqs.filter(e => e.is_active).map(e => h("option", { value: e.id }, e.name + (e.inventory_no ? ` (${e.inventory_no})` : ""))));
fill(topBox, h("div", { class: "card", style: "margin-top:8px" }, h("label", {}, fault ? "На какой машине неисправность?" : "Какая машина отказала (остановилась)?"),
searchSelect(sel, "🔍 Название или инв. №"),
h("div", { class: "row", style: "margin-top:8px" },
h("button", { class: fault ? "fault" : "bad", onclick: () => { if (sel.value) location.hash = `#/equipment/${sel.value}?${fault ? "fault" : "incident"}=1`; } }, "Дальше →"),
h("button", { class: "sec", onclick: () => fill(topBox) }, "Отмена"))));
});
fill(topActions,
h("a", { class: "btn", href: "#/new-task" }, "🗓 Новая задача"),
boss ? h("a", { class: "btn sec", href: "#/new-order" }, "📋 Новый наряд") : null,
h("button", { class: "bad", onclick: pickEq("incident") }, "❗ Зафиксировать отказ"),
h("button", { class: "fault", onclick: pickEq("fault") }, "⚠ Сообщить о неисправности"),
h("a", { class: "btn sec", href: "#/requests" }, "🛒 Заявка на инструмент"),
h("a", { class: "btn sec", href: "#/my-tools" }, "🧰 Мой инструмент"));
fill(topStats,
tile(mine.filter(o => ["new", "in_progress"].includes(o.status)).length, "мои наряды в работе", "#/"),
tile(st.overdue, "нарядов просрочено", "#/", st.overdue ? "warnt" : ""),
boss ? tile(review.length, "ждут моей проверки", "#/") : null,
tile(tasksMine.length, "моих активных задач", "#/tasks"),
tile(incOpen.length, "открытых отказов", "#/incidents", incOpen.length ? "badt" : ""),
tile(faultOpen.length, "открытых неисправностей", "#/faults", faultOpen.length ? "faultt" : ""));
}
async function redraw(which) {
saveWork(F);
if (!which || which === "o") await loadOrders();
if (!which || which === "t") await loadTasks();
if (!which || which === "i") await loadInc("i");
if (!which || which === "f") await loadInc("f");
}
fill($app, h("div", { class: "row" }, h("h1", { style: "flex:1" }, "Задачи"), h("span", { class: "small muted" }, "наряды, задачи и поручения, отказы и неисправности — на одном экране")),
top, h("div", { class: "quads" }, Q1.el, Q2.el, Q3.el, Q4.el));
await Promise.all([redraw(), loadSummary()]);
timer = setInterval(guard(async () => { await redraw(); await loadSummary(); }), 60000);
}