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

655 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/* Мобильная версия (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 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();
})();