Портал технической службы: наряды и чек-листы, задачи, отказы и неисправности, анализ надёжности, карты, склады, приложение для Android
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"use strict";
|
||||
// ---------- Отчёты ----------
|
||||
const REPORTS = {
|
||||
"completed-orders": { title: "Выполненные наряды", desc: "Список: машина, чек-лист, кто выполнил, должность, когда." },
|
||||
"equipment-state": { title: "Текущее состояние", desc: "Какие узлы машин требуют обслуживания сейчас и что потребуется скоро; остаток и прогноз." },
|
||||
"people": { title: "По сотрудникам", desc: "Сколько и как качественно выполнено нарядов и задач, трудоёмкость; списком и календарём." },
|
||||
"execution": { title: "Выполнение работ", desc: "По оборудованию: % выполнения чек-листов, суточных осмотров, ТО и заданий; отклонения и комментарии." },
|
||||
};
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
const toInput = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
async function reportsPage(id) {
|
||||
if (!id) {
|
||||
$app.replaceChildren(h("h1", {}, "Отчёты"), h("div", { class: "tiles" },
|
||||
...Object.entries(REPORTS).map(([k, r]) => h("a", { href: "#/reports/" + k, class: "tile" }, h("b", {}, r.title), h("span", { class: "muted small" }, r.desc)))));
|
||||
return;
|
||||
}
|
||||
if (id === "equipment-state") return stateReportPage();
|
||||
if (id === "execution") return executionReportPage();
|
||||
if (id === "people") return peopleReportPage();
|
||||
if (id !== "completed-orders") throw new Error("Отчёт не найден");
|
||||
const [eq, pos, users] = await Promise.all([ref("equipment"), ref("positions"), ref("users")]);
|
||||
const st = { from: "", to: "", equipment_id: "", position_id: "", user_id: "", include_review: false };
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const setRange = (a, b) => { st.from = toInput(a); st.to = toInput(b); };
|
||||
setRange(today, new Date());
|
||||
|
||||
const result = h("div"), form = h("div", { class: "card" });
|
||||
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)))));
|
||||
|
||||
function params(format) {
|
||||
if (!st.from || !st.to) throw new Error("Укажите начало и конец периода (дата и время)");
|
||||
if (st.to <= st.from) throw new Error("Конец периода должен быть позже начала");
|
||||
const q = new URLSearchParams({ from: st.from, to: st.to + ":59", format }); // "по" включает всю указанную минуту
|
||||
for (const k of ["equipment_id", "position_id", "user_id"]) if (st[k]) q.set(k, st[k]);
|
||||
if (st.include_review) q.set("include_review", "true");
|
||||
return q;
|
||||
}
|
||||
function drawForm() {
|
||||
const dt = (key, label) => h("div", {}, h("label", {}, label + " *"), h("input", { type: "datetime-local", value: st[key], required: true, onchange: e => st[key] = e.target.value }));
|
||||
const quick = (label, fn) => h("button", { class: "sec sm", onclick: () => { fn(); drawForm(); } }, label);
|
||||
const ago = days => { const d = new Date(); d.setDate(d.getDate() - days); return d; };
|
||||
form.replaceChildren(h("div", { class: "grid" }, dt("from", "С"), dt("to", "По"),
|
||||
sel("equipment_id", "Машина", eq, x => x.name + (x.inventory_no ? ` (${x.inventory_no})` : "")),
|
||||
sel("position_id", "Должность исполнителя", pos, x => x.name),
|
||||
(() => { const d = sel("user_id", "Исполнитель", users, x => x.full_name || x.username); d.append(searchSelect(d.querySelector("select"))); return d; })(),
|
||||
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_review, onchange: e => st.include_review = e.target.checked }), "Включать наряды на проверке"))),
|
||||
h("div", { class: "row", style: "margin-top:10px" }, h("span", { class: "small muted" }, "Быстрый выбор:"),
|
||||
quick("Сегодня", () => setRange(today, new Date())),
|
||||
quick("Вчера", () => { const y = new Date(today); y.setDate(y.getDate() - 1); setRange(y, today); }),
|
||||
quick("7 дней", () => setRange(ago(7), new Date())), quick("30 дней", () => setRange(ago(30), new Date())),
|
||||
quick("Этот месяц", () => setRange(new Date(today.getFullYear(), today.getMonth(), 1), new Date()))),
|
||||
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)")));
|
||||
}
|
||||
async function download(format) {
|
||||
const r = await authFetch(`${API}/reports/completed-orders?${params(format)}`);
|
||||
if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); }
|
||||
const blob = await r.blob(), a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `completed_orders_${st.from.slice(0, 10)}_${st.to.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/completed-orders?${params("json")}`);
|
||||
result.replaceChildren(h("div", { class: "card tablewrap" },
|
||||
h("div", { class: "row", style: "margin-bottom:8px" }, h("b", {}, `Найдено нарядов: ${j.total}`), j.truncated ? h("span", { class: "warnline" }, "Показана только часть — сузьте период") : null),
|
||||
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 = "#/order/" + r.order_id },
|
||||
h("td", { class: "muted" }, r.order_id), h("td", {}, fmtFull(r.finished_at)),
|
||||
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.checklist), h("td", {}, r.executor), h("td", {}, r.position),
|
||||
h("td", {}, r.status === "completed" ? "Выполнен" : "На проверке"),
|
||||
h("td", {}, r.deviations ? h("span", { class: "badge b-overdue" }, r.deviations) : "—"))))) : h("p", { class: "muted" }, "За выбранный период выполненных нарядов нет.")));
|
||||
}
|
||||
$app.replaceChildren(h("a", { href: "#/reports" }, "← Отчёты"), h("h1", {}, REPORTS[id].title), form, result);
|
||||
drawForm();
|
||||
}
|
||||
const fmtFull = s => s ? new Date(s).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—";
|
||||
|
||||
// ---------- Отчёт «Выполнение работ» ----------
|
||||
async function executionReportPage() {
|
||||
const [eq, locs] = await Promise.all([ref("equipment"), ref("locations")]);
|
||||
const st = { from: "", to: "", equipment_id: "", location_id: "" };
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const setRange = (a, b) => { st.from = toInput(a); st.to = toInput(b); };
|
||||
const ago = days => { const d = new Date(today); d.setDate(d.getDate() - days); return d; };
|
||||
setRange(ago(7), new Date());
|
||||
const result = h("div"), form = h("div", { class: "card" });
|
||||
const params = format => {
|
||||
if (!st.from || !st.to) throw new Error("Укажите период");
|
||||
const q = new URLSearchParams({ from: st.from, to: st.to + ":59", format });
|
||||
for (const k of ["equipment_id", "location_id"]) if (st[k]) q.set(k, st[k]);
|
||||
return q;
|
||||
};
|
||||
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)))));
|
||||
const pc = c => !c || !c.planned ? h("span", { class: "muted" }, "—") :
|
||||
h("div", { class: "pc" }, h("b", { class: c.percent >= 90 ? "s-ok" : c.percent >= 60 ? "s-soon" : "s-due" }, c.percent + "%"),
|
||||
h("div", { class: "bar " + (c.percent >= 90 ? "" : c.percent >= 60 ? "soon" : "due") }, h("i", { style: `width:${c.percent}%` })),
|
||||
h("span", { class: "small muted" }, `${c.done} из ${c.planned}`));
|
||||
async function download(format) {
|
||||
const r = await authFetch(`${API}/reports/execution?${params(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 = `execution_${st.from.slice(0, 10)}_${st.to.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/execution?${params("json")}`);
|
||||
const cats = Object.entries(j.categories);
|
||||
result.replaceChildren(
|
||||
h("div", { class: "stats" }, ...cats.map(([k, t]) => h("div", { class: "stat" }, h("b", {}, j.summary[k].percent == null ? "—" : j.summary[k].percent + "%"),
|
||||
h("span", {}, `${t}: ${j.summary[k].done}/${j.summary[k].planned}`))),
|
||||
h("div", { class: "stat" }, h("b", {}, j.total.percent == null ? "—" : j.total.percent + "%"), h("span", {}, `Всего: ${j.total.done}/${j.total.planned}`))),
|
||||
j.rows.length ? h("div", { class: "card tablewrap" }, h("table", {},
|
||||
h("thead", {}, h("tr", {}, h("th", {}, "Оборудование"), ...cats.map(([, t]) => h("th", {}, t)), h("th", {}, "Итого"), h("th", {}, "Пункты"), h("th", {}, "Откл."))),
|
||||
h("tbody", {}, ...j.rows.map(r => {
|
||||
const det = h("tr", { style: "display:none" }, h("td", { colspan: cats.length + 4 }, r.deviations.length ? h("div", {}, ...r.deviations.map(d => h("div", { class: "cm small" },
|
||||
h("span", { class: "muted" }, `${fmt(d.at)} · ${d.category} · `),
|
||||
d.order_id ? h("a", { href: "#/order/" + d.order_id }, `наряд №${d.order_id}`) : h("a", { href: "#/task/" + d.task_id }, `задание №${d.task_id}`),
|
||||
` «${d.title}»: `, h("span", { class: d.kind === "out_of_range" ? "s-due" : "" }, d.text)))) : h("span", { class: "muted small" }, "Отклонений нет")));
|
||||
return [h("tr", { class: "click", onclick: () => det.style.display = det.style.display ? "" : "none" },
|
||||
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),
|
||||
...cats.map(([k]) => h("td", {}, pc(r.cats[k]))), h("td", {}, pc(r.total)),
|
||||
h("td", {}, r.items_percent == null ? "—" : r.items_percent + "%"),
|
||||
h("td", {}, r.deviations.length ? h("span", { class: "badge b-overdue" }, "▾ " + r.deviations.length) : "—")), det];
|
||||
})))) : h("div", { class: "card muted" }, "За период работ нет"),
|
||||
h("p", { class: "small muted" }, "Нажмите на строку, чтобы увидеть отклонения: значения вне нормы, возвраты с проверки, комментарии, отмены и приостановки."));
|
||||
}
|
||||
function draw() {
|
||||
const dt = (key, label) => h("div", {}, h("label", {}, label + " *"), h("input", { type: "datetime-local", value: st[key], onchange: e => st[key] = e.target.value }));
|
||||
const quick = (label, fn) => h("button", { class: "sec sm", onclick: () => { fn(); draw(); } }, label);
|
||||
form.replaceChildren(h("div", { class: "grid" }, dt("from", "С"), dt("to", "По"),
|
||||
sel("equipment_id", "Машина", eq, x => x.name), sel("location_id", "Место", locs, x => x.name)),
|
||||
h("div", { class: "row", style: "margin-top:10px" }, h("span", { class: "small muted" }, "Быстрый выбор:"),
|
||||
quick("Сегодня", () => setRange(today, new Date())), quick("7 дней", () => setRange(ago(7), new Date())),
|
||||
quick("30 дней", () => setRange(ago(30), new Date())), quick("Этот месяц", () => setRange(new Date(today.getFullYear(), today.getMonth(), 1), new Date()))),
|
||||
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();
|
||||
}
|
||||
|
||||
// ---------- Отчёт «По сотрудникам» ----------
|
||||
async function peopleReportPage() {
|
||||
const [deps, pos, users] = await Promise.all([ref("departments"), ref("positions"), ref("users")]);
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
const st = { from: toInput(new Date(today.getFullYear(), today.getMonth(), 1)), to: toInput(new Date()), department_id: "", position_id: "", user_id: "", view: "list" };
|
||||
const ago = days => { const d = new Date(today); d.setDate(d.getDate() - days); return d; };
|
||||
const setRange = (a, b) => { st.from = toInput(a); st.to = toInput(b); };
|
||||
const result = h("div"), form = h("div", { class: "card" });
|
||||
const params = format => {
|
||||
if (!st.from || !st.to) throw new Error("Укажите период");
|
||||
const q = new URLSearchParams({ from: st.from, to: st.to + ":59", format });
|
||||
for (const k of ["department_id", "position_id", "user_id"]) if (st[k]) q.set(k, st[k]);
|
||||
return q;
|
||||
};
|
||||
const sel = (key, label, list, name) => { const s = 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)))); return h("div", {}, h("label", {}, label), key === "user_id" ? searchSelect(s) : s); };
|
||||
async function download(format) {
|
||||
const r = await authFetch(`${API}/reports/people?${params(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 = `people_${st.from.slice(0, 10)}_${st.to.slice(0, 10)}.${format === "pdf" ? "pdf" : "csv"}`;
|
||||
document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
}
|
||||
const pct = p => p == null ? h("span", { class: "muted" }, "—") : h("b", { class: p >= 90 ? "s-ok" : p >= 70 ? "s-soon" : "s-due" }, p + "%");
|
||||
async function show() {
|
||||
const j = await api("GET", `/reports/people?${params("json")}`);
|
||||
const T = j.totals;
|
||||
const stats = h("div", { class: "stats" }, ...[[T.done, "выполнено всего"], [T.orders_done, "нарядов"], [T.tasks_done, "задач и поручений"],
|
||||
[T.hours, "трудоёмкость, ч"], [T.overdue, "просрочено"], [T.returns, "возвратов с проверки"]].map(([n, l]) => h("div", { class: "stat" }, h("b", {}, n), h("span", {}, l))));
|
||||
const tabs = h("div", { class: "tabs" }, ...[["list", "Список"], ["cal", "Календарь"]].map(([k, t]) =>
|
||||
h("a", { href: "javascript:void 0", class: st.view === k ? "on" : "", onclick: () => { st.view = k; guard(show)(); } }, t)));
|
||||
let body;
|
||||
if (!j.rows.length) body = h("div", { class: "card muted" }, "За период выполненных работ нет");
|
||||
else if (st.view === "list") body = h("div", { class: "card tablewrap" }, h("table", {},
|
||||
h("thead", {}, h("tr", {}, ...["Сотрудник", "Выполнено", "Нарядов", "Задач", "В срок", "Просрочено", "Возвраты", "Отклонения", "Отмены / приост.", "Трудоёмкость, ч"].map(t => h("th", {}, t)))),
|
||||
h("tbody", {}, ...j.rows.map(r => h("tr", { class: "click", onclick: () => location.hash = "#/user/" + r.user_id },
|
||||
h("td", {}, h("b", {}, r.name), h("div", { class: "small muted" }, [r.position, r.department].filter(Boolean).join(" · "))),
|
||||
h("td", {}, h("b", {}, r.done), r.tasks_open ? h("div", { class: "small muted" }, `ещё открыто: ${r.tasks_open}`) : null),
|
||||
h("td", {}, r.orders_done), h("td", {}, `${r.tasks_done} из ${r.tasks_total}`), h("td", {}, pct(r.on_time_pct)),
|
||||
h("td", {}, r.overdue || "—"), h("td", {}, r.returns || "—"), h("td", {}, r.deviations || "—"),
|
||||
h("td", {}, `${r.tasks_cancelled || 0} / ${r.tasks_paused || 0}`), h("td", {}, h("b", {}, r.hours)))))));
|
||||
else {
|
||||
const maxH = Math.max(1, ...j.rows.flatMap(r => Object.values(r.calendar).map(c => c.h)));
|
||||
const wd = ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"];
|
||||
body = h("div", { class: "card tablewrap" }, h("table", { class: "pcal" },
|
||||
h("thead", {}, h("tr", {}, h("th", {}, "Сотрудник"), ...j.days.map(d => { const dt = new Date(d + "T00:00"); return h("th", { class: [0, 6].includes(dt.getDay()) ? "we" : "" }, d.slice(8, 10), h("div", {}, wd[dt.getDay()])); }),
|
||||
h("th", {}, "Итого"))),
|
||||
h("tbody", {}, ...j.rows.map(r => h("tr", {}, h("td", {}, h("b", {}, r.name)),
|
||||
...j.days.map(d => { const c = r.calendar[d]; return h("td", { class: "pc", style: c ? `background:color-mix(in srgb,var(--accent) ${Math.round(12 + 50 * c.h / maxH)}%,transparent)` : "",
|
||||
title: c ? `${c.n} задач(и), ${c.h} ч` : "" }, c ? [h("b", {}, c.n), h("div", {}, c.h + "ч")] : ""); }),
|
||||
h("td", { class: "pc" }, h("b", {}, r.done), h("div", {}, r.hours + "ч")))))),
|
||||
h("p", { class: "small muted" }, "В клетке: сколько нарядов и задач выполнено в этот день и их трудоёмкость, ч. Чем темнее — тем больше часов."));
|
||||
}
|
||||
result.replaceChildren(stats, tabs, body,
|
||||
h("p", { class: "small muted" }, "В срок — доля выполненного без просрочки. Трудоёмкость задачи — норма, если указана, иначе фактическое время «в работе»; наряда — время от начала до сдачи."));
|
||||
}
|
||||
function draw() {
|
||||
const dt = (key, label) => h("div", {}, h("label", {}, label + " *"), h("input", { type: "datetime-local", value: st[key], onchange: e => st[key] = e.target.value }));
|
||||
const quick = (label, fn) => h("button", { class: "sec sm", onclick: () => { fn(); draw(); guard(show)(); } }, label);
|
||||
form.replaceChildren(h("div", { class: "grid" }, dt("from", "С"), dt("to", "По"),
|
||||
sel("department_id", "Отдел", deps, x => x.name), sel("position_id", "Должность", pos, x => x.name), sel("user_id", "Сотрудник", users.filter(u => u.is_active), x => x.full_name || x.username)),
|
||||
h("div", { class: "row", style: "margin-top:10px" }, h("span", { class: "small muted" }, "Быстрый выбор:"),
|
||||
quick("Этот месяц", () => setRange(new Date(today.getFullYear(), today.getMonth(), 1), new Date())),
|
||||
quick("Прошлый месяц", () => setRange(new Date(today.getFullYear(), today.getMonth() - 1, 1), new Date(today.getFullYear(), today.getMonth(), 1))),
|
||||
quick("7 дней", () => setRange(ago(7), new Date())), quick("30 дней", () => setRange(ago(30), new Date()))),
|
||||
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: список"),
|
||||
h("button", { class: "sec", onclick: guard(() => download("csv_calendar")) }, "⬇ CSV: календарь")));
|
||||
}
|
||||
$app.replaceChildren(h("a", { href: "#/reports" }, "← Отчёты"), h("h1", {}, "По сотрудникам"),
|
||||
h("p", { class: "small muted" }, "Наряды — выполненные в периоде (по дате завершения), задачи и поручения — по их дню."), form, result);
|
||||
draw();
|
||||
await show();
|
||||
}
|
||||
Reference in New Issue
Block a user