Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android

This commit is contained in:
darkorion
2026-10-13 09:09:53 +03:00
commit c298f5a830
247 changed files with 23200 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
"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,
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();
}