378 lines
15 KiB
JavaScript
378 lines
15 KiB
JavaScript
/**
|
||
* Контроль отклонений — лента событий (не дубль отчётов).
|
||
*/
|
||
(function (global) {
|
||
const EVENT_LABELS = {
|
||
OVERLOAD: "Перегруз",
|
||
UNDERLOAD: "Недогруз",
|
||
LOADING_TIME: "Долгая загрузка",
|
||
LOADING_FAST: "Быстрая загрузка",
|
||
MIX_TIME: "Смешивание",
|
||
LEFT_IN_MIXER: "Остаток в миксере",
|
||
};
|
||
|
||
const EVENT_ICONS = {
|
||
OVERLOAD: "fa-arrow-trend-up",
|
||
UNDERLOAD: "fa-arrow-trend-down",
|
||
LOADING_TIME: "fa-hourglass-half",
|
||
LOADING_FAST: "fa-forward",
|
||
MIX_TIME: "fa-clock",
|
||
LEFT_IN_MIXER: "fa-triangle-exclamation",
|
||
};
|
||
|
||
let currentView = "summary";
|
||
let journalSubView = "reports";
|
||
let alertsSeverityFilter = "";
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function pluralDeviations(count) {
|
||
const n = Math.abs(Number(count) || 0);
|
||
const mod10 = n % 10;
|
||
const mod100 = n % 100;
|
||
if (mod10 === 1 && mod100 !== 11) return `${n} отклонение`;
|
||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return `${n} отклонения`;
|
||
return `${n} отклонений`;
|
||
}
|
||
|
||
function getDateParams() {
|
||
return global.WespReportsFilters?.getDateParams?.() || {
|
||
date_from: document.getElementById("date-from")?.value || "",
|
||
date_to: document.getElementById("date-to")?.value || "",
|
||
};
|
||
}
|
||
|
||
function getDispenserId() {
|
||
return global.WespReportsFilters?.getDispenserId?.() || "";
|
||
}
|
||
|
||
function buildAlertsUrl() {
|
||
const params = global.WespReportsFilters?.buildAlertsParams?.() || new URLSearchParams(getDateParams());
|
||
if (alertsSeverityFilter) params.set("severity", alertsSeverityFilter);
|
||
return `/api/feed-quality/alerts?${params.toString()}`;
|
||
}
|
||
|
||
function buildSummaryUrl() {
|
||
const params = global.WespReportsFilters?.buildAlertsParams?.() || new URLSearchParams(getDateParams());
|
||
return `/api/feed-quality/alerts/summary?${params.toString()}`;
|
||
}
|
||
|
||
function deviationClass(devKg, eventType) {
|
||
if (eventType === "MIX_TIME" || eventType === "LOADING_TIME" || eventType === "LOADING_FAST") {
|
||
return "deviation-significant";
|
||
}
|
||
if (devKg == null || Number.isNaN(Number(devKg))) return "";
|
||
const v = Number(devKg);
|
||
if (v > 0) return "deviation-positive";
|
||
if (v < 0) return "deviation-negative";
|
||
return "";
|
||
}
|
||
|
||
function formatDeltaPct(item) {
|
||
if (
|
||
item.eventType === "MIX_TIME" ||
|
||
item.eventType === "LOADING_TIME" ||
|
||
item.eventType === "LOADING_FAST"
|
||
) {
|
||
if (item.deviationKg == null) return "—";
|
||
const sec = Number(item.deviationKg);
|
||
return `${sec > 0 ? "+" : ""}${Math.round(sec)} с`;
|
||
}
|
||
if (item.deviationPct == null) return "—";
|
||
const pct = Number(item.deviationPct);
|
||
return `${pct > 0 ? "+" : ""}${pct.toFixed(1)}%`;
|
||
}
|
||
|
||
function formatMixingClock(sec) {
|
||
const total = Math.max(0, Math.round(Number(sec) || 0));
|
||
const m = Math.floor(total / 60);
|
||
const s = total % 60;
|
||
return `${m}:${String(s).padStart(2, "0")}`;
|
||
}
|
||
|
||
function objectLabel(item) {
|
||
if (item.componentName) return item.componentName;
|
||
if (item.groupName) return `группа «${item.groupName}»`;
|
||
if (item.eventType === "MIX_TIME") return "смешивание";
|
||
if (item.eventType === "LEFT_IN_MIXER") return "миксер";
|
||
return "рейс";
|
||
}
|
||
|
||
function buildContextLine(item) {
|
||
const parts = [];
|
||
const target = item.targetKg;
|
||
const actual = item.actualKg;
|
||
|
||
if (item.eventType === "MIX_TIME") {
|
||
if (actual != null && target != null) {
|
||
parts.push(`факт ${formatMixingClock(actual)} при плане ${formatMixingClock(target)}`);
|
||
}
|
||
} else if (item.eventType === "LEFT_IN_MIXER") {
|
||
if (actual != null) parts.push(`осталось ${Number(actual).toFixed(1)} кг`);
|
||
} else if (item.groupName) {
|
||
if (actual != null && target != null) {
|
||
parts.push(`выгружено ${Number(actual).toFixed(1)} из ${Number(target).toFixed(1)} кг`);
|
||
} else if (actual != null) {
|
||
parts.push(`выгружено ${Number(actual).toFixed(1)} кг`);
|
||
}
|
||
if (item.durationSec != null && Number(item.durationSec) > 0) {
|
||
const sec = Number(item.durationSec);
|
||
parts.push(sec >= 60 ? `выгрузка ${(sec / 60).toFixed(1)} мин` : `выгрузка ${sec.toFixed(1)} с`);
|
||
}
|
||
} else if (item.componentName) {
|
||
if (actual != null && target != null) {
|
||
parts.push(`загружено ${Number(actual).toFixed(1)} из ${Number(target).toFixed(1)} кг`);
|
||
} else if (actual != null) {
|
||
parts.push(`загружено ${Number(actual).toFixed(1)} кг`);
|
||
}
|
||
if (item.durationSec != null && Number(item.durationSec) > 0) {
|
||
parts.push(`загрузка ${Number(item.durationSec).toFixed(1)} с`);
|
||
}
|
||
}
|
||
|
||
if (item.costDeviationRub != null && item.costDeviationRub > 0) {
|
||
parts.push(`+${Number(item.costDeviationRub).toFixed(0)} ₽`);
|
||
}
|
||
|
||
return parts.length ? parts.join(" · ") : (item.detail || "").split(":").pop()?.trim() || "";
|
||
}
|
||
|
||
function sortItems(items) {
|
||
return [...items].sort((a, b) => {
|
||
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||
return tb - ta;
|
||
});
|
||
}
|
||
|
||
async function refreshSummary() {
|
||
const chip = document.getElementById("feedAlertsSummaryChip");
|
||
if (!chip) return;
|
||
try {
|
||
const resp = await fetch(buildSummaryUrl());
|
||
if (!resp.ok) return;
|
||
const data = await resp.json();
|
||
const total = Number(data.total) || 0;
|
||
if (total <= 0) {
|
||
chip.hidden = true;
|
||
chip.textContent = "";
|
||
return;
|
||
}
|
||
chip.hidden = false;
|
||
const err = Number(data.error) || 0;
|
||
chip.textContent =
|
||
err > 0
|
||
? `${pluralDeviations(total)} (${err} критич.)`
|
||
: pluralDeviations(total) + " за период";
|
||
} catch {
|
||
chip.hidden = true;
|
||
}
|
||
}
|
||
|
||
function renderAlerts(items) {
|
||
const listEl = document.getElementById("alertsList");
|
||
if (!listEl) return;
|
||
if (!items || !items.length) {
|
||
listEl.innerHTML =
|
||
'<div class="empty-state wesp-content-reveal zt-empty">' +
|
||
'<div class="empty-icon"><i class="fas fa-check-circle"></i></div>' +
|
||
'<h3 class="zt-empty__title">Отклонений нет</h3>' +
|
||
'<p class="zt-empty__hint">За выбранный период отклонений не зафиксировано</p></div>';
|
||
global.WespZootechModals?.revealContent(listEl.querySelector(".empty-state"));
|
||
return;
|
||
}
|
||
|
||
const sorted = sortItems(items);
|
||
const html = sorted
|
||
.map((item) => {
|
||
const sev = item.severity || "warning";
|
||
const typeLabel = EVENT_LABELS[item.eventType] || "Отклонение";
|
||
const icon = EVENT_ICONS[item.eventType] || "fa-circle-exclamation";
|
||
const devClass = deviationClass(item.deviationKg, item.eventType);
|
||
const delta = formatDeltaPct(item);
|
||
const context = buildContextLine(item);
|
||
const when = item.createdAt
|
||
? new Date(item.createdAt).toLocaleString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})
|
||
: "";
|
||
const trip = item.recipeName || "Рейс";
|
||
return (
|
||
`<button type="button" class="deviation-alert-card list-item zt-card zt-card--interactive deviation-feed-item deviation-feed-item--${escapeHtml(sev)}" ` +
|
||
`data-loading-report-id="${escapeHtml(item.loadingReportId)}">` +
|
||
'<div class="deviation-alert-card__top">' +
|
||
`<span class="deviation-feed-item__dot deviation-feed-item__dot--${escapeHtml(sev)}" aria-hidden="true"></span>` +
|
||
'<div class="deviation-alert-card__head">' +
|
||
`<h3 class="deviation-alert-card__title">` +
|
||
`<i class="fas ${icon}" aria-hidden="true"></i>` +
|
||
`${escapeHtml(typeLabel)} · ${escapeHtml(objectLabel(item))}` +
|
||
"</h3>" +
|
||
`<span class="deviation-type-badge deviation-type-badge--${escapeHtml(sev)}">${escapeHtml(sev === "error" ? "Критично" : "Предупр.")}</span>` +
|
||
"</div>" +
|
||
`<span class="deviation-feed-item__delta ${escapeHtml(devClass)}">${escapeHtml(delta)}</span>` +
|
||
"</div>" +
|
||
(context ? `<div class="deviation-alert-card__info"><span class="deviation-alert-card__context">${escapeHtml(context)}</span></div>` : "") +
|
||
`<div class="deviation-alert-card__meta">${escapeHtml(trip)}${when ? ` · ${escapeHtml(when)}` : ""}</div>` +
|
||
'<span class="deviation-feed-item__chevron" aria-hidden="true"><i class="fas fa-chevron-right"></i></span>' +
|
||
"</button>"
|
||
);
|
||
})
|
||
.join("");
|
||
|
||
listEl.innerHTML = `<div class="deviation-feed">${html}</div>`;
|
||
listEl.querySelectorAll("[data-loading-report-id]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const id = btn.getAttribute("data-loading-report-id");
|
||
if (id) openReportFromAlert(id);
|
||
});
|
||
});
|
||
global.WespZootechModals?.revealContent(listEl.querySelector(".deviation-feed"));
|
||
}
|
||
|
||
async function loadAlerts() {
|
||
const listEl = document.getElementById("alertsList");
|
||
if (!listEl) return;
|
||
listEl.innerHTML =
|
||
'<div class="loading-state wesp-content-reveal"><div class="zt-spinner" role="status"></div>' +
|
||
'<p class="zt-loading-caption">Загрузка отклонений…</p></div>';
|
||
try {
|
||
const resp = await fetch(buildAlertsUrl());
|
||
if (!resp.ok) throw new Error("Не удалось загрузить отклонения");
|
||
const data = await resp.json();
|
||
renderAlerts(data.items || []);
|
||
await refreshSummary();
|
||
} catch (err) {
|
||
listEl.innerHTML =
|
||
`<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function openReportFromAlert(loadingReportId) {
|
||
switchView("journal");
|
||
setJournalSubView("reports");
|
||
global.__wespHighlightReportId = loadingReportId;
|
||
if (typeof global.filterAndDisplayReports === "function") {
|
||
global.filterAndDisplayReports();
|
||
} else if (typeof global.loadReports === "function") {
|
||
global.loadReports();
|
||
}
|
||
}
|
||
|
||
function openJournalAlerts() {
|
||
switchView("journal");
|
||
setJournalSubView("alerts");
|
||
}
|
||
|
||
function setJournalSubView(sub) {
|
||
journalSubView = sub === "alerts" ? "alerts" : "reports";
|
||
document.querySelectorAll("[data-journal-view-tab]").forEach((btn) => {
|
||
btn.classList.toggle("active", btn.getAttribute("data-journal-view-tab") === journalSubView);
|
||
});
|
||
const alertsContainer = document.querySelector(".reports-container--alerts");
|
||
const reportsContainer = document.querySelector(".reports-container--reports");
|
||
const severityBlock = document.getElementById("alertsSeverityBlock");
|
||
if (reportsContainer) reportsContainer.hidden = journalSubView !== "reports";
|
||
if (alertsContainer) alertsContainer.hidden = journalSubView !== "alerts";
|
||
if (severityBlock) severityBlock.hidden = journalSubView !== "alerts";
|
||
if (journalSubView === "alerts") loadAlerts();
|
||
else if (typeof global.loadReports === "function") global.loadReports();
|
||
}
|
||
|
||
function updateToolbarVisibility() {
|
||
const journalBlock = document.getElementById("journalSubTabsBlock");
|
||
if (journalBlock) journalBlock.hidden = currentView !== "journal";
|
||
const severityBlock = document.getElementById("alertsSeverityBlock");
|
||
if (severityBlock) {
|
||
severityBlock.hidden = currentView !== "journal" || journalSubView !== "alerts";
|
||
}
|
||
}
|
||
|
||
function switchView(view) {
|
||
const allowed = ["summary", "comparison", "journal"];
|
||
currentView = allowed.includes(view) ? view : "summary";
|
||
|
||
document.querySelectorAll("[data-reports-view-tab]").forEach((btn) => {
|
||
btn.classList.toggle("active", btn.getAttribute("data-reports-view-tab") === currentView);
|
||
});
|
||
|
||
const summaryEl = document.querySelector(".reports-container--summary");
|
||
const comparisonEl = document.querySelector(".reports-container--comparison");
|
||
const reportsContainer = document.querySelector(".reports-container--reports");
|
||
const alertsContainer = document.querySelector(".reports-container--alerts");
|
||
|
||
if (summaryEl) summaryEl.hidden = currentView !== "summary";
|
||
if (comparisonEl) comparisonEl.hidden = currentView !== "comparison";
|
||
if (reportsContainer) reportsContainer.hidden = currentView !== "journal" || journalSubView !== "reports";
|
||
if (alertsContainer) alertsContainer.hidden = currentView !== "journal" || journalSubView !== "alerts";
|
||
|
||
updateToolbarVisibility();
|
||
|
||
if (currentView === "summary") {
|
||
global.WespAnalyticsSummary?.load?.();
|
||
} else if (currentView === "comparison") {
|
||
global.WespAnalyticsPlanFact?.load?.();
|
||
} else if (currentView === "journal") {
|
||
setJournalSubView(journalSubView);
|
||
} else {
|
||
refreshSummary();
|
||
}
|
||
}
|
||
|
||
function setSeverityFilter(value) {
|
||
alertsSeverityFilter = value || "";
|
||
document.querySelectorAll("[data-alerts-severity]").forEach((btn) => {
|
||
btn.classList.toggle("active", btn.getAttribute("data-alerts-severity") === alertsSeverityFilter);
|
||
});
|
||
if (currentView === "journal" && journalSubView === "alerts") loadAlerts();
|
||
}
|
||
|
||
function init() {
|
||
document.querySelectorAll("[data-reports-view-tab]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
switchView(btn.getAttribute("data-reports-view-tab"));
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-journal-view-tab]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
if (currentView !== "journal") switchView("journal");
|
||
setJournalSubView(btn.getAttribute("data-journal-view-tab"));
|
||
});
|
||
});
|
||
document.querySelectorAll("[data-alerts-severity]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
setSeverityFilter(btn.getAttribute("data-alerts-severity") || "");
|
||
});
|
||
});
|
||
global.WespAnalyticsSummary?.init?.();
|
||
switchView("summary");
|
||
refreshSummary();
|
||
}
|
||
|
||
function onFiltersApplied() {
|
||
refreshSummary();
|
||
global.WespAnalyticsSummary?.onFiltersApplied?.();
|
||
global.WespAnalyticsPlanFact?.onFiltersApplied?.();
|
||
if (currentView === "journal" && journalSubView === "alerts") loadAlerts();
|
||
}
|
||
|
||
global.WespFeedAlerts = {
|
||
init,
|
||
loadAlerts,
|
||
refreshSummary,
|
||
switchView,
|
||
openReportFromAlert,
|
||
openJournalAlerts,
|
||
onFiltersApplied,
|
||
getCurrentView: () => currentView,
|
||
};
|
||
})(typeof window !== "undefined" ? window : globalThis);
|