"use strict"; // ---------- Регламентное обслуживание: карточка машины (узлы, наработка) и отчёт «Текущее состояние» ---------- const SVC_STATE = { due: "Требуется", soon: "Скоро", ok: "В норме", nodata: "Нет данных" }; const SVC_MODE = [["hours", "По наработке (моточасы)"], ["calendar", "По календарю"], ["both", "И то и другое — что наступит раньше"]]; const SVC_UNITS = [["day", "дней"], ["week", "недель"], ["month", "месяцев"]]; const fmtDate = s => s ? new Date(s).toLocaleDateString("ru-RU") : "—"; const nowInput = () => { const d = new Date(); d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); return d.toISOString().slice(0, 16); }; const hrs = v => v == null ? "—" : (Math.round(v * 10) / 10).toLocaleString("ru-RU"); const stateBadge = u => h("span", { class: "badge s-" + u.state }, SVC_STATE[u.state] || u.state); const progress = u => h("div", { class: "bar " + u.state, title: u.percent + "% ресурса" }, h("i", { style: `width:${Math.min(100, u.percent)}%` })); function remainingLine(u) { const bits = []; if (u.remaining_hours != null) bits.push(u.remaining_hours > 0 ? `осталось ${hrs(u.remaining_hours)} м/ч` : `просрочено на ${hrs(-u.remaining_hours)} м/ч`); if (u.remaining_days != null) bits.push(u.remaining_days > 0 ? `осталось ${u.remaining_days} дн.` : u.remaining_days < 0 ? `просрочено на ${-u.remaining_days} дн.` : "срок — сегодня"); return bits.join(" · ") || "—"; } async function equipmentPage(id) { const [eq, units, meter] = await Promise.all([api("GET", "/equipment/" + id), api("GET", `/equipment/${id}/service-units`), api("GET", `/equipment/${id}/meter?limit=8`)]); const locs = await ref("locations"); const loc = locs.find(l => l.id === eq.location_id); const formBox = h("div"), canEdit = isEng(); const reload = () => equipmentPage(id); const portal = await equipmentPortalBlocks(eq, units, reload); // ----- наработка ----- const mh = h("input", { type: "number", step: "any", min: "0", placeholder: "Показание счётчика, м/ч" }), note = h("input", { placeholder: "Комментарий (необязательно)" }); const corr = h("input", { type: "checkbox" }); const meterCard = h("div", { class: "card" }, h("div", { class: "row" }, h("div", { style: "flex:1" }, h("div", { class: "small muted" }, "Текущая наработка"), h("div", { style: "font-size:28px;font-weight:700" }, `${hrs(eq.meter_hours)} м/ч`), h("div", { class: "small muted" }, eq.meter_updated_at ? "обновлено " + fmt(eq.meter_updated_at) : "показаний ещё не было"))), canEdit ? h("div", { class: "grid", style: "margin-top:10px" }, h("div", {}, h("label", {}, "Новое показание"), mh), h("div", {}, h("label", {}, "Комментарий"), note), h("div", {}, h("label", {}, "Коррекция"), h("label", { style: "display:flex;gap:6px;align-items:center;color:var(--text);font-size:14px;margin:0" }, corr, "счётчик заменён / обнулён (допускает значение меньше текущего)"))) : null, canEdit ? h("div", { class: "row", style: "margin-top:10px" }, h("button", { onclick: guard(async () => { if (mh.value === "") throw new Error("Введите показание счётчика"); await api("POST", `/equipment/${id}/meter`, { hours: +mh.value, correction: corr.checked, note: note.value }); delete cache.equipment; toast("Показание сохранено"); reload(); }) }, "Внести показание")) : null, meter.length ? h("details", { style: "margin-top:10px" }, h("summary", { class: "small muted" }, "История показаний"), h("table", {}, h("tbody", {}, ...meter.map(r => h("tr", {}, h("td", {}, fmt(r.at)), h("td", {}, hrs(r.hours) + " м/ч"), h("td", { class: "muted small" }, { manual: "вручную", order: "по наряду №" + r.order_id, correction: "коррекция" }[r.source] || r.source, r.note ? " · " + r.note : ""))))) ) : null); // ----- форма узла ----- function unitForm(u) { const v = u ? { ...u } : { name: "", notes: "", mode: "hours", interval_hours: null, interval_value: 1, interval_unit: "month", warn_percent: 10, is_active: true, last_service_at: nowInput(), last_service_hours: eq.meter_hours }; const fields = formFields([ { k: "name", label: "Название узла (например, «Масло двигателя»)", type: "text", req: 1 }, { k: "mode", label: "Как обслуживается", type: "enum", options: SVC_MODE, reload: 1 }, { k: "interval_hours", label: "Через сколько моточасов", type: "number", show: x => x.mode !== "calendar" }, { k: "interval_value", label: "Календарный интервал", type: "number", show: x => x.mode !== "hours" }, { k: "interval_unit", label: "Единица интервала", type: "enum", options: SVC_UNITS.map(([a, b]) => [a, b]), show: x => x.mode !== "hours" }, { k: "warn_percent", label: "Предупреждать «Скоро», когда осталось, % интервала", type: "number" }, { k: "notes", label: "Примечание (марка масла, артикул...)", type: "textarea" }, ...(u ? [{ k: "is_active", label: "Отслеживается", type: "bool" }] : [ { k: "last_service_at", label: "Когда обслуживался в последний раз", type: "datetime", hint: "Начальное состояние: от этой даты считается календарный срок" }, { k: "last_service_hours", label: "Наработка машины при последнем обслуживании, м/ч", type: "number", hint: "От этого значения считаются моточасы" }]), ], v); formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, u ? "Узел: " + u.name : "Добавить обслуживаемый узел"), fields.el, h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => { const x = fields.vals, body = { name: x.name, notes: x.notes || "", mode: x.mode, warn_percent: x.warn_percent ?? 10, interval_hours: x.mode === "calendar" ? null : x.interval_hours, interval_value: x.mode === "hours" ? null : x.interval_value, interval_unit: x.mode === "hours" ? null : x.interval_unit }; if (u) { body.is_active = x.is_active; await api("PATCH", "/service-units/" + u.id, body); } else { body.last_service_at = x.last_service_at || null; body.last_service_hours = x.last_service_hours ?? null; await api("POST", `/equipment/${id}/service-units`, body); } toast("Сохранено"); reload(); }) }, "Сохранить"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена")))); formBox.scrollIntoView({ behavior: "smooth" }); } // ----- сброс узла ----- function resetForm(u) { const at = h("input", { type: "datetime-local", value: nowInput() }), hv = h("input", { type: "number", step: "any", value: eq.meter_hours, min: "0" }), nt = h("input", { placeholder: "Например: заменили вне наряда, внеплановая замена" }); formBox.replaceChildren(h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Сброс: " + u.name), h("p", { class: "small muted" }, "Используйте, если обслуживание или замена выполнены отдельно от наряда либо при внештатной ситуации. Остаток по узлу начнёт отсчитываться заново."), h("div", { class: "grid" }, h("div", {}, h("label", {}, "Когда обслужено"), at), h("div", {}, h("label", {}, "Наработка машины в этот момент, м/ч"), hv), h("div", {}, h("label", {}, "Комментарий"), nt)), h("div", { class: "row", style: "margin-top:12px" }, h("button", { onclick: guard(async () => { await api("POST", `/service-units/${u.id}/reset`, { at: at.value || null, hours: hv.value === "" ? null : +hv.value, note: nt.value }); toast("Узел сброшен"); reload(); }) }, "Сбросить узел"), h("button", { class: "sec", onclick: () => formBox.replaceChildren() }, "Отмена")))); formBox.scrollIntoView({ behavior: "smooth" }); } const evBox = {}; async function toggleHistory(u, box) { if (box.firstChild) return box.replaceChildren(); const ev = await api("GET", `/service-units/${u.id}/events`); box.replaceChildren(h("table", {}, h("tbody", {}, ...ev.map(e => h("tr", {}, h("td", {}, fmt(e.at)), h("td", {}, hrs(e.hours) + " м/ч"), h("td", { class: "small" }, { initial: "начальное состояние", order: "наряд №" + e.order_id, reset: "сброс", incident: "замена при отказе" }[e.kind] || e.kind, e.user_name ? " · " + e.user_name : "", e.note && e.kind !== "order" ? " · " + e.note : "")))))); } const unitCard = u => { const hist = h("div"); return h("div", { class: "card", style: u.is_active ? "" : "opacity:.6" }, h("div", { class: "row" }, h("b", { style: "flex:1;font-size:16px" }, u.name, u.is_active ? "" : h("span", { class: "muted small" }, " (не отслеживается)")), stateBadge(u)), h("div", { class: "small muted" }, u.rule, u.notes ? " · " + u.notes : ""), h("div", { style: "margin:8px 0" }, progress(u)), h("div", { class: "small" }, h("b", {}, remainingLine(u)), h("span", { class: "muted" }, ` · обслужено ${fmtDate(u.last_service_at)} при ${hrs(u.last_service_hours)} м/ч`)), u.due_date || u.forecast_date ? h("div", { class: "small muted" }, u.due_date ? "Срок по календарю: " + fmtDate(u.due_date) : "", u.due_date && u.forecast_date ? " · " : "", u.forecast_date ? "Прогноз: " + fmtDate(u.forecast_date) : "") : null, h("div", { class: "row", style: "margin-top:8px" }, canEdit ? h("button", { class: "sec sm", onclick: () => resetForm(u) }, "↺ Сбросить") : null, h("button", { class: "sec sm", onclick: guard(() => toggleHistory(u, hist)) }, "История"), canEdit ? h("button", { class: "sec sm", onclick: () => unitForm(u) }, "Изменить") : null, canEdit ? h("button", { class: "sec sm", onclick: guard(async () => { if (confirm(`Удалить узел «${u.name}» вместе с историей?`)) { await api("DELETE", "/service-units/" + u.id); reload(); } }) }, "✕") : null), hist); }; $app.replaceChildren(me.crane_map && eq.is_crane ? h("a", { href: "#/cranes" }, "← Краны") : h("a", { href: "#/equipment" }, "← Оборудование"), h("h1", {}, eq.name, eq.inventory_no ? h("span", { class: "muted", style: "font-size:16px" }, " № " + eq.inventory_no) : null), h("div", { class: "small muted", style: "margin-bottom:10px" }, loc ? "Место: " + loc.name : "", eq.notes ? " · " + eq.notes : ""), portal.head, docsBlock(eq, reload), meterCard, h("div", { class: "row", style: "margin:8px 0" }, h("h2", { style: "flex:1;margin:0" }, "Обслуживаемые узлы"), canEdit ? h("button", { onclick: () => unitForm(null) }, "+ Добавить обслуживаемый узел") : null), formBox, units.length ? h("div", {}, ...units.map(unitCard)) : h("div", { class: "card muted" }, "Узлов пока нет. Добавьте то, что нужно обслуживать: масло, фильтры, ремни — укажите, по наработке или по календарю."), portal.incidents, portal.history); } // ---------- Отчёт «Текущее состояние» ---------- async function stateReportPage() { const [eq, locs] = await Promise.all([ref("equipment"), ref("locations")]); const st = { equipment_id: "", location_id: "", state: "attention", include_inactive: false }; const result = h("div"), form = h("div", { class: "card" }); const q = format => { const p = new URLSearchParams({ format, state: st.state === "all" ? "" : st.state }); for (const k of ["equipment_id", "location_id"]) if (st[k]) p.set(k, st[k]); if (st.include_inactive) p.set("include_inactive", "true"); return p; }; const sel = (key, label, list, name) => h("div", {}, h("label", {}, label), h("select", { onchange: e => st[key] = e.target.value }, h("option", { value: "" }, "Все"), ...list.map(x => h("option", { value: x.id, selected: String(st[key]) === String(x.id) }, name(x))))); async function download(format) { const r = await authFetch(`${API}/reports/equipment-state?${q(format)}`); if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); } const a = document.createElement("a"); a.href = URL.createObjectURL(await r.blob()); a.download = `equipment_state_${new Date().toISOString().slice(0, 10)}.${format}`; document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000); } async function show() { const j = await api("GET", `/reports/equipment-state?${q("json")}`); const tile = (k, label) => h("div", { class: "stat", onclick: () => { st.state = k; draw(); guard(show)(); } }, h("b", { class: "s-" + k }, j.summary[k] ?? 0), h("span", {}, label)); result.replaceChildren(h("div", { class: "stats", style: "margin-bottom:10px" }, tile("due", "Требуется обслуживание"), tile("soon", "Скоро"), tile("ok", "В норме")), h("div", { class: "card tablewrap" }, h("div", { class: "small muted", style: "margin-bottom:8px" }, `Узлов в отчёте: ${j.total} · на ${fmt(j.generated_at)}`), j.rows.length ? h("table", {}, h("thead", {}, h("tr", {}, ...["Машина", "Узел", "Правило", "Состояние", "Обслужен", "Остаток", "Срок / прогноз"].map(t => h("th", {}, t)))), h("tbody", {}, ...j.rows.map(r => h("tr", { class: "click", onclick: () => location.hash = "#/equipment/" + r.equipment_id }, h("td", {}, r.equipment, r.inventory_no ? h("span", { class: "muted small" }, ` (${r.inventory_no})`) : null, r.location ? h("div", { class: "small muted" }, r.location) : null), h("td", {}, r.unit), h("td", { class: "small" }, r.rule), h("td", {}, h("span", { class: "badge s-" + r.state }, r.state_ru), h("div", { class: "bar " + r.state, style: "margin-top:4px" }, h("i", { style: `width:${Math.min(100, r.percent)}%` }))), h("td", { class: "small" }, fmtDate(r.last_service_at), h("div", { class: "muted" }, `${hrs(r.last_service_hours)} м/ч (сейчас ${hrs(r.meter_hours)})`)), h("td", { class: "small" }, remainingLine(r)), h("td", { class: "small" }, r.due_date ? "срок " + fmtDate(r.due_date) : "", r.due_date && r.forecast_date ? h("br") : null, r.forecast_date ? "прогноз " + fmtDate(r.forecast_date) : (r.due_date ? "" : "—")))))) : h("p", { class: "muted" }, "Нет узлов, подходящих под условия. Узлы добавляются в карточке машины («Оборудование» → «Узлы»)."))); } function draw() { form.replaceChildren(h("div", { class: "grid" }, sel("equipment_id", "Машина", eq, x => x.name + (x.inventory_no ? ` (${x.inventory_no})` : "")), sel("location_id", "Место", locs, x => x.name), h("div", {}, h("label", {}, "Показать"), h("select", { onchange: e => st.state = e.target.value }, ...[["attention", "Требуют внимания (требуется + скоро)"], ["due", "Только «Требуется»"], ["soon", "Только «Скоро»"], ["ok", "Только «В норме»"], ["all", "Все узлы"]] .map(([k, t]) => h("option", { value: k, selected: st.state === k }, t)))), h("div", {}, h("label", {}, "Дополнительно"), h("label", { style: "display:flex;gap:6px;align-items:center;color:var(--text);font-size:14px" }, h("input", { type: "checkbox", checked: st.include_inactive, onchange: e => st.include_inactive = e.target.checked }), "Включая отключённые узлы"))), h("div", { class: "row", style: "margin-top:14px" }, h("button", { onclick: guard(show) }, "Показать"), h("button", { class: "sec", onclick: guard(() => download("pdf")) }, "⬇ PDF"), h("button", { class: "sec", onclick: guard(() => download("csv")) }, "⬇ CSV (Excel)"))); } $app.replaceChildren(h("a", { href: "#/reports" }, "← Отчёты"), h("h1", {}, "Текущее состояние"), h("p", { class: "small muted" }, "Что требует обслуживания сейчас и что потребуется скоро — по правилам обслуживания узлов каждой машины."), form, result); draw(); await show(); }