Files

228 lines
21 KiB
JavaScript
Raw Permalink 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 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();
}