847 lines
81 KiB
JavaScript
847 lines
81 KiB
JavaScript
"use strict";
|
||
// ---------- Портал: инструмент и склады, заявки, инциденты, суточные задания, карта, сотрудники ----------
|
||
const isStore = () => me && (me.is_admin || me.is_storekeeper);
|
||
const canStock = () => isStore() || isEng();
|
||
const TASK_ST = { assigned: "Назначен", in_progress: "В работе", paused: "Приостановлен", done: "Выполнен", cancelled: "Отменён" };
|
||
const TASK_NEXT = { assigned: ["in_progress", "paused", "done", "cancelled"], in_progress: ["paused", "done", "cancelled"],
|
||
paused: ["in_progress", "done", "cancelled"], done: ["assigned"], cancelled: ["assigned"] };
|
||
const TASK_NEED = ["paused", "done", "cancelled"];
|
||
const TASK_BTN = { in_progress: "▶ В работу", paused: "⏸ Приостановить", done: "✔ Выполнено", cancelled: "✕ Отменить", assigned: "↺ Вернуть в работу" };
|
||
const REQ_ST = { new: "Новая", approved: "Одобрена", ordered: "Заказано", done: "Получено", rejected: "Отклонена" };
|
||
const EQ_STATE = { alarm: "Отказ", fault: "Неисправность", due: "Требуется ТО", soon: "Скоро ТО", ok: "В норме" };
|
||
const IS_FAULT = i => i.kind === "fault";
|
||
const todayISO = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 10); };
|
||
const num = v => (Math.round(v * 1000) / 1000).toLocaleString("ru-RU");
|
||
const tbl = (heads, rows) => h("div", { class: "card tablewrap" }, h("table", {}, h("thead", {}, h("tr", {}, ...heads.map(t => h("th", {}, t)))), h("tbody", {}, ...rows)));
|
||
const fill = (el, ...k) => el.replaceChildren(...k.flat(9).filter(x => x != null && x !== false));
|
||
const empty = text => h("div", { class: "card muted" }, text);
|
||
const field = (label, ctl, hint) => h("div", {}, h("label", {}, label), ctl, hint ? h("div", { class: "small muted" }, hint) : null);
|
||
function tabs(list, cur) {
|
||
return h("div", { class: "tabs" }, ...list.map(([href, t]) => h("a", { href, class: cur === href ? "on" : "" }, t)));
|
||
}
|
||
|
||
// картинки, требующие авторизации (фото сотрудников, своя иконка, карта) — грузим blob-ом и кэшируем
|
||
const imgCache = {};
|
||
// ---------- масштабирование карт ----------
|
||
// Кнопки «+ / − / ⤢», Ctrl + колесо мыши, двойной щелчок, щипок двумя пальцами; при увеличении карту можно двигать
|
||
// перетаскиванием (мышью) или одним пальцем. key — запомнить масштаб и положение (карта перерисовывается раз в минуту).
|
||
const ZOOM_MEM = {};
|
||
function zoomable(stage, opts = {}) {
|
||
const wrap = h("div", { class: "mapwrap" + (opts.cls ? " " + opts.cls : "") }, stage);
|
||
const label = h("span", { class: "zl" }, "100%");
|
||
const btn = (t, title, fn) => h("button", { type: "button", class: "sec sm", title, onclick: e => { e.stopPropagation(); fn(); } }, t);
|
||
const box = h("div", { class: "mapbox" }, wrap, h("div", { class: "mapzoom" },
|
||
btn("+", "Увеличить", () => zoomTo(z * 1.5)), btn("−", "Уменьшить", () => zoomTo(z / 1.5)), btn("⤢", "Весь план", () => zoomTo(1)), label));
|
||
const MAX = opts.max || 6;
|
||
let z = 1;
|
||
const busy = () => (opts.isEditing ? opts.isEditing() : false);
|
||
const remember = () => {
|
||
if (!opts.key) return;
|
||
const r = wrap.getBoundingClientRect();
|
||
ZOOM_MEM[opts.key] = { z, fx: (wrap.scrollLeft + r.width / 2) / (stage.offsetWidth || 1), fy: (wrap.scrollTop + r.height / 2) / (stage.offsetHeight || 1) };
|
||
};
|
||
// cx, cy — точка в окне карты, которая остаётся на месте
|
||
function zoomTo(nz, cx, cy) {
|
||
nz = Math.min(MAX, Math.max(1, nz));
|
||
const r = wrap.getBoundingClientRect();
|
||
cx = cx ?? r.width / 2; cy = cy ?? r.height / 2;
|
||
const w0 = stage.offsetWidth || 1, h0 = stage.offsetHeight || 1;
|
||
const fx = (wrap.scrollLeft + cx) / w0, fy = (wrap.scrollTop + cy) / h0;
|
||
z = nz;
|
||
stage.style.width = z === 1 ? "" : (z * 100) + "%";
|
||
box.classList.toggle("zoomed", z > 1);
|
||
label.textContent = Math.round(z * 100) + "%";
|
||
wrap.scrollLeft = fx * stage.offsetWidth - cx;
|
||
wrap.scrollTop = fy * stage.offsetHeight - cy;
|
||
remember();
|
||
}
|
||
const local = ev => { const r = wrap.getBoundingClientRect(); return [ev.clientX - r.left, ev.clientY - r.top]; };
|
||
wrap.addEventListener("wheel", ev => {
|
||
if (!ev.ctrlKey) return; // без Ctrl колесо прокручивает страницу
|
||
ev.preventDefault();
|
||
const [x, y] = local(ev);
|
||
zoomTo(z * Math.exp(-ev.deltaY * 0.0025), x, y);
|
||
}, { passive: false });
|
||
wrap.addEventListener("dblclick", ev => {
|
||
if (busy() || ev.target.closest(".mk, .zone")) return;
|
||
const [x, y] = local(ev);
|
||
zoomTo(z < MAX ? z * 2 : 1, x, y);
|
||
});
|
||
// щипок двумя пальцами
|
||
let pinch = null;
|
||
const dist = t => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
|
||
wrap.addEventListener("touchstart", ev => { if (ev.touches.length === 2) { pinch = { d: dist(ev.touches), z }; ev.preventDefault(); } }, { passive: false });
|
||
wrap.addEventListener("touchmove", ev => {
|
||
if (!pinch || ev.touches.length !== 2) return;
|
||
ev.preventDefault();
|
||
const r = wrap.getBoundingClientRect();
|
||
const cx = (ev.touches[0].clientX + ev.touches[1].clientX) / 2 - r.left, cy = (ev.touches[0].clientY + ev.touches[1].clientY) / 2 - r.top;
|
||
zoomTo(pinch.z * dist(ev.touches) / pinch.d, cx, cy);
|
||
}, { passive: false });
|
||
wrap.addEventListener("touchend", ev => { if (ev.touches.length < 2) pinch = null; });
|
||
// перетаскивание мышью, когда карта увеличена
|
||
wrap.addEventListener("pointerdown", ev => {
|
||
if (z === 1 || ev.pointerType === "touch" || ev.button !== 0 || busy() || ev.target.closest(".mk, .zone, button, a")) return;
|
||
const sx = ev.clientX, sy = ev.clientY, l0 = wrap.scrollLeft, t0 = wrap.scrollTop;
|
||
let moved = false;
|
||
const move = mv => { moved = true; wrap.scrollLeft = l0 - (mv.clientX - sx); wrap.scrollTop = t0 - (mv.clientY - sy); };
|
||
const up = () => {
|
||
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); box.classList.remove("panning");
|
||
if (moved) wrap.addEventListener("click", e => e.stopPropagation(), { capture: true, once: true });
|
||
};
|
||
box.classList.add("panning");
|
||
window.addEventListener("pointermove", move); window.addEventListener("pointerup", up);
|
||
});
|
||
wrap.addEventListener("scroll", () => { if (z > 1) remember(); });
|
||
// восстановить масштаб после перерисовки (когда картинка загрузится и станут известны размеры)
|
||
const mem = opts.key && ZOOM_MEM[opts.key];
|
||
if (mem && mem.z > 1) {
|
||
const restore = () => requestAnimationFrame(() => {
|
||
z = mem.z; stage.style.width = (z * 100) + "%"; box.classList.add("zoomed"); label.textContent = Math.round(z * 100) + "%";
|
||
const r = wrap.getBoundingClientRect();
|
||
wrap.scrollLeft = mem.fx * stage.offsetWidth - r.width / 2; wrap.scrollTop = mem.fy * stage.offsetHeight - r.height / 2;
|
||
});
|
||
const img = stage.querySelector("img");
|
||
if (img && !img.complete) img.addEventListener("load", restore, { once: true }); else restore();
|
||
}
|
||
return box;
|
||
}
|
||
|
||
function authImg(path, attrs = {}) {
|
||
const img = h("img", { alt: "", ...attrs });
|
||
(imgCache[path] ||= authFetch(API + path).then(r => r.ok ? r.blob() : Promise.reject(new Error("нет картинки"))).then(b => URL.createObjectURL(b)))
|
||
.then(u => img.src = u).catch(() => { delete imgCache[path]; img.classList.add("noimg"); });
|
||
return img;
|
||
}
|
||
function eqIcon(e, cls = "eqicon") {
|
||
if (e.icon?.startsWith("builtin:")) return h("img", { class: cls, alt: "", src: `/static/icons/${e.icon.slice(8)}.svg` });
|
||
if (e.icon?.startsWith("file:")) return authImg(`/equipment/${e.id}/icon?v=${e.icon.slice(5, 13)}`, { class: cls });
|
||
return h("span", { class: cls + " noicon" }, "⚙");
|
||
}
|
||
function avatar(u, cls = "ava") {
|
||
if (u.has_photo) return authImg(`/users/${u.id}/photo?v=${u.id}-${avatar.v[u.id] || 0}`, { class: cls });
|
||
const ini = (u.full_name || u.username || "?").split(/\s+/).map(s => s[0]).slice(0, 2).join("").toUpperCase();
|
||
return h("span", { class: cls + " ini" }, ini);
|
||
}
|
||
avatar.v = {};
|
||
async function sendFile(method, path, file, max = 1600) {
|
||
const blob = file.type === "image/png" && max >= 3000 ? file : await downscale(file, max);
|
||
const r = await authFetch(API + path, { method, headers: { "Content-Type": blob.type || "image/jpeg" }, body: blob });
|
||
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
|
||
return r.status === 204 ? null : r.json();
|
||
}
|
||
function filePicker(onfile, label = "📷 Загрузить фото", cls = "sec sm") {
|
||
const inp = h("input", { type: "file", accept: "image/*", style: "display:none", onchange: guard(async e => { const f = e.target.files[0]; if (f) await onfile(f); }) });
|
||
return h("span", {}, inp, h("button", { class: cls, onclick: () => inp.click() }, label));
|
||
}
|
||
|
||
// ======================= Инструмент и склады =======================
|
||
async function inventoryPage(tab) {
|
||
if (!canStock()) return myToolsPage();
|
||
tab = tab || "stock";
|
||
const [items, whs, users] = await Promise.all([api("GET", "/inventory/items"), api("GET", "/warehouses"), ref("users", true)]);
|
||
const box = h("div"), formBox = h("div");
|
||
const activeItems = items.filter(i => i.is_active), activeWh = whs.filter(w => w.is_active), activeUsers = users.filter(u => u.is_active);
|
||
const reload = () => inventoryPage(tab);
|
||
const opt = (list, name, cur) => [h("option", { value: "" }, "—"), ...list.map(x => h("option", { value: x.id, selected: String(cur) === String(x.id) }, name(x)))];
|
||
|
||
function opForm(kind, preset = {}) {
|
||
const s = { item_id: preset.item_id || "", qty: preset.qty || 1, from_wh: preset.from_wh || "", from_user: preset.from_user || "", to_wh: preset.to_wh || "", to_user: preset.to_user || "", note: "" };
|
||
const sel = (k, list, name) => h("select", { onchange: e => s[k] = e.target.value }, ...opt(list, name, s[k]));
|
||
const whSel = k => sel(k, activeWh, w => w.name), userSel = k => searchSelect(sel(k, activeUsers, u => u.full_name || u.username));
|
||
const itemSel = sel("item_id", activeItems, i => i.name + (i.inventory_no ? ` (${i.inventory_no})` : ""));
|
||
const T = { receive: "Поступление на склад", issue: "Выдача сотруднику", return: "Возврат от сотрудника", transfer: "Перемещение между складами", writeoff: "Списание" };
|
||
const wo = h("select", { onchange: e => { s.wo = e.target.value; draw(); } }, h("option", { value: "wh" }, "со склада"), h("option", { value: "user", selected: s.wo === "user" }, "у сотрудника"));
|
||
const body = h("div", { class: "grid" });
|
||
function draw() {
|
||
const parts = [field("Позиция *", itemSel), field("Количество *", h("input", { type: "number", min: "0", step: "any", value: s.qty, onchange: e => s.qty = +e.target.value }))];
|
||
if (kind === "receive") parts.push(field("На склад *", whSel("to_wh")));
|
||
if (kind === "issue") parts.push(field("Со склада *", whSel("from_wh")), field("Сотруднику *", userSel("to_user")));
|
||
if (kind === "return") parts.push(field("От сотрудника *", userSel("from_user")), field("На склад *", whSel("to_wh")));
|
||
if (kind === "transfer") parts.push(field("Со склада *", whSel("from_wh")), field("На склад *", whSel("to_wh")));
|
||
if (kind === "writeoff") parts.push(field("Откуда", wo), s.wo === "user" ? field("Сотрудник *", userSel("from_user")) : field("Склад *", whSel("from_wh")));
|
||
parts.push(field("Комментарий", h("input", { value: s.note, onchange: e => s.note = e.target.value })));
|
||
body.replaceChildren(...parts);
|
||
}
|
||
draw();
|
||
const place = (w, u) => w ? { warehouse_id: +w } : u ? { user_id: +u } : null;
|
||
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, T[kind]), body,
|
||
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
|
||
if (!s.item_id) throw new Error("Выберите позицию");
|
||
const req = { item_id: +s.item_id, qty: s.qty, note: s.note };
|
||
if (kind === "receive") Object.assign(req, { kind: "receive", to: place(s.to_wh) });
|
||
else if (kind === "writeoff") Object.assign(req, { kind: "writeoff", from: s.wo === "user" ? place(null, s.from_user) : place(s.from_wh) });
|
||
else Object.assign(req, { kind: "move", from: place(s.from_wh, s.from_user), to: place(s.to_wh, s.to_user) });
|
||
await api("POST", "/inventory/moves", req);
|
||
toast("Готово"); reload();
|
||
}) }, "Провести"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
|
||
formBox.scrollIntoView({ behavior: "smooth" });
|
||
}
|
||
|
||
function itemForm(it) {
|
||
const f = formFields([{ k: "name", label: "Название", type: "text", req: 1 }, { k: "category", label: "Категория", type: "text", hint: "Например: электроинструмент, измерительный, расходники" },
|
||
{ k: "unit", label: "Единица", type: "text" }, { k: "inventory_no", label: "Инвентарный номер", type: "text", hint: "Для штучного инструмента" },
|
||
{ k: "notes", label: "Описание", type: "textarea" }, { k: "is_active", label: "Используется", type: "bool" }], it || { unit: "шт", is_active: true });
|
||
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, it ? "Позиция каталога" : "Новая позиция каталога"), f.el,
|
||
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
|
||
const b = { name: f.vals.name, category: f.vals.category || "", unit: f.vals.unit || "шт", inventory_no: f.vals.inventory_no || "", notes: f.vals.notes || "", is_active: !!f.vals.is_active };
|
||
await api(it ? "PATCH" : "POST", "/inventory/items" + (it ? "/" + it.id : ""), b); toast("Сохранено"); reload();
|
||
}) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
|
||
formBox.scrollIntoView({ behavior: "smooth" });
|
||
}
|
||
function whForm(w) {
|
||
const f = formFields([{ k: "name", label: "Название склада", type: "text", req: 1 }, { k: "notes", label: "Примечание", type: "textarea" }, { k: "is_active", label: "Работает", type: "bool" }], w || { is_active: true });
|
||
formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, w ? "Склад" : "Новый склад"), f.el,
|
||
h("p", { class: "small muted" }, "Зону склада на карте завода отмечают на вкладке «Карта складов»."),
|
||
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
|
||
await api(w ? "PATCH" : "POST", "/warehouses" + (w ? "/" + w.id : ""), { name: f.vals.name, notes: f.vals.notes || "", is_active: !!f.vals.is_active }); toast("Сохранено"); reload();
|
||
}) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена"))));
|
||
formBox.scrollIntoView({ behavior: "smooth" });
|
||
}
|
||
|
||
const ops = isStore() ? h("div", { class: "row", style: "margin-bottom:12px" },
|
||
h("button", { onclick: () => opForm("issue") }, "→ Выдать"), h("button", { class: "sec", onclick: () => opForm("return") }, "← Принять возврат"),
|
||
h("button", { class: "sec", onclick: () => opForm("receive") }, "+ Поступление"), h("button", { class: "sec", onclick: () => opForm("transfer") }, "⇄ Переместить"),
|
||
h("button", { class: "sec", onclick: () => opForm("writeoff") }, "Списать")) : h("p", { class: "small muted" }, "Просмотр. Выдачу и учёт ведёт администратор склада.");
|
||
|
||
if (tab === "stock") {
|
||
const wid = +(new URLSearchParams(location.hash.split("?")[1] || "").get("wh") || 0);
|
||
const list = wid ? activeWh.filter(w => w.id === wid) : activeWh;
|
||
const stocks = await Promise.all(list.map(w => api("GET", "/inventory/stock?warehouse_id=" + w.id)));
|
||
fill(box,
|
||
wid ? h("p", {}, h("a", { href: "#/inventory/stock" }, "← Все склады")) : null,
|
||
isStore() ? h("div", { class: "row", style: "margin-bottom:10px" }, h("button", { class: "sec sm", onclick: () => whForm(null) }, "+ Склад")) : null,
|
||
...(list.length ? list.map((w, i) => h("div", { class: "card" },
|
||
h("div", { class: "row" }, h("h2", { style: "margin:0;flex:1" }, "🏬 " + w.name), h("span", { class: "muted small" }, `${w.positions} поз.`),
|
||
isStore() ? h("button", { class: "sec sm", onclick: () => whForm(w) }, "Изменить") : null,
|
||
isStore() ? h("button", { class: "sec sm", onclick: guard(async () => { if (confirm(`Удалить склад «${w.name}»?`)) { await api("DELETE", "/warehouses/" + w.id); reload(); } }) }, "✕") : null),
|
||
w.notes ? h("div", { class: "small muted" }, w.notes) : null,
|
||
stocks[i].length ? h("table", { style: "margin-top:8px" }, h("tbody", {}, ...stocks[i].map(s => h("tr", {},
|
||
h("td", {}, s.item_name, s.inventory_no ? h("span", { class: "muted small" }, ` (${s.inventory_no})`) : null, h("div", { class: "small muted" }, s.category)),
|
||
h("td", { style: "white-space:nowrap" }, `${num(s.qty)} ${s.unit}`),
|
||
h("td", { class: "row end" }, isStore() ? h("button", { class: "sec sm", onclick: () => opForm("issue", { item_id: s.item_id, from_wh: w.id }) }, "Выдать") : null))))) :
|
||
h("p", { class: "muted small" }, "Склад пуст"))) : [empty("Складов пока нет")]));
|
||
} else if (tab === "items") {
|
||
fill(box, isStore() ? h("div", { class: "row", style: "margin-bottom:10px" }, h("button", { class: "sec sm", onclick: () => itemForm(null) }, "+ Позиция каталога")) : null,
|
||
items.length ? tbl(["Позиция", "Категория", "Инв. №", "На складах", "Выдано", ""], items.map(i => h("tr", { style: i.is_active ? "" : "opacity:.55" },
|
||
h("td", {}, i.name, i.notes ? h("div", { class: "small muted" }, i.notes) : null), h("td", {}, i.category || "—"), h("td", {}, i.inventory_no || "—"),
|
||
h("td", {}, `${num(i.in_stock)} ${i.unit}`), h("td", {}, i.issued ? `${num(i.issued)} ${i.unit}` : "—"),
|
||
h("td", { class: "row end" }, isStore() ? [h("button", { class: "sec sm", onclick: () => opForm("receive", { item_id: i.id }) }, "+ Поступление"),
|
||
h("button", { class: "sec sm", onclick: () => itemForm(i) }, "Изменить"),
|
||
h("button", { class: "sec sm", onclick: guard(async () => { if (confirm(`Удалить «${i.name}»?`)) { await api("DELETE", "/inventory/items/" + i.id); reload(); } }) }, "✕")] : null)))) : empty("Каталог пуст"));
|
||
} else if (tab === "issued") {
|
||
const all = await api("GET", "/inventory/stock");
|
||
const issued = all.filter(s => s.user_id);
|
||
const by = {};
|
||
for (const s of issued) (by[s.user_id] ||= []).push(s);
|
||
fill(box, ...(Object.keys(by).length ? Object.entries(by).map(([uid, list]) => h("div", { class: "card" },
|
||
h("div", { class: "row" }, h("a", { href: "#/user/" + uid, style: "flex:1;font-weight:700" }, "👤 " + list[0].user_name)),
|
||
h("table", { style: "margin-top:6px" }, h("tbody", {}, ...list.map(s => h("tr", {},
|
||
h("td", {}, s.item_name, s.inventory_no ? h("span", { class: "muted small" }, ` (${s.inventory_no})`) : null),
|
||
h("td", {}, `${num(s.qty)} ${s.unit}`), h("td", { class: "small muted" }, "с " + fmt(s.since)),
|
||
h("td", { class: "row end" }, isStore() ? h("button", { class: "sec sm", onclick: () => opForm("return", { item_id: s.item_id, from_user: s.user_id, qty: s.qty }) }, "Вернуть на склад") : null))))))) : [empty("Сотрудникам ничего не выдано")]));
|
||
} else {
|
||
const moves = await api("GET", "/inventory/moves?limit=300");
|
||
const K = { receive: "Поступление", issue: "Выдача", return: "Возврат", transfer: "Перемещение", writeoff: "Списание" };
|
||
fill(box, moves.length ? tbl(["Когда", "Операция", "Позиция", "Кол-во", "Откуда", "Куда", "Кто", "Комментарий"], moves.map(m => h("tr", {},
|
||
h("td", {}, fmt(m.at)), h("td", {}, K[m.kind] || m.kind), h("td", {}, m.item_name), h("td", {}, `${num(m.qty)} ${m.unit}`),
|
||
h("td", {}, m.from_text), h("td", {}, m.to_text), h("td", {}, m.by_name || "—"), h("td", { class: "small" }, m.note || "")))) : empty("Движений пока нет"));
|
||
}
|
||
fill($app, h("h1", {}, "Инструмент и склады"),
|
||
tabs([["#/inventory/stock", "Склады"], ["#/inventory/items", "Каталог"], ["#/inventory/issued", "Выдано сотрудникам"], ["#/inventory/moves", "Журнал"], ["#/map/warehouses", "Карта складов"]], "#/inventory/" + tab),
|
||
ops, formBox, box);
|
||
}
|
||
|
||
async function myToolsPage() {
|
||
const list = await api("GET", "/me/inventory");
|
||
fill($app, h("h1", {}, "Мой инструмент"),
|
||
h("p", { class: "small muted" }, "Инструмент и инвентарь, выданные лично вам. Чего-то не хватает — оформите ", h("a", { href: "#/requests" }, "заявку"), "."),
|
||
list.length ? tbl(["Позиция", "Категория", "Инв. №", "Количество", "Выдано"], list.map(s => h("tr", {},
|
||
h("td", {}, s.item_name), h("td", {}, s.category || "—"), h("td", {}, s.inventory_no || "—"), h("td", {}, `${num(s.qty)} ${s.unit}`), h("td", {}, fmt(s.since))))) :
|
||
empty("За вами ничего не числится"));
|
||
}
|
||
|
||
// ======================= Заявки на инструмент =======================
|
||
async function requestsPage() {
|
||
const handler = isStore() || isMgr();
|
||
const st = { what: "", why: "", link: "", qty: 1 };
|
||
const list = await api("GET", "/tool-requests");
|
||
const form = h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Новая заявка"),
|
||
h("div", { class: "grid" },
|
||
field("Что купить *", h("input", { placeholder: "Например: мультиметр Fluke 117", onchange: e => st.what = e.target.value })),
|
||
field("Количество", h("input", { type: "number", min: "1", step: "any", value: 1, onchange: e => st.qty = +e.target.value })),
|
||
field("Ссылка на товар", h("input", { type: "url", placeholder: "https://…", onchange: e => st.link = e.target.value }), "Необязательно: страница конкретного товара в магазине")),
|
||
h("div", { style: "margin-top:8px" }, field("Зачем нужно *", h("textarea", { placeholder: "Для чего, почему не хватает имеющегося", onchange: e => st.why = e.target.value }))),
|
||
h("div", { class: "row", style: "margin-top:10px" }, h("button", { onclick: guard(async () => {
|
||
await api("POST", "/tool-requests", st); toast("Заявка отправлена"); requestsPage();
|
||
}) }, "Отправить заявку")));
|
||
const card = r => {
|
||
const ans = h("textarea", { placeholder: "Ответ / комментарий (обязателен при отклонении)" }, r.answer);
|
||
const sel = h("select", {}, ...Object.entries(REQ_ST).map(([k, t]) => h("option", { value: k, selected: r.status === k }, t)));
|
||
return h("div", { class: "card" },
|
||
h("div", { class: "row" }, h("b", { style: "flex:1;font-size:16px" }, `№${r.id}. ${r.what}`, h("span", { class: "muted", style: "font-weight:400" }, ` — в количестве ${num(r.qty)}`)), h("span", { class: "badge rq-" + r.status }, REQ_ST[r.status])),
|
||
h("div", { class: "small muted" }, `${r.author_name || "—"} · ${fmt(r.created_at)}`),
|
||
h("div", { style: "margin-top:6px;white-space:pre-wrap" }, r.why),
|
||
r.link ? h("div", { style: "margin-top:4px" }, h("a", { href: r.link, target: "_blank", rel: "noopener noreferrer nofollow" }, "🔗 Ссылка на товар")) : null,
|
||
r.answer && !handler ? h("div", { class: "sheet", style: "margin-top:8px" }, h("b", {}, "Ответ: "), r.answer, r.handled_by_name ? h("span", { class: "muted" }, " — " + r.handled_by_name) : null) : null,
|
||
handler ? h("div", { class: "row", style: "margin-top:10px;align-items:flex-start" }, h("div", { style: "width:170px" }, sel), h("div", { style: "flex:1;min-width:220px" }, ans),
|
||
h("button", { onclick: guard(async () => { await api("POST", `/tool-requests/${r.id}/answer`, { status: sel.value, answer: ans.value }); toast("Сохранено"); requestsPage(); }) }, "Сохранить")) : null,
|
||
(handler || (r.status === "new" && r.author_id === me.id)) ? h("div", { style: "margin-top:6px" }, h("a", { href: "javascript:void 0", class: "small", onclick: guard(async () => { if (confirm("Удалить заявку?")) { await api("DELETE", "/tool-requests/" + r.id); requestsPage(); } }) }, "удалить")) : null);
|
||
};
|
||
fill($app, h("h1", {}, "Заявки на инструмент"),
|
||
h("p", { class: "small muted" }, handler ? "Вы видите все заявки сотрудников." : "Заявку увидит администратор склада. Здесь же появится ответ."),
|
||
form, h("h2", {}, handler ? "Все заявки" : "Мои заявки"), ...(list.length ? list.map(card) : [empty("Заявок пока нет")]));
|
||
}
|
||
|
||
// ======================= Инциденты =======================
|
||
function incidentCard(i, onchange) {
|
||
const canClose = i.status === "open" && (i.author_id === me.id || isEng());
|
||
const f = IS_FAULT(i), fbox = h("div");
|
||
const fa = i.escalated_at || i.occurred_at, D = FAIL_DICT;
|
||
const hrsTxt = v => v == null ? "" : `${Math.round(v * 10) / 10} ч`;
|
||
const down = !f && i.restored_at ? (new Date(i.restored_at) - new Date(fa)) / 3600000 : null;
|
||
// сводка записи об отказе (для закрытых): простой, причина, что сделано
|
||
const facts = i.status === "closed" && !f ? [down != null ? "простой " + hrsTxt(down) : null, i.parts_wait_hours ? "ожидание запчастей " + hrsTxt(i.parts_wait_hours) : null,
|
||
i.hours_at_failure != null ? `наработка ${Math.round(i.hours_at_failure)} м/ч` : null, i.cause_unit_name || i.cause_unit_other || null,
|
||
D.cause[i.cause] || null, D.nature[i.nature] || null, D.action[i.action] || null, D.precursor[i.precursor] || null].filter(Boolean) : [];
|
||
return h("div", { class: "card inc " + i.status + (f ? " fault" : "") },
|
||
h("div", { class: "row" }, h("b", { style: "flex:1;font-size:16px" }, i.status === "open" ? (f ? "⚠ " : "❗ ") : "", i.title),
|
||
h("span", { class: "badge kind-" + i.kind }, f ? "неисправность" : "отказ"),
|
||
h("span", { class: "badge " + (i.status === "open" ? (f ? "b-fault" : "b-overdue") : "b-completed") }, i.status === "open" ? (f ? "открыта" : "открыт") : (f ? "закрыта" : "закрыт"))),
|
||
h("div", { class: "small muted" }, `${fmt(i.occurred_at)} · ${i.author_name || "—"}`, i.equipment_name ? [" · ", h("a", { href: "#/equipment/" + i.equipment_id }, i.equipment_name)] : null),
|
||
i.source !== "staff" ? h("div", { class: "sheet", style: "margin-top:6px" }, i.source === "public" ? "📨 Через публичную форму" : "🔌 Из внешней системы",
|
||
i.reporter_name ? ": " + i.reporter_name : "", i.reporter_contact ? ", " + i.reporter_contact : "", i.external_id ? ` (№ ${i.external_id})` : "") : null,
|
||
i.description ? h("div", { style: "margin-top:6px;white-space:pre-wrap" }, i.description) : null,
|
||
i.units.length ? h("div", { class: "small", style: "margin-top:6px" }, "Затронуты узлы: ", i.units.map((u, n) => [n ? ", " : "", u.unit_name, u.replaced ? " (заменён)" : ""])) : null,
|
||
i.replaced ? h("div", { class: "small", style: "margin-top:4px" }, "🔧 Была замена", i.replacement_note ? ": " + i.replacement_note : "") : null,
|
||
i.escalated_at ? h("div", { class: "small muted", style: "margin-top:4px" }, `Сообщение о неисправности от ${fmt(i.occurred_at)} переведено в отказ ${fmt(i.escalated_at)}`) : null,
|
||
i.status === "closed" ? h("div", { class: "small muted", style: "margin-top:4px" }, `${f ? "Закрыта" : "Закрыт"} ${fmt(i.closed_at)}${i.closed_by_name ? ", " + i.closed_by_name : ""}${i.close_note ? ": " + i.close_note : ""}`) : null,
|
||
facts.length ? h("div", { class: "small", style: "margin-top:4px" }, "📊 ", facts.join(" · ")) : null,
|
||
i.status === "closed" && !f && isEng() ? h("div", { class: "row", style: "margin-top:6px" },
|
||
h("button", { class: "sec sm", onclick: guard(() => failureForm(i, fbox, onchange, "edit")) }, "✎ Данные отказа")) : null,
|
||
i.status === "open" ? h("div", { class: "row", style: "margin-top:8px" },
|
||
canClose ? h("button", { class: "sec sm", onclick: guard(() => failureForm(i, fbox, onchange, "close")) }, f ? "✔ Неисправность устранена" : "✔ Закрыть отказ") : null,
|
||
f ? h("button", { class: "bad sm", title: "Неисправность привела к остановке оборудования", onclick: guard(async () => {
|
||
const note = prompt("Оборудование остановлено? Перевести сообщение в отказ (машина получит состояние «Отказ»). Что случилось:", ""); if (note === null) return;
|
||
await api("POST", `/incidents/${i.id}/escalate`, { note }); toast("Переведено в отказ"); onchange();
|
||
}) }, "❗ Перевести в отказ") : null) : null, fbox);
|
||
}
|
||
|
||
// справочники записи об отказе (как в app/reliability.py)
|
||
const FAIL_DICT = {
|
||
action: { repair: "Ремонт", replace: "Замена узла", adjust: "Регулировка или наладка", restart: "Перезапуск без ремонта (сбой)" },
|
||
nature: { sudden: "Внезапный", gradual: "Постепенный" },
|
||
cause: { wear: "Износ и старение", operation: "Нарушение условий эксплуатации, ошибка персонала", external: "Внешние воздействия (температура, влага, пыль, вибрация)",
|
||
power: "Электропитание или смежные системы", defect: "Дефект изготовления, монтажа или прошлого ремонта", secondary: "Вследствие отказа другого узла", unknown: "Не установлена" },
|
||
precursor: { inspection: "Замечено на обходе или в чек-листе", fault_report: "Было сообщение о неисправности", none: "Признаков не было" },
|
||
};
|
||
const dtIn = v => { if (!v) return ""; const d = new Date(v), p = n => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; };
|
||
|
||
// Форма закрытия отказа или неисправности (mode=close) и уточнения данных закрытого отказа (mode=edit).
|
||
// Всё, что можно, подставлено заранее: дежурному обычно достаточно выбрать «что сделано», «характер» и написать пару слов.
|
||
async function failureForm(i, box, done, mode) {
|
||
if (box.firstChild) return fill(box);
|
||
const f = IS_FAULT(i), edit = mode === "edit", D = FAIL_DICT;
|
||
const units = (await api("GET", `/equipment/${i.equipment_id}/service-units`)).filter(u => u.is_active);
|
||
const fa = i.escalated_at || i.occurred_at;
|
||
const st = { note: edit ? i.close_note : "", repair_started_at: dtIn(i.repair_started_at || (f ? null : fa)), restored_at: dtIn(i.restored_at || (f ? null : new Date())),
|
||
parts_wait_hours: i.parts_wait_hours ?? "", hours_at_failure: i.hours_at_failure ?? i.hours_at_open ?? "", meter_at_restore: "",
|
||
cause_unit: i.cause_unit_id ? String(i.cause_unit_id) : i.cause_unit_other ? "other" : "", cause_unit_other: i.cause_unit_other || "",
|
||
action: i.action || "", nature: i.nature || "", cause: i.cause || (f ? "" : "unknown"), precursor: i.precursor || (i.escalated_at ? "fault_report" : ""),
|
||
replaced: new Set() };
|
||
const chips = (key, dict) => { const wrap = h("div", { class: "row chipsel" }); const draw = () => wrap.replaceChildren(...Object.entries(dict).map(([k, t]) =>
|
||
h("button", { type: "button", class: "chipf" + (st[key] === k ? " on" : ""), onclick: () => { st[key] = st[key] === k ? "" : k; draw(); } }, t))); draw(); return wrap; };
|
||
const sel = (key, dict, empty) => h("select", { onchange: e => st[key] = e.target.value },
|
||
h("option", { value: "" }, empty), ...Object.entries(dict).map(([k, t]) => h("option", { value: k, selected: st[key] === k }, t)));
|
||
const inp = (key, attrs) => h("input", { ...attrs, value: st[key], onchange: e => st[key] = e.target.value });
|
||
const other = h("input", { placeholder: "Какой узел или деталь", value: st.cause_unit_other, style: st.cause_unit === "other" ? "" : "display:none", onchange: e => st.cause_unit_other = e.target.value });
|
||
const unitSel = h("select", { onchange: e => { st.cause_unit = e.target.value; other.style.display = st.cause_unit === "other" ? "" : "none"; } },
|
||
h("option", { value: "" }, "— не указан —"), ...units.map(u => h("option", { value: u.id, selected: st.cause_unit === String(u.id) }, u.name)),
|
||
h("option", { value: "other", selected: st.cause_unit === "other" }, "Другое…"));
|
||
const repl = units.length ? h("div", {}, ...units.map(u => h("label", { class: "chk" }, h("input", { type: "checkbox", onchange: e => e.target.checked ? st.replaced.add(u.id) : st.replaced.delete(u.id) }),
|
||
u.name, h("span", { class: "small muted" }, " — заменён, ресурс сбросится"))) ) : h("p", { class: "small muted" }, "У машины нет заведённых узлов");
|
||
const need = !f && !edit; // для закрытия отказа «что сделано» и «характер» обязательны
|
||
const details = h("details", { class: "faildet", open: !f },
|
||
h("summary", {}, f ? "Подробнее (необязательно): время, причина" : "Время, наработка и причина — подставлено, поправьте при необходимости"),
|
||
h("div", { class: "grid" },
|
||
field(`Начало работ${f ? "" : " *"}`, inp("repair_started_at", { type: "datetime-local" })),
|
||
field(`Ввод в эксплуатацию${f ? "" : " *"}`, inp("restored_at", { type: "datetime-local" })),
|
||
field("Ожидание запчастей, ч", inp("parts_wait_hours", { type: "number", min: 0, step: "0.5", placeholder: "0" })),
|
||
field("Наработка на момент отказа, м/ч", inp("hours_at_failure", { type: "number", min: 0, step: "0.1" })),
|
||
field("Показание счётчика при вводе, м/ч", inp("meter_at_restore", { type: "number", min: 0, step: "0.1", placeholder: "если снимали" })),
|
||
field("Узел-причина", h("div", {}, unitSel, other))),
|
||
h("div", { class: "grid", style: "margin-top:8px" },
|
||
field("Причина", sel("cause", D.cause, "— выберите —")),
|
||
field("Были ли признаки заранее", sel("precursor", D.precursor, "— не указано —"))),
|
||
edit ? null : h("div", { style: "margin-top:8px" }, h("label", {}, "Заменённые при ремонте узлы"), repl));
|
||
fill(box, h("div", { class: "card failform", style: `border-color:var(${f ? "--fault" : "--bad"})` },
|
||
h("h2", { style: "margin-top:0" }, edit ? "Данные отказа" : f ? "Неисправность устранена" : "Закрытие отказа"),
|
||
h("label", {}, `Что сделано${need ? " *" : ""}`), chips("action", D.action),
|
||
f ? null : [h("label", {}, `Характер отказа${need ? " *" : ""}`), chips("nature", D.nature)],
|
||
h("label", {}, "Описание устранения"), h("textarea", { placeholder: "Что сделали, что заменили, рекомендации", onchange: e => st.note = e.target.value }, st.note),
|
||
details,
|
||
h("div", { class: "row", style: "margin-top:12px" },
|
||
h("button", { onclick: guard(async () => {
|
||
if (need && !st.action) throw new Error("Выберите, что сделано");
|
||
if (need && !st.nature) throw new Error("Укажите характер отказа: внезапный или постепенный");
|
||
if (st.cause_unit === "other" && !st.cause_unit_other.trim()) throw new Error("Укажите, какой узел — причина");
|
||
const num = v => v === "" || v == null ? null : +v;
|
||
const body = { note: st.note || "", action: st.action || null, nature: st.nature || null, cause: st.cause || null, precursor: st.precursor || null,
|
||
repair_started_at: st.repair_started_at || null, restored_at: st.restored_at || null, parts_wait_hours: num(st.parts_wait_hours),
|
||
hours_at_failure: num(st.hours_at_failure), cause_unit_id: /^\d+$/.test(st.cause_unit) ? +st.cause_unit : null,
|
||
cause_unit_other: st.cause_unit === "other" ? st.cause_unit_other : "" };
|
||
if (num(st.meter_at_restore) != null) body.meter_at_restore = num(st.meter_at_restore);
|
||
if (!edit) body.replaced_units = [...st.replaced];
|
||
await api(edit ? "PATCH" : "POST", `/incidents/${i.id}/${edit ? "failure" : "close"}`, body);
|
||
toast(edit ? "Сохранено" : f ? "Неисправность закрыта" : "Отказ закрыт"); done();
|
||
}) }, edit ? "Сохранить" : "Закрыть"),
|
||
h("button", { class: "sec", onclick: () => fill(box) }, "Отмена"))));
|
||
box.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||
}
|
||
|
||
// kind: incident — авария (оборудование остановлено), fault — сообщение о неисправности (оборудование работает)
|
||
function incidentForm(eq, units, box, onsaved, kind = "incident") {
|
||
const fault = kind === "fault";
|
||
const st = { title: "", description: "", occurred_at: nowInput(), replaced: false, replacement_note: "", units: {} };
|
||
const unitRows = units.filter(u => u.is_active).map(u => {
|
||
const chk = h("input", { type: "checkbox", onchange: e => { if (e.target.checked) st.units[u.id] = st.units[u.id] || { replaced: false }; else delete st.units[u.id]; rep.disabled = !e.target.checked; } });
|
||
const rep = h("input", { type: "checkbox", disabled: true, onchange: e => { if (st.units[u.id]) st.units[u.id].replaced = e.target.checked; } });
|
||
return h("div", { class: "unitpick" }, h("label", { class: "chk" }, chk, u.name), fault ? null : h("label", { class: "chk small" }, rep, "заменён (сбросить ресурс)"));
|
||
});
|
||
fill(box, h("div", { class: "card", style: `border-color:var(${fault ? "--fault" : "--bad"})` },
|
||
h("h2", { style: "margin-top:0" }, (fault ? "⚠ Сообщить о неисправности: " : "❗ Зафиксировать отказ: ") + eq.name),
|
||
fault ? h("p", { class: "small muted" }, "Неисправность замечена, но оборудование работает. Машина получит отметку «Неисправность» на карте и в карточке; если оборудование остановится — сообщение переводится в отказ.") : null,
|
||
h("div", { class: "grid" },
|
||
field(fault ? "Что замечено (кратко) *" : "Что произошло (кратко) *", h("input", { placeholder: fault ? "Например: посторонний шум, подтекает сальник, греется подшипник" : "Например: течь масла, заклинил подшипник", onchange: e => st.title = e.target.value })),
|
||
field("Когда", h("input", { type: "datetime-local", value: st.occurred_at, onchange: e => st.occurred_at = e.target.value }))),
|
||
h("div", { style: "margin-top:8px" }, field("Подробности", h("textarea", { placeholder: "Обстоятельства, причина, что сделано", onchange: e => st.description = e.target.value }))),
|
||
h("h2", {}, fault ? "Какой узел (если известно)" : "Затронутые узлы"), unitRows.length ? h("div", {}, ...unitRows) : h("p", { class: "small muted" }, "У машины нет заведённых узлов"),
|
||
fault ? null : h("div", { class: "grid", style: "margin-top:8px" },
|
||
h("label", { class: "chk" }, h("input", { type: "checkbox", onchange: e => st.replaced = e.target.checked }), "Была замена (деталей, узлов)"),
|
||
field("Что заменено", h("input", { onchange: e => st.replacement_note = e.target.value }))),
|
||
h("div", { class: "row", style: "margin-top:12px" }, h("button", { class: fault ? "fault" : "bad", onclick: guard(async () => {
|
||
await api("POST", "/incidents", { kind, equipment_id: eq.id, title: st.title, description: st.description, occurred_at: st.occurred_at || null,
|
||
replaced: st.replaced, replacement_note: st.replacement_note, units: Object.entries(st.units).map(([id, x]) => ({ unit_id: +id, replaced: x.replaced })) });
|
||
toast(fault ? "Неисправность зарегистрирована" : "Отказ зафиксирован"); onsaved();
|
||
}) }, fault ? "Сообщить" : "Зафиксировать"), h("button", { class: "sec", onclick: () => fill(box, ) }, "Отмена"))));
|
||
box.scrollIntoView({ behavior: "smooth" });
|
||
}
|
||
|
||
// #/incidents[/all|/cranes|/cranes-all] — инциденты; #/faults[...] — сообщения о неисправности (kind=fault)
|
||
async function incidentsPage(which, kind = "incident") {
|
||
// при отдельной службе кранов: без кранов — в «Оборудовании», только краны — в разделе «Краны»
|
||
const fault = kind === "fault", root = fault ? "#/faults" : "#/incidents";
|
||
const crane = me.crane_map && (which === "cranes" || which === "cranes-all");
|
||
const all = which === "all" || which === "cranes-all";
|
||
const p = new URLSearchParams({ kind });
|
||
if (!all) p.set("status", "open");
|
||
if (me.crane_map) p.set("cranes", crane ? "true" : "false");
|
||
const list = await api("GET", "/incidents?" + p);
|
||
const base = crane ? root + "/cranes" : root, allHref = crane ? root + "/cranes-all" : root + "/all";
|
||
const what = fault ? (crane ? "Неисправности по кранам" : "Сообщения о неисправности") : (crane ? "Отказы по кранам" : "Отказы");
|
||
fill($app, h("h1", {}, what),
|
||
tabs([[base, "Открытые"], [allHref, "Все"]], all ? allHref : base),
|
||
h("p", { class: "small muted" }, fault
|
||
? `Неисправность — оборудование работает, но замечена проблема. Сообщить можно в карточке ${crane ? "крана" : "машины"}: «⚠ Сообщить о неисправности». Если оборудование остановилось — «Перевести в отказ».`
|
||
: `Отказ — оборудование остановлено. Зафиксировать можно в карточке ${crane ? "крана: «Краны» → кран" : "оборудования: «Оборудование» → машина"} → «Зафиксировать отказ».`),
|
||
...(list.length ? list.map(i => incidentCard(i, () => incidentsPage(which, kind))) : [empty(all ? (fault ? "Сообщений пока нет" : "Отказов пока нет") : (fault ? "Открытых неисправностей нет" : "Открытых отказов нет"))]));
|
||
}
|
||
|
||
// блоки карточки оборудования: иконка, состояние, инциденты (вызывается из equipmentPage)
|
||
async function equipmentPortalBlocks(eq, units, reload) {
|
||
const [incs, health] = await Promise.all([api("GET", `/incidents?equipment_id=${eq.id}&limit=50`), api("GET", "/equipment-health")]);
|
||
const hs = health[eq.id] || { state: "ok" };
|
||
const formBox = h("div"), pick = h("div");
|
||
async function iconPicker() {
|
||
if (pick.firstChild) return pick.replaceChildren();
|
||
const icons = await api("GET", "/icons");
|
||
pick.replaceChildren(h("div", { class: "card" }, h("div", { class: "small muted", style: "margin-bottom:8px" }, "Выберите иконку из набора или загрузите своё фото/картинку:"),
|
||
h("div", { class: "iconpick" }, ...icons.map(i => h("button", { class: "sec" + (eq.icon === i.id ? " on" : ""), title: i.name, onclick: guard(async () => {
|
||
await api("PATCH", "/equipment/" + eq.id, { icon: i.id }); delete cache.equipment; toast("Иконка выбрана"); reload();
|
||
}) }, h("img", { src: i.url, alt: "" }), h("span", {}, i.name)))),
|
||
h("div", { class: "row", style: "margin-top:10px" }, filePicker(async f => { await sendFile("PUT", `/equipment/${eq.id}/icon`, f, 512); delete cache.equipment; toast("Иконка загружена"); reload(); }, "📷 Загрузить своё фото"),
|
||
eq.icon ? h("button", { class: "sec sm", onclick: guard(async () => { await api("PATCH", "/equipment/" + eq.id, { icon: "" }); reload(); }) }, "Убрать иконку") : null)));
|
||
}
|
||
const head = h("div", { class: "eqhead" }, h("div", { class: "eqbig st-" + hs.state }, eqIcon(eq, "eqicon big"),
|
||
hs.state === "alarm" ? h("span", { class: "alarm" }, "!") : hs.state === "fault" ? h("span", { class: "alarm faultmk" }, "⚠") : null),
|
||
h("div", { style: "flex:1" }, h("span", { class: "badge es-" + hs.state }, EQ_STATE[hs.state]),
|
||
hs.open_incidents ? h("span", { class: "small", style: "margin-left:8px;color:var(--bad)" }, `открытых отказов: ${hs.open_incidents}`) : null,
|
||
hs.open_faults ? h("span", { class: "small", style: "margin-left:8px;color:var(--fault)" }, `открытых неисправностей: ${hs.open_faults}`) : null,
|
||
h("div", { class: "row", style: "margin-top:8px" },
|
||
h("button", { class: "bad", onclick: () => incidentForm(eq, units, formBox, reload) }, "❗ Зафиксировать отказ"),
|
||
h("button", { class: "fault", onclick: () => incidentForm(eq, units, formBox, reload, "fault") }, "⚠ Сообщить о неисправности"),
|
||
isEng() ? h("button", { class: "sec", onclick: guard(iconPicker) }, "🖼 Иконка") : null,
|
||
h("a", { class: "btn sec", href: "#/new-task?eq=" + eq.id }, "+ Задание по машине"))));
|
||
const incBox = h("div", {}, h("h2", {}, `Отказы и неисправности (${incs.length})`),
|
||
...(incs.length ? incs.slice(0, 10).map(i => incidentCard(i, reload)) : [h("p", { class: "small muted" }, "Отказов и неисправностей не было")]));
|
||
if (/[?&]incident=1/.test(location.hash)) setTimeout(() => incidentForm(eq, units, formBox, reload), 50); // из вкладки «Задачи»
|
||
if (/[?&]fault=1/.test(location.hash)) setTimeout(() => incidentForm(eq, units, formBox, reload, "fault"), 50);
|
||
return { head: h("div", {}, head, pick, formBox), incidents: incBox, history: equipmentHistory(eq) };
|
||
}
|
||
|
||
// история машины: всё, что происходило, с фильтрами
|
||
const HIST_IC = { incident: "❗", service: "🔧", meter: "⏱", order: "📋", task: "🗓" };
|
||
function equipmentHistory(eq) {
|
||
const st = { types: new Set(["incident", "service", "meter", "order", "task"]), from: "", to: "", q: "" };
|
||
const list = h("div"), chips = h("div", { class: "row" });
|
||
let seq = 0, tm;
|
||
async function load() {
|
||
const my = ++seq, p = new URLSearchParams({ types: [...st.types].join(",") || "none", limit: "500" });
|
||
if (st.from) p.set("from", st.from);
|
||
if (st.to) p.set("to", st.to);
|
||
if (st.q.trim()) p.set("q", st.q.trim());
|
||
const j = await api("GET", `/equipment/${eq.id}/history?${p}`);
|
||
if (my !== seq) return;
|
||
chips.replaceChildren(...Object.entries(j.types).map(([k, t]) => h("label", { class: "chk hchip" + (st.types.has(k) ? " on" : "") },
|
||
h("input", { type: "checkbox", checked: st.types.has(k), onchange: e => { e.target.checked ? st.types.add(k) : st.types.delete(k); guard(load)(); } }),
|
||
`${HIST_IC[k]} ${t}`, st.types.has(k) ? h("span", { class: "muted small" }, ` ${j.counts[k]}`) : null)));
|
||
let day = "";
|
||
const out = [];
|
||
for (const r of j.rows) {
|
||
let d = new Date(r.at).toLocaleDateString("ru-RU", { weekday: "short", day: "2-digit", month: "long", year: "numeric" });
|
||
d = d[0].toUpperCase() + d.slice(1);
|
||
if (d !== day) { day = d; out.push(h("div", { class: "hday" }, d)); }
|
||
const link = r.order_id ? h("a", { href: "#/order/" + r.order_id }, " открыть наряд") : r.task_id ? h("a", { href: "#/task/" + r.task_id }, " открыть задание") : null;
|
||
out.push(h("div", { class: "hrow h-" + (r.severity || "") },
|
||
h("span", { class: "hic" }, HIST_IC[r.type]),
|
||
h("div", { style: "flex:1" }, h("div", {}, h("b", {}, r.title), link),
|
||
r.text ? h("div", { class: "small", style: "white-space:pre-wrap" }, r.text) : null,
|
||
h("div", { class: "small muted" }, new Date(r.at).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" }), " · ", r.type_ru, r.user_name ? " · " + r.user_name : ""))));
|
||
}
|
||
list.replaceChildren(...(out.length ? out : [h("p", { class: "muted" }, "Событий нет")]),
|
||
j.total > j.rows.length ? h("p", { class: "small muted" }, `Показаны последние ${j.rows.length} из ${j.total} — сузьте период`) : null);
|
||
}
|
||
const dt = (k, label) => h("div", {}, h("label", {}, label), h("input", { type: "date", onchange: e => { st[k] = e.target.value; guard(load)(); } }));
|
||
const box = h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "История машины"),
|
||
h("p", { class: "small muted" }, "Всё, что происходило: отказы и неисправности, обслуживание и замены узлов, наработка, наряды и задания."),
|
||
chips,
|
||
h("div", { class: "grid", style: "margin:10px 0" }, dt("from", "С даты"), dt("to", "По дату"),
|
||
h("div", {}, h("label", {}, "Поиск"), h("input", { type: "search", placeholder: "текст, исполнитель…", oninput: e => { st.q = e.target.value; clearTimeout(tm); tm = setTimeout(guard(load), 300); } }))),
|
||
list);
|
||
guard(load)();
|
||
return box;
|
||
}
|
||
|
||
// ======================= Суточные задания =======================
|
||
const tfilt = { day: "", mine: false, status: "", assignee_id: "" };
|
||
async function tasksPage() {
|
||
const boss = isEng();
|
||
const users = boss ? (await ref("users")).filter(u => u.is_active) : [];
|
||
if (!tfilt.day && tfilt.day !== null) tfilt.day = todayISO();
|
||
const box = h("div");
|
||
async function load() {
|
||
const q = new URLSearchParams();
|
||
if (tfilt.day) q.set("day", tfilt.day);
|
||
if (tfilt.mine) q.set("mine", "true");
|
||
if (tfilt.status) q.set("status", tfilt.status);
|
||
if (tfilt.assignee_id) q.set("assignee_id", tfilt.assignee_id);
|
||
const list = await api("GET", "/tasks?" + q);
|
||
fill(box, list.length ? h("div", { class: "tasklist" }, ...list.map(t => h("a", { class: "card taskc 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")} · ${t.assignee_name || "—"}`, t.equipment_name ? ` · ⚙ ${t.equipment_name}${t.unit_name ? " / " + t.unit_name : ""}` : "",
|
||
t.photos.length ? ` · 📷${t.photos.length}` : ""),
|
||
t.last_comment ? h("div", { class: "small", style: "margin-top:4px" }, "💬 " + t.last_comment) : null))) : empty("Заданий нет"));
|
||
}
|
||
const ch = (k, label) => h("label", { class: "chk" }, h("input", { type: "checkbox", checked: tfilt[k], onchange: e => { tfilt[k] = e.target.checked; guard(load)(); } }), label);
|
||
fill($app, h("div", { class: "row" }, h("h1", { style: "flex:1" }, "Задачи и поручения"), h("a", { class: "btn", href: "#/new-task" }, "+ Задание")),
|
||
h("div", { class: "row", style: "margin-bottom:12px" },
|
||
h("input", { type: "date", value: tfilt.day || "", style: "width:auto", onchange: e => { tfilt.day = e.target.value || null; guard(load)(); } }),
|
||
h("button", { class: "sec sm", onclick: () => { tfilt.day = todayISO(); tasksPage(); } }, "Сегодня"),
|
||
h("button", { class: "sec sm", onclick: () => { tfilt.day = null; tasksPage(); } }, "Все дни"),
|
||
h("select", { style: "width:auto", onchange: e => { tfilt.status = e.target.value; guard(load)(); } }, h("option", { value: "" }, "Все статусы"),
|
||
...Object.entries(TASK_ST).map(([k, v]) => h("option", { value: k, selected: tfilt.status === k }, v))),
|
||
boss ? h("div", { style: "min-width:260px" }, searchSelect(h("select", { onchange: e => { tfilt.assignee_id = e.target.value; guard(load)(); } }, h("option", { value: "" }, "Все сотрудники"),
|
||
...users.map(u => h("option", { value: u.id, selected: String(tfilt.assignee_id) === String(u.id) }, u.full_name || u.username))), "🔍 Сотрудник")) : null,
|
||
ch("mine", "Только мои")),
|
||
box);
|
||
await load();
|
||
timer = setInterval(guard(load), 30000);
|
||
}
|
||
|
||
async function unitOptions(eqId) { return eqId ? api("GET", `/equipment/${eqId}/service-units`) : []; }
|
||
|
||
async function taskForm(t) {
|
||
const boss = isEng();
|
||
const [eqs, users] = await Promise.all([ref("equipment"), boss ? ref("users") : Promise.resolve([])]);
|
||
const pre = +(new URLSearchParams(location.hash.split("?")[1] || "").get("eq") || 0);
|
||
const s = t ? { ...t } : { title: "", description: "", day: todayISO(), assignee_id: me.id, equipment_id: pre || null, unit_id: null };
|
||
const unitSel = h("select", { onchange: e => s.unit_id = e.target.value ? +e.target.value : null });
|
||
async function fillUnits() {
|
||
const us = await unitOptions(s.equipment_id);
|
||
unitSel.replaceChildren(h("option", { value: "" }, "— вся машина —"), ...us.map(u => h("option", { value: u.id, selected: s.unit_id === u.id }, u.name)));
|
||
}
|
||
await fillUnits();
|
||
fill($app, h("a", { href: t ? "#/task/" + t.id : "#/tasks" }, "← Назад"), h("h1", {}, t ? "Изменить задание" : "Новая задача (поручение)"),
|
||
h("div", { class: "card" }, h("div", { class: "grid" },
|
||
field("Что сделать *", h("input", { value: s.title, onchange: e => s.title = e.target.value })),
|
||
field("День", h("input", { type: "date", value: s.day, onchange: e => s.day = e.target.value })),
|
||
boss ? field("Исполнитель", searchSelect(h("select", { onchange: e => s.assignee_id = +e.target.value }, ...users.filter(u => u.is_active).map(u => h("option", { value: u.id, selected: s.assignee_id === u.id }, u.full_name || u.username)))))
|
||
: field("Исполнитель", h("div", {}, "я (" + (me.full_name || me.username) + ")")),
|
||
field("Оборудование", h("select", { onchange: guard(async e => { s.equipment_id = e.target.value ? +e.target.value : null; s.unit_id = null; await fillUnits(); }) },
|
||
h("option", { value: "" }, "—"), ...eqs.map(x => h("option", { value: x.id, selected: s.equipment_id === x.id }, x.name)))),
|
||
field("Узел", unitSel),
|
||
field("Трудоёмкость (норма), ч", h("input", { type: "number", min: "0", step: "0.25", value: s.labor_hours ?? "", placeholder: "необязательно",
|
||
onchange: e => s.labor_hours = e.target.value === "" ? null : +e.target.value }), "Для отчёта по сотрудникам; пусто — учитывается фактическое время в работе")),
|
||
h("div", { style: "margin-top:8px" }, field("Описание", h("textarea", { onchange: e => s.description = e.target.value }, s.description))),
|
||
h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => {
|
||
const body = { title: s.title, description: s.description, day: s.day || null, assignee_id: s.assignee_id, equipment_id: s.equipment_id, unit_id: s.unit_id, labor_hours: s.labor_hours ?? null };
|
||
const r = t ? await api("PATCH", "/tasks/" + t.id, body) : await api("POST", "/tasks", body);
|
||
toast("Сохранено"); location.hash = "#/task/" + r.id;
|
||
}) }, t ? "Сохранить" : "Создать задание"))));
|
||
}
|
||
|
||
async function taskPage(id) {
|
||
const t = await api("GET", "/tasks/" + id);
|
||
const people = t.can_transfer ? (await api("GET", "/users")).filter(u => u.is_active && u.id !== t.assignee_id) : [];
|
||
const trTo = h("select", {}, h("option", { value: "" }, "— кому передать —"),
|
||
...people.map(u => h("option", { value: u.id }, (u.full_name || u.username) + (u.position ? " · " + u.position.name : ""))));
|
||
const trWhy = h("input", { placeholder: "Причина передачи (обязательно)" });
|
||
const reload = () => taskPage(id);
|
||
const comment = h("textarea", { placeholder: "Комментарий (обязателен для «приостановлен», «выполнен», «отменён»)" });
|
||
const next = t.can_change ? TASK_NEXT[t.status] : [];
|
||
const photos = h("div", { class: "photos" }, ...t.photos.map(pid => {
|
||
const img = authImg(`/task-photos/${pid}`, { class: "thumb" });
|
||
img.onclick = () => img.src && window.open(img.src, "_blank");
|
||
return h("div", { class: "ph" }, img, t.can_change || isEng() ? h("button", { class: "x", title: "Удалить", onclick: guard(async () => { if (confirm("Удалить фото?")) { await api("DELETE", "/task-photos/" + pid); reload(); } }) }, "✕") : null);
|
||
}), t.can_change || t.can_edit ? filePicker(async f => { toast("Загрузка…"); await sendFile("POST", `/tasks/${id}/photos`, f); reload(); }, "📷 Добавить фото") : null);
|
||
fill($app, h("a", { href: "#/tasks" }, "← Задачи и поручения"),
|
||
h("div", { class: "card" }, h("div", { class: "row" }, h("h1", { style: "margin:0;flex:1" }, t.title), h("span", { class: "badge t-" + t.status }, TASK_ST[t.status])),
|
||
h("div", { class: "grid", style: "margin-top:12px" },
|
||
h("div", {}, h("label", {}, "День"), new Date(t.day).toLocaleDateString("ru-RU")),
|
||
h("div", {}, h("label", {}, "Исполнитель"), h("a", { href: "#/user/" + t.assignee_id }, t.assignee_name || "—")),
|
||
h("div", {}, h("label", {}, "Поставил"), t.created_by_name || "—"),
|
||
h("div", {}, h("label", {}, "Оборудование"), t.equipment_id ? h("a", { href: "#/equipment/" + t.equipment_id }, t.equipment_name) : "—", t.unit_name ? " / " + t.unit_name : ""),
|
||
h("div", {}, h("label", {}, "Завершено"), fmt(t.finished_at)),
|
||
t.labor_hours ? h("div", {}, h("label", {}, "Трудоёмкость (норма)"), `${num(t.labor_hours)} ч`) : null),
|
||
t.description ? h("div", { style: "margin-top:10px;white-space:pre-wrap" }, t.description) : null,
|
||
t.can_edit ? h("div", { style: "margin-top:10px" }, h("button", { class: "sec sm", onclick: guard(() => taskForm(t)) }, "Изменить"),
|
||
" ", h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить задание?")) { await api("DELETE", "/tasks/" + id); location.hash = "#/tasks"; } }) }, "Удалить")) : null),
|
||
next.length ? h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Статус"), comment,
|
||
h("div", { class: "row", style: "margin-top:10px" }, ...next.map(s => h("button", { class: s === "cancelled" ? "bad" : s === "done" ? "" : "sec", onclick: guard(async () => {
|
||
if ((TASK_NEED.includes(s) || ["done", "cancelled"].includes(t.status)) && !comment.value.trim()) { comment.focus(); throw new Error("Напишите комментарий"); }
|
||
await api("POST", `/tasks/${id}/status`, { status: s, comment: comment.value }); toast(TASK_ST[s]); reload();
|
||
}) }, TASK_BTN[s])))) : null,
|
||
t.can_transfer ? h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Передать задание"),
|
||
h("div", { class: "grid" }, field("Сотрудник", searchSelect(trTo)), field("Причина", trWhy)),
|
||
h("p", { class: "small muted" }, "Передать может исполнитель, тот, кто поставил задание, или инженер. Начатое задание у нового исполнителя станет «Назначен»."),
|
||
h("button", { class: "sec", onclick: guard(async () => {
|
||
if (!trTo.value) throw new Error("Выберите сотрудника");
|
||
if (!trWhy.value.trim()) { trWhy.focus(); throw new Error("Укажите причину передачи"); }
|
||
await api("POST", `/tasks/${id}/transfer`, { assignee_id: +trTo.value, comment: trWhy.value });
|
||
toast("Задание передано"); reload();
|
||
}) }, "⇄ Передать")) : null,
|
||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Фото"), photos),
|
||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "История"), ...(t.events || []).map(e => h("div", { class: "cm" },
|
||
e.kind === "transfer" ? h("div", { class: "small muted" }, `${fmt(e.at)} · ${e.user_name || "—"} · `, h("b", {}, `⇄ передано: ${e.from_user_name || "—"} → ${e.to_user_name || "—"}`))
|
||
: h("div", { class: "small muted" }, `${fmt(e.at)} · ${e.user_name || "—"} · `, e.from_status ? `${TASK_ST[e.from_status]} → ` : "", h("b", {}, TASK_ST[e.to_status])),
|
||
e.comment ? h("div", { style: "white-space:pre-wrap" }, e.comment) : null))));
|
||
}
|
||
|
||
// ======================= Карта завода и карта складов =======================
|
||
async function mapPage(layer) {
|
||
layer = ["warehouses", "cranes"].includes(layer) ? layer : "equipment";
|
||
const crane = layer === "cranes", q = crane ? "?kind=crane" : "";
|
||
const m = await api("GET", "/map" + q);
|
||
const canEdit = layer !== "warehouses" ? m.can_edit_equipment : m.can_edit_warehouses;
|
||
let editing = false, placing = null, zoneFor = null;
|
||
const side = h("div", { class: "mapside" }), info = h("div");
|
||
const stage = h("div", { class: "mapstage" });
|
||
const img = m.has_image ? authImg("/map/image?" + (crane ? "kind=crane&" : "") + "v=" + m.image_version, { class: "mapimg", draggable: "false" }) : null;
|
||
const pct = ev => { const r = stage.getBoundingClientRect(); return { x: Math.max(0, Math.min(100, (ev.clientX - r.left) / r.width * 100)), y: Math.max(0, Math.min(100, (ev.clientY - r.top) / r.height * 100)) }; };
|
||
const round = v => Math.round(v * 100) / 100;
|
||
|
||
function markers() {
|
||
const out = [];
|
||
if (layer !== "warehouses") {
|
||
for (const e of m.equipment.filter(e => e.x != null)) {
|
||
const mk = h("div", { class: `mk st-${e.state}` + (editing ? " edit" : ""), style: `left:${e.x}%;top:${e.y}%`, title: e.name },
|
||
eqIcon(e, "eqicon"), e.state === "alarm" ? h("span", { class: "alarm" }, "!") : e.state === "fault" ? h("span", { class: "alarm faultmk" }, "⚠") : null,
|
||
h("span", { class: "mkname" }, e.name));
|
||
if (editing) dragMarker(mk, e);
|
||
else mk.onclick = ev => { ev.stopPropagation(); showEq(e); };
|
||
out.push(mk);
|
||
}
|
||
} else {
|
||
for (const w of m.warehouses.filter(w => w.zone)) {
|
||
const z = w.zone;
|
||
const zone = h("div", { class: "zone", style: `left:${z.x}%;top:${z.y}%;width:${z.w}%;height:${z.h}%` }, h("span", {}, "🏬 " + w.name, h("small", {}, ` ${w.positions} поз.`)));
|
||
zone.onclick = ev => { ev.stopPropagation(); if (!editing) showWh(w); };
|
||
out.push(zone);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
function draw() {
|
||
stage.replaceChildren(...(img ? [img] : []), ...markers());
|
||
stage.classList.toggle("placing", !!(placing || zoneFor));
|
||
stage.classList.toggle("editing", editing);
|
||
drawSide();
|
||
}
|
||
function showEq(e) {
|
||
setTimeout(() => info.scrollIntoView({ behavior: "smooth", block: "nearest" }), 50);
|
||
fill(info, h("div", { class: "card" }, h("div", { class: "row" }, eqIcon(e, "eqicon"), h("b", { style: "flex:1" }, e.name), h("span", { class: "badge es-" + e.state }, EQ_STATE[e.state])),
|
||
e.open_incidents ? h("div", { class: "small", style: "color:var(--bad);margin-top:6px" }, `❗ Открытых отказов: ${e.open_incidents}`) : null,
|
||
e.open_faults ? h("div", { class: "small", style: "color:var(--fault);margin-top:6px" }, `⚠ Открытых неисправностей: ${e.open_faults}`) : null,
|
||
e.due || e.soon ? h("div", { class: "small", style: "color:var(--warn);margin-top:4px" }, `Узлы: требуется ТО — ${e.due}, скоро — ${e.soon}`) : null,
|
||
h("div", { class: "row", style: "margin-top:8px" }, h("a", { class: "btn sm", href: "#/equipment/" + e.id }, "Открыть карточку"))));
|
||
}
|
||
async function showWh(w) {
|
||
setTimeout(() => info.scrollIntoView({ behavior: "smooth", block: "nearest" }), 300);
|
||
if (!m.can_see_stock) return fill(info, h("div", { class: "card" }, h("b", {}, "🏬 " + w.name), h("p", { class: "small muted" }, "Состав склада видит администратор склада и инженеры.")));
|
||
const st = await api("GET", "/inventory/stock?warehouse_id=" + w.id);
|
||
fill(info, h("div", { class: "card" }, h("div", { class: "row" }, h("b", { style: "flex:1" }, "🏬 " + w.name), h("a", { class: "btn sec sm", href: "#/inventory/stock?wh=" + w.id }, "Открыть склад")),
|
||
st.length ? h("table", { style: "margin-top:6px" }, h("tbody", {}, ...st.map(s => h("tr", {}, h("td", {}, s.item_name, s.inventory_no ? h("span", { class: "small muted" }, ` (${s.inventory_no})`) : null), h("td", {}, `${num(s.qty)} ${s.unit}`))))) : h("p", { class: "small muted" }, "Склад пуст")));
|
||
}
|
||
function dragMarker(mk, e) {
|
||
mk.addEventListener("pointerdown", ev => {
|
||
ev.preventDefault(); mk.setPointerCapture(ev.pointerId); mk.classList.add("drag");
|
||
const move = mv => { const p = pct(mv); mk.style.left = p.x + "%"; mk.style.top = p.y + "%"; };
|
||
const up = guard(async uv => {
|
||
mk.removeEventListener("pointermove", move); mk.removeEventListener("pointerup", up); mk.classList.remove("drag");
|
||
const p = pct(uv); e.x = round(p.x); e.y = round(p.y);
|
||
await api("PUT", `/equipment/${e.id}/map`, { x: e.x, y: e.y }); toast("Сохранено: " + e.name);
|
||
});
|
||
mk.addEventListener("pointermove", move); mk.addEventListener("pointerup", up);
|
||
});
|
||
}
|
||
// размещение оборудования кликом / рисование зоны склада протягиванием
|
||
stage.addEventListener("click", guard(async ev => {
|
||
if (!placing) return;
|
||
const p = pct(ev), e = placing;
|
||
e.x = round(p.x); e.y = round(p.y); placing = null;
|
||
await api("PUT", `/equipment/${e.id}/map`, { x: e.x, y: e.y }); toast("Размещено: " + e.name); draw();
|
||
}));
|
||
stage.addEventListener("pointerdown", ev => {
|
||
if (!zoneFor) return;
|
||
ev.preventDefault();
|
||
const a = pct(ev), box = h("div", { class: "zone drawing" });
|
||
stage.append(box);
|
||
const rect = b => ({ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(b.x - a.x), h: Math.abs(b.y - a.y) });
|
||
const paint = z => Object.assign(box.style, { left: z.x + "%", top: z.y + "%", width: z.w + "%", height: z.h + "%" });
|
||
const move = mv => paint(rect(pct(mv)));
|
||
const up = guard(async uv => {
|
||
window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up);
|
||
const z = rect(pct(uv)), w = zoneFor;
|
||
if (z.w < 1 || z.h < 1) { box.remove(); return; }
|
||
zoneFor = null;
|
||
const r = await api("PUT", `/warehouses/${w.id}/zone`, { zone: { x: round(z.x), y: round(z.y), w: round(z.w), h: round(z.h) } });
|
||
Object.assign(w, r); toast("Зона склада сохранена"); draw();
|
||
});
|
||
window.addEventListener("pointermove", move); window.addEventListener("pointerup", up);
|
||
});
|
||
|
||
function drawSide() {
|
||
if (!canEdit) return fill(side, );
|
||
if (!editing) return fill(side, h("button", { class: "sec", onclick: () => { editing = true; draw(); } }, "✎ Редактировать карту"));
|
||
const parts = [h("div", { class: "row" }, h("b", { style: "flex:1" }, "Редактирование"), h("button", { class: "sm", onclick: () => { editing = false; placing = zoneFor = null; draw(); } }, "Готово"))];
|
||
if (layer !== "warehouses") {
|
||
if (me.is_admin) parts.push(h("div", { style: "margin:8px 0" }, filePicker(async f => {
|
||
if (f.size > 20 * 1048576) throw new Error("Файл больше 20 МБ");
|
||
toast("Загрузка карты…"); await sendFile("PUT", "/map/image" + q, f, 4000); toast("Карта обновлена"); mapPage(layer);
|
||
}, crane ? (m.own_image ? "🗺 Заменить картинку карты кранов" : "🗺 Загрузить свою картинку карты кранов") : m.has_image ? "🗺 Заменить картинку карты" : "🗺 Загрузить картинку карты", "sec")));
|
||
if (crane && !m.own_image) parts.push(h("p", { class: "small muted" }, "Пока своей картинки нет, краны ставятся на план завода."));
|
||
if (!m.equipment.length) parts.push(h("p", { class: "small" }, crane ? "Кранов нет: отметьте «Кран» в карточке оборудования (Оборудование → Изменить)." : "Оборудования нет."));
|
||
parts.push(h("p", { class: "small muted" }, placing ? `Кликните на карте, куда поставить «${placing.name}»` : "Перетаскивайте иконки мышью или пальцем. Чтобы поставить машину, выберите её ниже и кликните по карте."));
|
||
parts.push(h("div", { class: "eqpick" }, ...m.equipment.map(e => h("div", { class: "row" + (placing === e ? " on" : "") },
|
||
eqIcon(e, "eqicon sm"), h("span", { style: "flex:1" }, e.name),
|
||
h("button", { class: "sec sm", onclick: () => { placing = e; zoneFor = null; draw(); } }, e.x == null ? "Поставить" : "Переставить"),
|
||
e.x != null ? h("button", { class: "sec sm", title: "Убрать с карты", onclick: guard(async () => { await api("PUT", `/equipment/${e.id}/map`, { x: null, y: null }); e.x = e.y = null; draw(); }) }, "✕") : null))));
|
||
} else {
|
||
parts.push(h("p", { class: "small muted" }, zoneFor ? `Протяните мышью или пальцем прямоугольник зоны склада «${zoneFor.name}»` : "Выберите склад и протяните на карте прямоугольник его зоны."));
|
||
parts.push(h("div", { class: "eqpick" }, ...m.warehouses.map(w => h("div", { class: "row" + (zoneFor === w ? " on" : "") },
|
||
h("span", { style: "flex:1" }, "🏬 " + w.name),
|
||
h("button", { class: "sec sm", onclick: () => { zoneFor = w; placing = null; draw(); } }, w.zone ? "Перерисовать" : "Отметить зону"),
|
||
w.zone ? h("button", { class: "sec sm", title: "Убрать зону", onclick: guard(async () => { await api("PUT", `/warehouses/${w.id}/zone`, { zone: null }); w.zone = null; draw(); }) }, "✕") : null))));
|
||
if (!m.warehouses.length) parts.push(h("p", { class: "small" }, h("a", { href: "#/inventory/stock" }, "Сначала создайте склады")));
|
||
}
|
||
fill(side, h("div", { class: "card" }, ...parts));
|
||
}
|
||
|
||
const legend = layer !== "warehouses" ? h("div", { class: "legend small muted" },
|
||
h("span", {}, h("i", { class: "lg alarm" }, "!"), " отказ (оборудование остановлено)"), h("span", {}, h("i", { class: "lg alarm faultmk" }, "⚠"), " неисправность (оборудование работает)"),
|
||
h("span", {}, h("i", { class: "lg due" }), " требуется ТО"),
|
||
h("span", {}, h("i", { class: "lg soon" }), " приближается лимит")) : h("div", { class: "legend small muted" }, "Нажмите на зону склада, чтобы увидеть, что на нём лежит.");
|
||
fill($app, h("h1", {}, crane ? "Карта кранов" : layer !== "warehouses" ? "Карта завода" : "Карта складов"),
|
||
tabs([["#/map", "Оборудование"], ...(m.crane_map ? [["#/map/cranes", "Краны"]] : []), ["#/map/warehouses", "Склады"]], layer === "equipment" ? "#/map" : "#/map/" + layer),
|
||
m.crane_map && layer === "equipment" ? h("p", { class: "small muted" }, "Краны вынесены на отдельную карту — вкладка «Краны».") : null,
|
||
legend, side,
|
||
m.has_image ? zoomable(stage, { key: "map-" + layer, isEditing: () => editing && !!(placing || zoneFor) }) : empty(me.is_admin ? "Карта ещё не загружена. Нажмите «Редактировать карту» и загрузите картинку плана завода." : "Администратор ещё не загрузил карту завода."),
|
||
info);
|
||
if (!m.has_image && me.is_admin && layer !== "warehouses") { editing = true; }
|
||
draw();
|
||
timer = setInterval(guard(async () => { // состояние оборудования обновляется само
|
||
if (editing || layer === "warehouses") return;
|
||
const fresh = await api("GET", "/map" + q);
|
||
m.equipment = fresh.equipment; draw();
|
||
}), 60000);
|
||
}
|
||
|
||
// ======================= Сотрудники =======================
|
||
async function peoplePage() {
|
||
const users = (await ref("users", true)).filter(u => u.is_active);
|
||
const deps = await ref("departments", true);
|
||
const depName = Object.fromEntries(deps.map(d => [d.id, d.name]));
|
||
// отдел вместе с вложенными отделами
|
||
const under = id => { const out = new Set([id]); let grown = true; while (grown) { grown = false; for (const d of deps) if (!out.has(d.id) && out.has(d.parent_id)) { out.add(d.id); grown = true; } } return out; };
|
||
let dep = new URLSearchParams(location.hash.split("?")[1] || "").get("dep") || "";
|
||
const q = h("input", { type: "search", placeholder: "🔍 Поиск по ФИО, должности, отделу, навыкам", style: "max-width:420px" });
|
||
const depSel = h("select", { style: "width:auto", onchange: e => { dep = e.target.value; draw(); } }, h("option", { value: "" }, "Все отделы"),
|
||
h("option", { value: "none", selected: dep === "none" }, "— без отдела —"), ...deps.map(d => h("option", { value: d.id, selected: String(d.id) === dep }, d.name + ` (${d.members})`)));
|
||
const grid = h("div", { class: "people" }), count = h("div", { class: "small muted", style: "margin-bottom:8px" });
|
||
const draw = () => {
|
||
const s = q.value.trim().toLowerCase();
|
||
const set = dep && dep !== "none" ? under(+dep) : null;
|
||
const list = users.filter(u => (!dep || (dep === "none" ? !u.department_id : set.has(u.department_id)))
|
||
&& (!s || [u.full_name, u.username, u.position?.name, depName[u.department_id], u.skills].join(" ").toLowerCase().includes(s)));
|
||
count.textContent = `Сотрудников: ${list.length}`;
|
||
grid.replaceChildren(...list.map(u =>
|
||
h("a", { class: "card person", href: "#/user/" + u.id }, avatar(u), h("div", {}, h("b", {}, u.full_name || u.username),
|
||
h("div", { class: "small muted" }, u.position?.name || "без должности", u.position ? (u.on_shift ? " · на смене" : " · не на смене") : ""),
|
||
u.department_id ? h("div", { class: "small" }, "🏢 " + (depName[u.department_id] || "")) : null,
|
||
u.skills ? h("div", { class: "small", style: "margin-top:2px" }, u.skills.length > 80 ? u.skills.slice(0, 80) + "…" : u.skills) : null))));
|
||
};
|
||
q.oninput = draw;
|
||
fill($app, h("h1", {}, "Сотрудники"), h("div", { class: "row", style: "margin-bottom:12px" }, q, depSel,
|
||
isMgr() ? h("a", { class: "btn sec sm", href: "#/departments" }, "Отделы") : null), count, grid);
|
||
draw();
|
||
}
|
||
|
||
async function userCardPage(id) {
|
||
const c = await api("GET", `/users/${id}/card`);
|
||
const u = c.user, reload = () => userCardPage(id);
|
||
const roles = [u.is_admin && "администратор", u.is_moderator && "модератор", u.is_storekeeper && "администратор склада"].filter(Boolean).join(", ");
|
||
const skills = h("textarea", { placeholder: "Например: сварка, электрика до 1000 В, гидравлика, работа на высоте" }, u.skills);
|
||
const phone = h("input", { value: u.phone, placeholder: "+7 …" });
|
||
fill($app, h("a", { href: "#/people" }, "← Сотрудники"),
|
||
h("div", { class: "card ucard" }, h("div", { class: "uphoto" }, avatar(u, "ava big"),
|
||
c.can_edit ? h("div", { class: "row", style: "margin-top:8px;justify-content:center" },
|
||
filePicker(async f => { await sendFile("PUT", `/users/${id}/photo`, f, 800); avatar.v[id] = (avatar.v[id] || 0) + 1; delete cache.users; if (+id === me.id) me = null; toast("Фото обновлено"); reload(); }, "📷 Фото"),
|
||
u.has_photo ? h("button", { class: "sec sm", onclick: guard(async () => { await api("DELETE", `/users/${id}/photo`); avatar.v[id] = (avatar.v[id] || 0) + 1; delete cache.users; reload(); }) }, "✕") : null) : null),
|
||
h("div", { style: "flex:1;min-width:240px" }, h("h1", { style: "margin:0 0 4px" }, u.full_name || u.username),
|
||
h("div", { class: "muted" }, u.position?.name || "Должность не указана", roles ? " · " + roles : ""),
|
||
u.department ? h("div", { style: "margin-top:2px" }, "🏢 ", h("a", { href: "#/people?dep=" + u.department.id }, u.department.name)) : null,
|
||
u.position ? h("div", { class: "small", style: "margin-top:4px" }, u.on_shift ? "🟢 Сейчас на смене" : "⚪ Сейчас не на смене") : null,
|
||
u.phone && !c.can_edit ? h("div", { style: "margin-top:6px" }, "📞 ", h("a", { href: "tel:" + u.phone.replace(/[^+\d]/g, "") }, u.phone)) : null)),
|
||
h("div", { class: "cols" },
|
||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "График"),
|
||
c.schedule.name ? h("div", { class: "small muted", style: "margin-bottom:6px" }, c.schedule.name) : null,
|
||
c.schedule.always ? h("p", {}, "Без графика — всегда на смене") :
|
||
c.schedule.shifts.length ? h("table", {}, h("tbody", {}, ...c.schedule.shifts.map(s => h("tr", {},
|
||
h("td", {}, new Date(s.start).toLocaleDateString("ru-RU", { weekday: "short", day: "2-digit", month: "2-digit" })),
|
||
h("td", {}, `${new Date(s.start).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}–${new Date(s.end).toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" })}`),
|
||
h("td", { class: "small muted" }, s.label || ""))))) : h("p", { class: "muted small" }, u.position ? "Смен в ближайшие 2 недели нет" : "Должность не назначена")),
|
||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Что умею"),
|
||
c.can_edit ? [skills, h("div", { style: "margin-top:8px" }, field("Телефон", phone)), h("button", { style: "margin-top:8px", onclick: guard(async () => {
|
||
await api("PATCH", `/users/${id}/profile`, { skills: skills.value, phone: phone.value }); delete cache.users; toast("Сохранено");
|
||
}) }, "Сохранить")] : h("div", { style: "white-space:pre-wrap" }, u.skills || h("span", { class: "muted" }, "не заполнено")))),
|
||
h("div", { class: "cols" },
|
||
h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, `Текущие задания (${c.tasks.length})`),
|
||
...(c.tasks.length ? c.tasks.map(t => h("div", { class: "cm" }, h("a", { href: "#/task/" + t.id }, t.title), " ", h("span", { class: "badge t-" + t.status }, TASK_ST[t.status]),
|
||
h("span", { class: "small muted" }, " · " + new Date(t.day).toLocaleDateString("ru-RU")))) : [h("p", { class: "muted small" }, "Нет")]),
|
||
c.orders.length ? [h("h2", {}, `Наряды (${c.orders.length})`), ...c.orders.map(o => h("div", { class: "cm" }, h("a", { href: "#/order/" + o.id }, `№${o.id} ${o.title}`), " ",
|
||
h("span", { class: "badge b-" + o.status }, STATUS[o.status]), o.due_at ? h("span", { class: "small muted" }, " · срок " + fmt(o.due_at)) : null))] : null),
|
||
c.inventory ? h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, `Выданный инструмент (${c.inventory.length})`),
|
||
c.inventory.length ? h("table", {}, h("tbody", {}, ...c.inventory.map(s => h("tr", {}, h("td", {}, s.item_name, s.inventory_no ? h("span", { class: "small muted" }, ` (${s.inventory_no})`) : null),
|
||
h("td", {}, `${num(s.qty)} ${s.unit}`), h("td", { class: "small muted" }, "с " + fmt(s.since)))))) : h("p", { class: "muted small" }, "Ничего не выдано")) : null));
|
||
}
|