Files
tech-service-portal/app/static/service.js
T

173 lines
17 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 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();
}