211 lines
18 KiB
JavaScript
211 lines
18 KiB
JavaScript
"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);
|
||
}
|