"use strict"; // ---------- Единая вкладка «Задачи»: всё, что нужно сотруднику, на одном экране ---------- // Шапка — быстрые действия и сводка; ниже четыре четверти: наряды, задачи и поручения, инциденты, сообщения о неисправности. // У каждой четверти свои фильтры (запоминаются в браузере). Остальные страницы не меняются. const WORK_KEY = "work_filters"; function workFilters() { const def = { o: { scope: "mine", overdue: false, status: "active", q: "" }, t: { scope: "mine", day: "today", state: "active", who: "" }, i: { state: "open", mine: false, source: "", q: "", view: "list", layer: "", kind: "" } }; def.f = { ...def.i }; try { const s = JSON.parse(localStorage.getItem(WORK_KEY) || "{}"); return { o: { ...def.o, ...s.o }, t: { ...def.t, ...s.t }, i: { ...def.i, ...s.i }, f: { ...def.f, ...s.f } }; } catch (e) { return def; } } const saveWork = f => { try { localStorage.setItem(WORK_KEY, JSON.stringify(f)); } catch (e) {} }; async function workPage() { const F = workFilters(), boss = isEng(); const users = boss ? (await ref("users")).filter(u => u.is_active) : []; const chip = (on, label, fn, title) => h("button", { class: "chipf" + (on ? " on" : ""), title, onclick: fn }, label); const quad = (cls, title, icon, link, linkText) => { const count = h("span", { class: "qcount" }), filters = h("div", { class: "qfilters" }), list = h("div", { class: "qlist" }); const el = h("section", { class: "quad " + cls }, h("div", { class: "qhead" }, h("h2", {}, icon, " ", title), count, h("span", { style: "flex:1" }), link ? h("a", { href: link, class: "small" }, linkText || "все →") : null), filters, list); return { el, count, filters, list }; }; const Q1 = quad("q-orders", "Наряды", "📋", "#/", "все наряды →"); const Q2 = quad("q-tasks", "Задачи и поручения", "🗓", "#/tasks", "все задачи →"); const Q3 = quad("q-inc", "Отказы", "❗", "#/incidents/all", "все отказы →"); const Q4 = quad("q-fault", "Неисправности", "⚠", "#/faults/all", "все сообщения →"); const top = h("section", { class: "qtop" }), topActions = h("div", { class: "qactions" }), topStats = h("div", { class: "qstats" }), topBox = h("div"); top.append(topActions, topStats, topBox); const empty = t => h("div", { class: "muted small qempty" }, t); const more = (n, shown, href) => n > shown ? h("a", { class: "small qmore", href }, `ещё ${n - shown} →`) : null; const MAX = 12; let tm; const debounce = fn => { clearTimeout(tm); tm = setTimeout(guard(fn), 300); }; // ----- наряды ----- async function loadOrders() { const f = F.o, p = new URLSearchParams({ limit: "200" }); if (f.scope === "mine") p.set("mine", "true"); if (f.scope === "review") p.set("to_review", "true"); if (f.overdue) p.set("overdue", "true"); if (f.status !== "active" && f.status !== "all") p.set("status", f.status); if (f.q.trim()) p.set("q", f.q.trim()); let list = await api("GET", "/orders?" + p); if (f.status === "active") list = list.filter(o => ["new", "in_progress", "review"].includes(o.status)); Q1.count.textContent = list.length; fill(Q1.filters, h("div", { class: "row" }, chip(f.scope === "mine", "Мне", () => { f.scope = "mine"; redraw("o"); }, "Назначены мне или выполняю я"), boss ? chip(f.scope === "review", "Проверить", () => { f.scope = "review"; redraw("o"); }, "Ждут моей проверки") : null, chip(f.scope === "all", "Все доступные", () => { f.scope = "all"; redraw("o"); }), chip(f.overdue, "⏰ Просроченные", () => { f.overdue = !f.overdue; redraw("o"); })), h("div", { class: "row" }, h("select", { onchange: e => { f.status = e.target.value; redraw("o"); } }, ...[["active", "Активные"], ["new", "Новые"], ["in_progress", "В работе"], ["review", "На проверке"], ["completed", "Выполненные"], ["all", "Все статусы"]] .map(([k, t]) => h("option", { value: k, selected: f.status === k }, t))), h("input", { type: "search", placeholder: "🔍 Наряд, машина, №", value: f.q, oninput: e => { f.q = e.target.value; saveWork(F); debounce(loadOrders); } }))); fill(Q1.list, ...(list.length ? list.slice(0, MAX).map(o => h("a", { class: "qitem", href: "#/order/" + o.id }, h("div", { class: "row" }, h("b", { style: "flex:1" }, `№${o.id} ${o.title}`), badge(o)), h("div", { class: "small muted" }, [o.equipment_name, o.executor_name || o.assignee_name, o.due_at ? "срок " + fmt(o.due_at) : ""].filter(Boolean).join(" · "), o.total_count ? ` · ${o.done_count}/${o.total_count}` : ""), o.can_execute ? h("div", { class: "small", style: "color:var(--accent)" }, "▶ можно выполнять") : o.can_review ? h("div", { class: "small", style: "color:#9333ea" }, "✔ ждёт вашей проверки") : null)) : [empty(f.scope === "review" ? "Проверять нечего" : "Нарядов нет")]), more(list.length, MAX, "#/")); return list; } // ----- задачи и поручения ----- async function loadTasks() { const f = F.t, p = new URLSearchParams({ limit: "300" }); if (f.scope === "mine" || !boss && f.scope === "all") p.set("mine", "true"); if (f.scope === "byme") p.set("created_by_me", "true"); if (f.day === "today") p.set("day", todayISO()); if (f.state === "active") p.set("active", "true"); if (boss && f.scope === "all" && f.who) p.set("assignee_id", f.who); const list = await api("GET", "/tasks?" + p); Q2.count.textContent = list.length; const who = boss && f.scope === "all" ? searchSelect(h("select", { onchange: e => { f.who = e.target.value; redraw("t"); } }, h("option", { value: "" }, "Все сотрудники"), ...users.map(u => h("option", { value: u.id, selected: String(f.who) === String(u.id) }, u.full_name || u.username))), "🔍 Сотрудник") : null; fill(Q2.filters, h("div", { class: "row" }, chip(f.scope === "mine", "Мои", () => { f.scope = "mine"; redraw("t"); }, "Где я исполнитель"), chip(f.scope === "byme", "Поставил я", () => { f.scope = "byme"; redraw("t"); }), boss ? chip(f.scope === "all", "Все", () => { f.scope = "all"; redraw("t"); }) : null), h("div", { class: "row" }, chip(f.day === "today", "Сегодня", () => { f.day = "today"; redraw("t"); }), chip(f.day === "all", "Все дни", () => { f.day = "all"; redraw("t"); }), chip(f.state === "active", "Активные", () => { f.state = "active"; redraw("t"); }), chip(f.state === "all", "+ завершённые", () => { f.state = "all"; redraw("t"); })), who); fill(Q2.list, ...(list.length ? list.slice(0, MAX).map(t => h("a", { class: "qitem ts-" + t.status, href: "#/task/" + t.id }, h("div", { class: "row" }, h("b", { style: "flex:1" }, t.title), h("span", { class: "badge t-" + t.status }, TASK_ST[t.status])), h("div", { class: "small muted" }, [new Date(t.day).toLocaleDateString("ru-RU"), F.t.scope !== "mine" ? t.assignee_name : (t.created_by_name ? "от " + t.created_by_name : ""), t.equipment_name ? "⚙ " + t.equipment_name : "", t.labor_hours ? `${num(t.labor_hours)} ч` : ""].filter(Boolean).join(" · ")), t.last_comment ? h("div", { class: "small" }, "💬 " + t.last_comment) : null)) : [empty(f.day === "today" ? "На сегодня задач нет" : "Задач нет")]), more(list.length, MAX, "#/tasks")); return list; } // ----- инциденты (авария, машина стоит) и сообщения о неисправности (машина работает): одинаковые четверти ----- const KINDS = { i: { kind: "incident", Q: Q3, ic: "❗", by: "Зафиксированы мной", all: "#/incidents/all", st: "st-alarm", mk: "!", open: "открыт", closed: "закрыт", none: ["Открытых отказов нет", "Отказов нет"], what: "отказами" }, f: { kind: "fault", Q: Q4, ic: "⚠", by: "Сообщены мной", all: "#/faults/all", st: "st-fault", mk: "⚠", open: "открыта", closed: "закрыта", none: ["Открытых неисправностей нет", "Сообщений о неисправности нет"], what: "неисправностями" } }; async function loadInc(key) { const K = KINDS[key], f = F[key], Q = K.Q, p = new URLSearchParams({ limit: "100", kind: K.kind }); if (f.state === "open") p.set("status", "open"); if (f.mine) p.set("mine", "true"); if (f.source) p.set("source", f.source); if (f.q.trim()) p.set("q", f.q.trim()); if (me.crane_map && f.kind) p.set("cranes", f.kind === "cranes" ? "true" : "false"); const list = await api("GET", "/incidents?" + p); Q.count.textContent = list.length; fill(Q.filters, h("div", { class: "row" }, chip(f.state === "open", "Открытые", () => { f.state = "open"; redraw(key); }), chip(f.state === "all", "Все", () => { f.state = "all"; redraw(key); }), chip(f.mine, K.by, () => { f.mine = !f.mine; redraw(key); }), h("span", { style: "flex:1" }), chip(f.view !== "map", "☰ Список", () => { f.view = "list"; redraw(key); }), chip(f.view === "map", "🗺 На карте", () => { f.view = "map"; redraw(key); }, `Машины с ${K.what} на карте завода`)), h("div", { class: "row" }, h("select", { onchange: e => { f.source = e.target.value; redraw(key); } }, ...[["", "Любой источник"], ["staff", "Сотрудники"], ["public", "Публичная форма"], ["api", "Внешние системы"]].map(([k, t]) => h("option", { value: k, selected: f.source === k }, t))), me.crane_map ? h("select", { onchange: e => { f.kind = e.target.value; f.layer = ""; redraw(key); } }, ...[["", "Оборудование и краны"], ["equipment", "Только оборудование"], ["cranes", "Только краны"]].map(([k, t]) => h("option", { value: k, selected: f.kind === k }, t))) : null, h("input", { type: "search", placeholder: "🔍 Машина или описание", value: f.q, oninput: e => { f.q = e.target.value; saveWork(F); debounce(() => loadInc(key)); } }))); const SRC = { public: "📨 форма", api: "🔌 система" }; if (f.view === "map") { await incMap(key, list); return list; } fill(Q.list, ...(list.length ? list.slice(0, MAX).map(i => h("a", { class: "qitem inc " + i.status + (K.kind === "fault" ? " fault" : ""), href: "#/equipment/" + i.equipment_id }, h("div", { class: "row" }, h("b", { style: "flex:1" }, (i.status === "open" ? K.ic + " " : "") + i.title), h("span", { class: "badge " + (i.status === "open" ? (K.kind === "fault" ? "b-fault" : "b-overdue") : "b-completed") }, i.status === "open" ? K.open : K.closed)), h("div", { class: "small muted" }, [i.equipment_name, fmt(i.occurred_at), i.author_name || i.reporter_name, SRC[i.source] || ""].filter(Boolean).join(" · ")))) : [empty(f.state === "open" ? K.none[0] : K.none[1])]), more(list.length, MAX, K.all)); return list; } // Карта: машины из отфильтрованного списка на карте завода (и на карте кранов, если краны — отдельная служба) async function incMap(key, list) { const K = KINDS[key], f = F[key], byEq = {}; for (const i of list) (byEq[i.equipment_id] ||= []).push(i); const plant = await api("GET", "/map"); const cranes = plant.crane_map ? await api("GET", "/map?kind=crane") : null; const layers = [["plant", "Завод", plant], ...(cranes ? [["crane", "Краны", cranes]] : [])]; // слой не выбран — первая карта, где есть нужные машины const cur = layers.find(l => l[0] === f.layer) || layers.find(l => l[2].equipment.some(e => byEq[e.id])) || layers[0]; const m = cur[2], lk = cur[0]; const hit = m.equipment.filter(e => byEq[e.id]); const placed = hit.filter(e => e.x != null), unplaced = hit.filter(e => e.x == null); const known = new Set(layers.flatMap(l => l[2].equipment.map(e => e.id))); const stage = h("div", { class: "mapstage" }, m.has_image ? authImg("/map/image?" + (lk === "crane" ? "kind=crane&" : "") + "v=" + m.image_version, { class: "mapimg", draggable: "false" }) : null, ...placed.map(e => { const inc = byEq[e.id], open = inc.filter(i => i.status === "open").length; return h("a", { class: "mk " + (open ? K.st : "st-ok"), href: "#/equipment/" + e.id, style: `left:${e.x}%;top:${e.y}%`, title: e.name + "\n" + inc.map(i => (i.status === "open" ? K.ic + " " : "✓ ") + i.title).join("\n") }, eqIcon(e, "eqicon"), h("span", { class: "alarm" + (K.kind === "fault" ? " faultmk" : ""), style: open ? "" : "background:var(--muted);animation:none" }, inc.length > 1 ? String(inc.length) : open ? K.mk : "✓"), h("span", { class: "mkname" }, e.name)); })); fill(K.Q.list, layers.length > 1 ? h("div", { class: "row", style: "margin-bottom:6px" }, ...layers.map(([k, t, lm]) => chip(lk === k, `${t} · ${lm.equipment.filter(e => byEq[e.id]).length}`, () => { f.layer = k; redraw(key); }))) : null, m.has_image ? zoomable(stage, { cls: "qmap", key: "work-" + key + "-" + lk }) : empty(lk === "crane" ? "Картинка карты кранов не загружена" : "Карта завода ещё не загружена"), !hit.length ? empty(`На этой карте нет машин с ${K.what} по фильтру`) : null, unplaced.length ? h("div", { class: "small muted" }, "Не размещены на карте: ", ...unplaced.map((e, n) => [n ? ", " : "", h("a", { href: "#/equipment/" + e.id }, e.name)])) : null, list.some(i => !known.has(i.equipment_id)) ? h("div", { class: "small muted" }, "Часть машин не показана: они выведены из работы.") : null, h("a", { class: "small qmore", href: lk === "crane" ? "#/map/cranes" : "#/map" }, "открыть большую карту →")); } // ----- шапка: быстрые действия и сводка ----- async function loadSummary() { const [st, mine, review, tasksMine, incOpen, faultOpen] = await Promise.all([api("GET", "/orders/stats"), api("GET", "/orders?mine=true&limit=500"), boss ? api("GET", "/orders?to_review=true&limit=500") : Promise.resolve([]), api("GET", "/tasks?mine=true&active=true&limit=500"), api("GET", "/incidents?status=open&kind=incident&limit=500"), api("GET", "/incidents?status=open&kind=fault&limit=500")]); const tile = (n, label, href, cls) => h("a", { class: "stat qstat " + (cls || ""), href }, h("b", {}, n), h("span", {}, label)); const pickEq = kind => guard(async () => { const fault = kind === "fault", eqs = await ref("equipment"); const sel = h("select", {}, h("option", { value: "" }, "— машина —"), ...eqs.filter(e => e.is_active).map(e => h("option", { value: e.id }, e.name + (e.inventory_no ? ` (${e.inventory_no})` : "")))); fill(topBox, h("div", { class: "card", style: "margin-top:8px" }, h("label", {}, fault ? "На какой машине неисправность?" : "Какая машина отказала (остановилась)?"), searchSelect(sel, "🔍 Название или инв. №"), h("div", { class: "row", style: "margin-top:8px" }, h("button", { class: fault ? "fault" : "bad", onclick: () => { if (sel.value) location.hash = `#/equipment/${sel.value}?${fault ? "fault" : "incident"}=1`; } }, "Дальше →"), h("button", { class: "sec", onclick: () => fill(topBox) }, "Отмена")))); }); fill(topActions, h("a", { class: "btn", href: "#/new-task" }, "🗓 Новая задача"), boss ? h("a", { class: "btn sec", href: "#/new-order" }, "📋 Новый наряд") : null, h("button", { class: "bad", onclick: pickEq("incident") }, "❗ Зафиксировать отказ"), h("button", { class: "fault", onclick: pickEq("fault") }, "⚠ Сообщить о неисправности"), h("a", { class: "btn sec", href: "#/requests" }, "🛒 Заявка на инструмент"), h("a", { class: "btn sec", href: "#/my-tools" }, "🧰 Мой инструмент")); fill(topStats, tile(mine.filter(o => ["new", "in_progress"].includes(o.status)).length, "мои наряды в работе", "#/"), tile(st.overdue, "нарядов просрочено", "#/", st.overdue ? "warnt" : ""), boss ? tile(review.length, "ждут моей проверки", "#/") : null, tile(tasksMine.length, "моих активных задач", "#/tasks"), tile(incOpen.length, "открытых отказов", "#/incidents", incOpen.length ? "badt" : ""), tile(faultOpen.length, "открытых неисправностей", "#/faults", faultOpen.length ? "faultt" : "")); } async function redraw(which) { saveWork(F); if (!which || which === "o") await loadOrders(); if (!which || which === "t") await loadTasks(); if (!which || which === "i") await loadInc("i"); if (!which || which === "f") await loadInc("f"); } fill($app, h("div", { class: "row" }, h("h1", { style: "flex:1" }, "Задачи"), h("span", { class: "small muted" }, "наряды, задачи и поручения, отказы и неисправности — на одном экране")), top, h("div", { class: "quads" }, Q1.el, Q2.el, Q3.el, Q4.el)); await Promise.all([redraw(), loadSummary()]); timer = setInterval(guard(async () => { await redraw(); await loadSummary(); }), 60000); }