Files
tech-service-portal/app/static/portal.js
T
darkorion e67c175f80 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00

849 lines
82 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
// ---------- Портал: инструмент и склады, заявки, инциденты, суточные задания, карта, сотрудники ----------
const 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("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));
}