Files

211 lines
18 KiB
JavaScript
Raw Permalink 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";
// ---------- Единая вкладка «Задачи»: всё, что нужно сотруднику, на одном экране ----------
// Шапка — быстрые действия и сводка; ниже четыре четверти: наряды, задачи и поручения, инциденты, сообщения о неисправности.
// У каждой четверти свои фильтры (запоминаются в браузере). Остальные страницы не меняются.
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);
}