"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) { // запасной путь (старые браузеры): через 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 pwEye(input) { const btn = h("button", { type: "button", class: "pweye", title: "Показать пароль", "aria-label": "Показать пароль" }, "👁"); btn.onclick = () => { const show = input.type === "password"; input.type = show ? "text" : "password"; btn.textContent = show ? "🙈" : "👁"; btn.title = show ? "Скрыть пароль" : "Показать пароль"; btn.setAttribute("aria-label", btn.title); }; return h("div", { class: "pwwrap" }, input, btn); } 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, pwEye(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(); })();