Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Чек-листы">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<title>Чек-листы</title>
|
||||
<link rel="manifest" href="/static/m/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/static/m/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" href="/static/m/icon-192.png">
|
||||
<link rel="stylesheet" href="/static/m/m.css">
|
||||
</head>
|
||||
<body>
|
||||
<button id="burger" aria-label="Меню">☰</button>
|
||||
<div id="shade"></div>
|
||||
<aside id="drawer"></aside>
|
||||
<header id="bar"></header>
|
||||
<main id="view"></main>
|
||||
<div id="toast"></div>
|
||||
<script src="/static/m/m.js"></script>
|
||||
<script src="/static/assistant.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
:root{--bg:#f4f5f8;--card:#fff;--text:#1d2330;--muted:#6b7385;--line:#dfe3ea;--accent:#2563eb;--on-accent:#fff;--ok:#16803c;--warn:#b45309;--bad:#c62828;--info:#1d4ed8;--soft:#eef2ff;
|
||||
--safe-t:env(safe-area-inset-top,0px);--safe-b:env(safe-area-inset-bottom,0px)}
|
||||
@media (prefers-color-scheme:dark){:root{--bg:#12151c;--card:#1b2029;--text:#e6e9f0;--muted:#98a1b3;--line:#2c3340;--accent:#6c9bff;--on-accent:#0b0f17;--ok:#4ade80;--warn:#fbbf24;--bad:#f87171;--info:#7aa7ff;--soft:#222a3b}}
|
||||
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:16px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding-bottom:calc(16px + var(--safe-b))}
|
||||
header{position:sticky;top:0;z-index:10;background:var(--card);border-bottom:1px solid var(--line);padding:calc(8px + var(--safe-t)) 14px 8px;display:flex;align-items:center;gap:8px;min-height:56px}
|
||||
header h1{font-size:18px;margin:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
header .sub{font-size:12px;color:var(--muted);display:block;font-weight:400}
|
||||
header .sub.warn{color:var(--warn)}header .sub.bad{color:var(--bad)}
|
||||
.ib{background:none;border:0;color:var(--text);font-size:22px;width:44px;height:44px;border-radius:50%;padding:0;display:flex;align-items:center;justify-content:center}
|
||||
.ib:active{background:var(--soft)}.ib.spin{animation:sp 1s linear infinite}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
a{color:var(--accent)}
|
||||
main{padding:12px;max-width:640px;margin:0 auto}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;margin-bottom:12px}
|
||||
.card.tap:active{background:var(--soft)}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.row.sp{justify-content:space-between}
|
||||
.muted{color:var(--muted)}.small{font-size:13px}.bad{color:var(--bad)}.ok{color:var(--ok)}.warn{color:var(--warn)}
|
||||
h2{font-size:17px;margin:0 0 10px}
|
||||
.title{font-weight:600;font-size:17px}
|
||||
.badge{display:inline-block;padding:2px 10px;border-radius:99px;font-size:12px;font-weight:600;border:1px solid currentColor;white-space:nowrap}
|
||||
.b-new{color:var(--info)}.b-in_progress{color:var(--warn)}.b-review{color:#9333ea}.b-completed{color:var(--ok)}.b-cancelled{color:var(--muted)}.b-overdue{color:var(--bad)}
|
||||
.bar{height:6px;border-radius:4px;background:var(--line);overflow:hidden;flex:1}.bar>i{display:block;height:100%;background:var(--ok)}
|
||||
input[type=text],input[type=password],input[type=search],input:not([type]),textarea{width:100%;font:inherit;padding:12px;border:1px solid var(--line);border-radius:12px;background:var(--bg);color:var(--text);min-height:48px}
|
||||
textarea{min-height:84px;resize:vertical}
|
||||
input:focus,textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
|
||||
label{display:block;font-size:13px;color:var(--muted);margin:0 0 4px}
|
||||
button,.btn{font:inherit;font-weight:600;min-height:48px;padding:0 18px;border-radius:12px;border:0;background:var(--accent);color:var(--on-accent);text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||
button:active,.btn:active{filter:brightness(.92)}
|
||||
button:disabled{opacity:.45}
|
||||
button.sec,.btn.sec{background:transparent;color:var(--text);border:1px solid var(--line)}
|
||||
button.bad,.btn.bad{background:var(--bad);color:#fff}
|
||||
button.block,.btn.block{width:100%}
|
||||
.chips{display:flex;gap:8px;flex-wrap:wrap}
|
||||
.chip{min-height:44px;padding:0 16px;border-radius:99px;border:1px solid var(--line);background:var(--card);color:var(--text);font-weight:500}
|
||||
.chip.on{background:var(--accent);color:var(--on-accent);border-color:var(--accent);font-weight:700}
|
||||
.chip:disabled{opacity:.55}
|
||||
.filters{display:flex;gap:8px;overflow-x:auto;padding-bottom:10px;margin:0 -12px 4px;padding-left:12px;padding-right:12px;scrollbar-width:none}
|
||||
.filters::-webkit-scrollbar{display:none}.filters .chip{flex:0 0 auto}
|
||||
.item{padding:14px 0;border-top:1px solid var(--line)}.item:first-of-type{border-top:0;padding-top:4px}
|
||||
.item .q{font-weight:600;margin-bottom:8px}
|
||||
.item.oor{background:color-mix(in srgb,var(--bad) 8%,transparent);margin:0 -14px;padding-left:14px;padding-right:14px}
|
||||
.photos{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:center}
|
||||
.ph{position:relative}.ph img{width:84px;height:84px;object-fit:cover;border-radius:10px;border:1px solid var(--line);background:var(--bg);display:block}
|
||||
.ph .x{position:absolute;top:-8px;right:-8px;width:28px;height:28px;min-height:0;padding:0;border-radius:50%;background:var(--bad);color:#fff;font-size:13px}
|
||||
.ph .p{position:absolute;left:4px;bottom:2px;color:#fff;text-shadow:0 0 3px #000}
|
||||
.notice{border-radius:12px;padding:12px;margin-bottom:12px;font-size:15px;background:color-mix(in srgb,var(--c) 14%,transparent);color:var(--c)}
|
||||
.kv{display:grid;grid-template-columns:40% 60%;gap:4px 8px;font-size:15px}.kv span:nth-child(odd){color:var(--muted)}
|
||||
.cm{padding:8px 0;border-top:1px solid var(--line)}.cm:first-of-type{border-top:0}
|
||||
.actions{position:sticky;bottom:0;background:linear-gradient(transparent,var(--bg) 30%);padding:12px 0 calc(8px + var(--safe-b));display:flex;gap:10px;z-index:5}
|
||||
.actions button{flex:1}
|
||||
.login{max-width:380px;margin:8vh auto 0;padding:0 4px}
|
||||
.login h1{font-size:26px;margin:0 0 4px}
|
||||
.stack>*+*{margin-top:12px}
|
||||
#toast{position:fixed;left:50%;bottom:calc(24px + var(--safe-b));transform:translateX(-50%);background:var(--text);color:var(--bg);padding:12px 16px;border-radius:12px;opacity:0;transition:.2s;pointer-events:none;max-width:90vw;z-index:50;font-size:15px}
|
||||
#toast.show{opacity:1}#toast.err{background:var(--bad);color:#fff}
|
||||
.viewer{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:60;display:flex;align-items:center;justify-content:center}
|
||||
.viewer img{max-width:100%;max-height:100%}
|
||||
.sheet{background:var(--soft);border-radius:12px;padding:12px;font-size:14px}
|
||||
|
||||
/* боковое меню вместо шапки: заголовок экрана прокручивается вместе со страницей, меню — по кнопке ☰ */
|
||||
header{position:static;background:transparent;border:0;min-height:60px}
|
||||
body.authed header{padding-left:66px}
|
||||
#burger{display:none;position:fixed;left:10px;top:calc(8px + var(--safe-t));z-index:40;width:48px;min-height:48px;height:48px;padding:0;border-radius:14px;font-size:24px;box-shadow:0 2px 12px rgba(0,0,0,.3)}
|
||||
body.authed #burger{display:flex}
|
||||
body.menu-open #burger{display:none}
|
||||
#drawer{position:fixed;left:0;top:0;bottom:0;width:min(310px,86vw);background:var(--card);z-index:60;transform:translateX(-102%);transition:transform .2s;overflow-y:auto;padding:calc(12px + var(--safe-t)) 10px calc(12px + var(--safe-b));box-shadow:0 0 30px rgba(0,0,0,.35)}
|
||||
body.menu-open #drawer{transform:none}
|
||||
#shade{display:none}
|
||||
body.menu-open #shade{display:block;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:55}
|
||||
#drawer .dhead{padding:8px 10px 14px;border-bottom:1px solid var(--line);margin-bottom:6px}
|
||||
#drawer .dhead b{font-size:17px}
|
||||
#drawer nav{display:flex;flex-direction:column}
|
||||
#drawer nav a{display:flex;gap:12px;align-items:center;min-height:48px;padding:0 10px;border-radius:12px;color:var(--text);text-decoration:none;font-size:16px}
|
||||
#drawer nav a i{font-style:normal;width:24px;text-align:center}
|
||||
#drawer nav a.on{background:var(--soft);color:var(--accent);font-weight:600}
|
||||
#drawer .navh{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:14px 10px 4px}
|
||||
|
||||
/* обратная связь */
|
||||
.fbm{opacity:.7;font-size:13px}
|
||||
.fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:14px}
|
||||
.fbmodal .card{width:100%;max-width:520px}.fbmodal textarea{width:100%;margin-top:8px}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Чек-листы ремонтной службы",
|
||||
"short_name": "Чек-листы",
|
||||
"description": "Обходы и обслуживание оборудования",
|
||||
"start_url": "/m",
|
||||
"scope": "/m",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#12151c",
|
||||
"theme_color": "#2563eb",
|
||||
"lang": "ru",
|
||||
"icons": [
|
||||
{ "src": "/static/m/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/static/m/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/static/m/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user