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

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+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();
})();