"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: "closebtn", 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("label", { class: "chk", style: "margin-top:8px", title: "Фото к заданию кладутся в отдельный каталог и не удаляются автоочисткой сервера" }, h("input", { type: "checkbox", checked: !!s.photo_keep, onchange: e => s.photo_keep = e.target.checked }), "📷 Фото к заданию хранить более года"), 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, photo_keep: !!s.photo_keep }; 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)); }