@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Редактор серверной SQLite: дерево bind → таблицы, сетка с сохранением через /api/admin/db/*.
|
||||
*/
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function parseInputValue(raw, sampleOriginal) {
|
||||
const t = raw.trim();
|
||||
if (t === "" && (sampleOriginal === null || sampleOriginal === undefined)) return null;
|
||||
if (t === "") return "";
|
||||
if (/^-?\d+$/.test(t)) return Number(t);
|
||||
if (/^-?\d+\.\d+$/.test(t)) return Number(t);
|
||||
if (t === "true") return 1;
|
||||
if (t === "false") return 0;
|
||||
return raw;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const m = document.getElementById("adminSqliteModal");
|
||||
if (m) m.hidden = true;
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
let browseState = {
|
||||
bind: "recipes",
|
||||
table: null,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
page: null,
|
||||
};
|
||||
|
||||
/** Колбэк выбора таблицы (нужен для повторной отрисовки дерева). */
|
||||
let dbTreePickCallback = null;
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const r = await fetch(url, options);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
throw new Error(data.message || `HTTP ${r.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function renderTree(binds, preferredBind, onPickTable) {
|
||||
dbTreePickCallback = onPickTable;
|
||||
const root = document.getElementById("adminDbTree");
|
||||
if (!root) return;
|
||||
root.innerHTML = binds
|
||||
.map((b) => {
|
||||
const open = b.bind === preferredBind ? "is-open" : "";
|
||||
const rows = (b.tables || [])
|
||||
.map(
|
||||
(t) =>
|
||||
`<li class="wesp-db-tree-leaf"><button type="button" class="wesp-db-tree-table ant-btn ant-btn-sm" data-bind="${escHtml(b.bind)}" data-table="${escHtml(t.name)}">${escHtml(t.name)}</button></li>`,
|
||||
)
|
||||
.join("");
|
||||
return `<li class="wesp-db-tree-node ${open}">
|
||||
<button type="button" class="wesp-db-tree-caret" aria-expanded="${b.bind === preferredBind ? "true" : "false"}"><span class="wesp-db-tree-caret-icon">▶</span></button>
|
||||
<span class="wesp-db-tree-folder">${escHtml(b.bind)}</span>
|
||||
<ul class="wesp-db-tree-children">${rows}</ul>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
root.querySelectorAll(".wesp-db-tree-caret").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const li = btn.closest(".wesp-db-tree-node");
|
||||
if (!li) return;
|
||||
const open = li.classList.toggle("is-open");
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll(".wesp-db-tree-table").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const bind = btn.getAttribute("data-bind");
|
||||
const table = btn.getAttribute("data-table");
|
||||
root.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active"));
|
||||
btn.classList.add("is-active");
|
||||
onPickTable(bind, table);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderGrid(data, setStatus) {
|
||||
const meta = document.getElementById("adminDbGridMeta");
|
||||
const wrap = document.getElementById("adminDbGridWrap");
|
||||
const pager = document.getElementById("adminDbPager");
|
||||
if (!meta || !wrap || !pager) return;
|
||||
|
||||
browseState.page = data;
|
||||
const { columns, rows, total, has_rowid: hasRowid } = data;
|
||||
meta.textContent = `${data.bind} / ${data.table} — строк ${total}, показано ${rows.length} (offset ${browseState.offset})`;
|
||||
|
||||
const colMetaByName = Object.fromEntries((columns || []).map((c) => [c.name, c]));
|
||||
const displayCols = (data.column_names || []).filter((n) => n !== "_wesp_rowid");
|
||||
|
||||
let thead =
|
||||
"<thead><tr>" +
|
||||
(hasRowid ? "<th>rowid</th>" : "") +
|
||||
displayCols.map((n) => `<th title="${escHtml(colMetaByName[n]?.type || "")}">${escHtml(n)}</th>`).join("") +
|
||||
"<th class=\"wesp-db-grid-actions-col\">Действия</th></tr></thead>";
|
||||
|
||||
const body = rows
|
||||
.map((row) => {
|
||||
const rowKey = {};
|
||||
if (hasRowid && row._wesp_rowid != null) rowKey._wesp_rowid = row._wesp_rowid;
|
||||
(data.pk_names || []).forEach((pk) => {
|
||||
if (pk in row) rowKey[pk] = row[pk];
|
||||
});
|
||||
|
||||
const cells = displayCols
|
||||
.map((name) => {
|
||||
const cm = colMetaByName[name];
|
||||
const val = row[name];
|
||||
if (cm && !cm.editable) {
|
||||
return `<td class="wesp-db-cell-readonly">(BLOB)</td>`;
|
||||
}
|
||||
const v = val == null ? "" : String(val);
|
||||
return `<td><input type="text" class="ant-input wesp-db-cell" data-col="${escHtml(name)}" value="${escHtml(v)}"/></td>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const ridCell = hasRowid
|
||||
? `<td class="wesp-db-cell-readonly">${row._wesp_rowid != null ? escHtml(String(row._wesp_rowid)) : "—"}</td>`
|
||||
: "";
|
||||
|
||||
const rowKeyEnc = encodeURIComponent(JSON.stringify(rowKey));
|
||||
const rowJsonEnc = encodeURIComponent(JSON.stringify(row));
|
||||
return `<tr data-row-key="${rowKeyEnc}" data-row-json="${rowJsonEnc}">
|
||||
${ridCell}${cells}
|
||||
<td class="wesp-db-row-actions">
|
||||
<button type="button" class="ant-btn ant-btn-sm ant-btn-primary wesp-db-row-save"><span>Сохранить</span></button>
|
||||
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger wesp-db-row-delete"><span>Удалить</span></button>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
wrap.innerHTML = `<table class="ant-table wesp-db-grid-table">${thead}<tbody>${body}</tbody></table>`;
|
||||
|
||||
wrap.querySelectorAll(".wesp-db-row-delete").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const tr = btn.closest("tr");
|
||||
if (!tr) return;
|
||||
let rowKey;
|
||||
try {
|
||||
rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D"));
|
||||
} catch (e) {
|
||||
setStatus("Ошибка разбора строки", true);
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const prev = await fetchJson("/api/admin/db/row/delete-preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bind: browseState.bind,
|
||||
table: browseState.table,
|
||||
row_key: rowKey,
|
||||
}),
|
||||
});
|
||||
let msg = `Будет удалено строк: ${prev.total}\n\n${(prev.lines || []).join("\n")}`;
|
||||
if (prev.truncated) msg += "\n\n… (в списке не все строки)";
|
||||
msg += "\n\nУчитываются только связи, объявленные внешними ключами SQLite.\nПродолжить удаление?";
|
||||
if (!window.confirm(msg)) {
|
||||
setStatus("Удаление отменено");
|
||||
return;
|
||||
}
|
||||
await fetchJson("/api/admin/db/row/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bind: browseState.bind,
|
||||
table: browseState.table,
|
||||
row_key: rowKey,
|
||||
}),
|
||||
});
|
||||
setStatus(`Удалено строк: ${prev.total}`);
|
||||
await loadTablePage(setStatus);
|
||||
} catch (e) {
|
||||
setStatus(e.message, true, "Ошибка удаления");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
wrap.querySelectorAll(".wesp-db-row-save").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const tr = btn.closest("tr");
|
||||
if (!tr) return;
|
||||
let rowKey;
|
||||
let original;
|
||||
try {
|
||||
rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D"));
|
||||
original = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-json") || "%7B%7D"));
|
||||
} catch (e) {
|
||||
setStatus("Ошибка разбора строки", true);
|
||||
return;
|
||||
}
|
||||
const changes = {};
|
||||
tr.querySelectorAll(".wesp-db-cell[data-col]").forEach((inp) => {
|
||||
const col = inp.getAttribute("data-col");
|
||||
if (!col || inp.readOnly) return;
|
||||
const oldVal = Object.prototype.hasOwnProperty.call(original, col) ? original[col] : undefined;
|
||||
const newVal = parseInputValue(inp.value, oldVal);
|
||||
const same =
|
||||
(oldVal == null && (newVal === "" || newVal === null)) ||
|
||||
JSON.stringify(oldVal) === JSON.stringify(newVal === "" && oldVal == null ? null : newVal);
|
||||
if (!same) {
|
||||
changes[col] = newVal === "" && oldVal == null ? null : newVal;
|
||||
}
|
||||
});
|
||||
if (!Object.keys(changes).length) {
|
||||
setStatus("Нет изменений в строке");
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await fetchJson("/api/admin/db/row", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bind: browseState.bind,
|
||||
table: browseState.table,
|
||||
row_key: rowKey,
|
||||
changes,
|
||||
}),
|
||||
});
|
||||
setStatus("Строка сохранена на сервере");
|
||||
await loadTablePage(setStatus);
|
||||
} catch (e) {
|
||||
setStatus(e.message, true, "Ошибка сохранения");
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const prev = browseState.offset > 0;
|
||||
const next = browseState.offset + rows.length < total;
|
||||
pager.innerHTML = `
|
||||
<button type="button" class="ant-btn ant-btn-sm" id="adminDbPrev" ${prev ? "" : "disabled"}><span>Назад</span></button>
|
||||
<button type="button" class="ant-btn ant-btn-sm" id="adminDbNext" ${next ? "" : "disabled"}><span>Вперёд</span></button>
|
||||
`;
|
||||
document.getElementById("adminDbPrev")?.addEventListener("click", async () => {
|
||||
browseState.offset = Math.max(0, browseState.offset - browseState.limit);
|
||||
await loadTablePage(setStatus);
|
||||
});
|
||||
document.getElementById("adminDbNext")?.addEventListener("click", async () => {
|
||||
browseState.offset += browseState.limit;
|
||||
await loadTablePage(setStatus);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTablePage(setStatus) {
|
||||
const errEl = document.getElementById("adminSqliteError");
|
||||
if (errEl) errEl.textContent = "";
|
||||
if (!browseState.table) return;
|
||||
const u = `/api/admin/db/table?bind=${encodeURIComponent(browseState.bind)}&table=${encodeURIComponent(browseState.table)}&limit=${browseState.limit}&offset=${browseState.offset}`;
|
||||
const data = await fetchJson(u);
|
||||
renderGrid(data, setStatus);
|
||||
}
|
||||
|
||||
export async function refreshAdminDbTree(setStatus) {
|
||||
const modal = document.getElementById("adminSqliteModal");
|
||||
const errEl = document.getElementById("adminSqliteError");
|
||||
if (!modal || modal.hidden) return;
|
||||
try {
|
||||
const tree = await fetchJson("/api/admin/db/tree");
|
||||
const pref = browseState.bind || "recipes";
|
||||
const cb = dbTreePickCallback;
|
||||
if (typeof cb !== "function") {
|
||||
if (typeof setStatus === "function") setStatus("Подождите, дерево ещё загружается");
|
||||
return;
|
||||
}
|
||||
renderTree(tree.binds || [], pref, cb);
|
||||
document.querySelectorAll(".wesp-db-tree-table").forEach((btn) => {
|
||||
if (
|
||||
btn.getAttribute("data-bind") === browseState.bind &&
|
||||
btn.getAttribute("data-table") === browseState.table
|
||||
) {
|
||||
document.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active"));
|
||||
btn.classList.add("is-active");
|
||||
}
|
||||
});
|
||||
if (typeof setStatus === "function") setStatus("Каталог таблиц обновлён");
|
||||
} catch (e) {
|
||||
if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
|
||||
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAdminDbGrid(setStatus) {
|
||||
const errEl = document.getElementById("adminSqliteError");
|
||||
if (!browseState.table) {
|
||||
if (typeof setStatus === "function") setStatus("Сначала выберите таблицу в дереве");
|
||||
return;
|
||||
}
|
||||
if (errEl) errEl.textContent = "";
|
||||
try {
|
||||
await loadTablePage(setStatus);
|
||||
if (typeof setStatus === "function") setStatus("Таблица обновлена с сервера");
|
||||
} catch (e) {
|
||||
if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
|
||||
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
|
||||
}
|
||||
}
|
||||
|
||||
export async function openAdminDbBrowser(options = {}) {
|
||||
const { preferredBind = "recipes", setStatus } = options;
|
||||
const modal = document.getElementById("adminSqliteModal");
|
||||
const errEl = document.getElementById("adminSqliteError");
|
||||
if (!modal || !errEl) {
|
||||
if (typeof setStatus === "function") setStatus("Нет разметки редактора БД", true);
|
||||
return;
|
||||
}
|
||||
|
||||
browseState = {
|
||||
bind: preferredBind,
|
||||
table: null,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
page: null,
|
||||
};
|
||||
|
||||
modal.hidden = false;
|
||||
document.body.style.overflow = "hidden";
|
||||
errEl.textContent = "";
|
||||
document.getElementById("adminDbGridMeta").textContent = "";
|
||||
document.getElementById("adminDbGridWrap").innerHTML =
|
||||
'<p class="wesp-db-browser__muted">Выберите таблицу в дереве слева.</p>';
|
||||
document.getElementById("adminDbPager").innerHTML = "";
|
||||
|
||||
try {
|
||||
const tree = await fetchJson("/api/admin/db/tree");
|
||||
renderTree(tree.binds || [], preferredBind, async (bind, table) => {
|
||||
browseState.bind = bind;
|
||||
browseState.table = table;
|
||||
browseState.offset = 0;
|
||||
errEl.textContent = "";
|
||||
try {
|
||||
await loadTablePage(setStatus);
|
||||
} catch (e) {
|
||||
errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
|
||||
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
|
||||
}
|
||||
});
|
||||
if (typeof setStatus === "function") setStatus("Дерево БД загружено");
|
||||
} catch (e) {
|
||||
errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию";
|
||||
if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию");
|
||||
}
|
||||
}
|
||||
|
||||
export function wireAdminDbBrowserModal() {
|
||||
document.getElementById("adminSqliteModalClose")?.addEventListener("click", closeModal);
|
||||
document.getElementById("adminSqliteModalMask")?.addEventListener("click", closeModal);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Единый словарь подписей analytics для зоотехника.
|
||||
*/
|
||||
(function (global) {
|
||||
global.WespAnalyticsCopy = {
|
||||
tabs: {
|
||||
summary: "Итоги",
|
||||
comparison: "Сравнение",
|
||||
journal: "Подробно",
|
||||
reports: "Отчёты",
|
||||
alerts: "Отклонения",
|
||||
},
|
||||
summary: {
|
||||
overloadTitle: "Перерасход",
|
||||
overloadHint: "Насыпали больше, чем в плане",
|
||||
overloadHintZero: "Перерасхода нет — загрузка в пределах плана",
|
||||
underloadTitle: "Недогруз",
|
||||
underloadHint: "Риск для надоя: коровы могли недополучить корм",
|
||||
underloadHintZero: "Недогруза нет — план выполнен",
|
||||
netTitle: "Итого по деньгам",
|
||||
netHintOverload: "Преобладает перерасход — прямой убыток бюджета",
|
||||
netHintUnderload: "Преобладает недогруз — экономия сейчас, риск падения надоев",
|
||||
netHintBalanced: "Перерасход и недогруз за период уравновешены",
|
||||
topTitle: "Где больше всего потеряли",
|
||||
allAlertsLink: "Все отклонения →",
|
||||
openStockLink: "Открыть склад →",
|
||||
empty: "За выбранный период отчётов нет",
|
||||
exportExcel: "Excel",
|
||||
},
|
||||
comparison: {
|
||||
sectionLoading: "Загрузка",
|
||||
sectionUnloading: "Выгрузка",
|
||||
legendBase: "По рецепту",
|
||||
legendPlan: "На сегодня",
|
||||
legendActual: "Сделали",
|
||||
noteExecution: "Ошибка при загрузке",
|
||||
noteUnloadingExecution: "Ошибка при выгрузке",
|
||||
mixerRemainderLabel: "Остаток в миксере",
|
||||
noteExcluded: "Исключено из плана",
|
||||
noteAdjusted: "План скорректирован",
|
||||
empty: "За этот период рейсов с отчётами нет",
|
||||
},
|
||||
stock: {
|
||||
bannerTitle: "Обратите внимание:",
|
||||
daysRecipe: "Хватит по рецепту",
|
||||
daysAdjusted: "Хватит (с учётом плана)",
|
||||
},
|
||||
formatRub(value) {
|
||||
const n = Number(value) || 0;
|
||||
return `${Math.round(n).toLocaleString("ru-RU")} ₽`;
|
||||
},
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Вкладка «Сравнение» на /reports.
|
||||
*/
|
||||
(function (global) {
|
||||
const COPY = () => global.WespAnalyticsCopy || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function getDateParams() {
|
||||
const from = document.getElementById("date-from")?.value || "";
|
||||
const to = document.getElementById("date-to")?.value || "";
|
||||
return { date_from: from, date_to: to };
|
||||
}
|
||||
|
||||
function buildUrl() {
|
||||
const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(getDateParams());
|
||||
return `/api/analytics/plan-fact?${params.toString()}`;
|
||||
}
|
||||
|
||||
function barWidth(value, max) {
|
||||
if (!max || max <= 0) return 0;
|
||||
return Math.min(100, (Number(value) / max) * 100);
|
||||
}
|
||||
|
||||
function faultBadge(fault, executionLabel) {
|
||||
const c = COPY().comparison || {};
|
||||
if (fault === "execution") {
|
||||
return `<span class="analytics-pf-fault analytics-pf-fault--execution">${escapeHtml(
|
||||
executionLabel || c.noteExecution || ""
|
||||
)}</span>`;
|
||||
}
|
||||
if (fault === "excluded" || fault === "zootech_ok") {
|
||||
return `<span class="analytics-pf-fault analytics-pf-fault--excluded">${escapeHtml(c.noteExcluded || "")}</span>`;
|
||||
}
|
||||
if (fault === "adjusted" || fault === "zootech") {
|
||||
return `<span class="analytics-pf-fault analytics-pf-fault--adjusted">${escapeHtml(c.noteAdjusted || "")}</span>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function legendHtml() {
|
||||
const c = COPY().comparison || {};
|
||||
return (
|
||||
`<div class="analytics-pf-legend">` +
|
||||
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--base"></i>${escapeHtml(c.legendBase)}</span>` +
|
||||
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--plan"></i>${escapeHtml(c.legendPlan)}</span>` +
|
||||
`<span><i class="analytics-pf-legend__dot analytics-pf-legend__dot--actual"></i>${escapeHtml(c.legendActual)}</span>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderMetricRows(rows, maxKg, options) {
|
||||
const executionLabel = options?.executionLabel;
|
||||
return rows
|
||||
.map((c) => {
|
||||
const notes = (c.notes || [])
|
||||
.map((n) => {
|
||||
const note = typeof n === "string" ? { text: n, kind: "execution" } : n;
|
||||
const kind = note.kind === "zootech" ? "zootech" : "execution";
|
||||
const cls =
|
||||
kind === "zootech"
|
||||
? "analytics-pf-note analytics-pf-note--zootech"
|
||||
: "analytics-pf-note analytics-pf-note--execution";
|
||||
return `<li class="${cls}">${escapeHtml(note.text)}</li>`;
|
||||
})
|
||||
.join("");
|
||||
const faultBadgeHtml = faultBadge(c.fault, executionLabel);
|
||||
const compCls =
|
||||
c.skippedToday && (c.fault === "excluded" || c.fault === "zootech_ok")
|
||||
? "analytics-pf-comp analytics-pf-comp--excluded"
|
||||
: options?.rowClass || "analytics-pf-comp";
|
||||
return (
|
||||
`<div class="${compCls}">` +
|
||||
`<div class="analytics-pf-comp__head">` +
|
||||
`<div class="analytics-pf-comp__name">${escapeHtml(c.name)}</div>` +
|
||||
faultBadgeHtml +
|
||||
`</div>` +
|
||||
`<div class="analytics-pf-bars">` +
|
||||
`<div class="analytics-pf-bar analytics-pf-bar--base" title="${escapeHtml(COPY().comparison?.legendBase)}"><span style="width:${barWidth(c.baseKg, maxKg)}%"></span><em>${Number(c.baseKg).toFixed(1)}</em></div>` +
|
||||
`<div class="analytics-pf-bar analytics-pf-bar--plan" title="${escapeHtml(COPY().comparison?.legendPlan)}"><span style="width:${barWidth(c.planTodayKg, maxKg)}%"></span><em>${Number(c.planTodayKg).toFixed(1)}</em></div>` +
|
||||
`<div class="analytics-pf-bar analytics-pf-bar--actual" title="${escapeHtml(COPY().comparison?.legendActual)}"><span style="width:${barWidth(c.actualKg, maxKg)}%"></span><em>${Number(c.actualKg).toFixed(1)}</em></div>` +
|
||||
`</div>` +
|
||||
(notes ? `<ul class="analytics-pf-notes">${notes}</ul>` : "") +
|
||||
`</div>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function sectionHtml(title, body) {
|
||||
if (!body) return "";
|
||||
return (
|
||||
`<section class="analytics-pf-section">` +
|
||||
`<h3 class="analytics-pf-section__title">${escapeHtml(title)}</h3>` +
|
||||
body +
|
||||
`</section>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderItem(item) {
|
||||
const comps = item.components || [];
|
||||
const groups = item.unloadingGroups || [];
|
||||
const mixer = item.mixerRemainder || null;
|
||||
const compMax = Math.max(...comps.flatMap((c) => [c.baseKg, c.planTodayKg, c.actualKg]), 1);
|
||||
const unloadMax = Math.max(...groups.flatMap((c) => [c.baseKg, c.planTodayKg, c.actualKg]), 1);
|
||||
|
||||
const compHtml = renderMetricRows(comps, compMax, {
|
||||
executionLabel: COPY().comparison?.noteExecution,
|
||||
});
|
||||
const unloadHtml = renderMetricRows(groups, unloadMax, {
|
||||
executionLabel: COPY().comparison?.noteUnloadingExecution,
|
||||
});
|
||||
const mixerHtml = mixer
|
||||
? (() => {
|
||||
const kg = Number(mixer.actualKg);
|
||||
const sign = kg > 0 ? "+" : "";
|
||||
const cls =
|
||||
kg < 0
|
||||
? "analytics-pf-mixer-line analytics-pf-mixer-line--negative"
|
||||
: "analytics-pf-mixer-line";
|
||||
return (
|
||||
`<p class="${cls}">${escapeHtml(
|
||||
COPY().comparison?.mixerRemainderLabel || "Остаток в миксере"
|
||||
)}: <strong>${sign}${kg.toFixed(1)}</strong> кг</p>`
|
||||
);
|
||||
})()
|
||||
: "";
|
||||
|
||||
const when = item.startTime ? escapeHtml(item.startTime.slice(0, 16).replace("T", " ")) : "";
|
||||
const unloadingSection =
|
||||
groups.length || mixer
|
||||
? sectionHtml(
|
||||
COPY().comparison?.sectionUnloading || "Выгрузка",
|
||||
legendHtml() + unloadHtml + mixerHtml
|
||||
)
|
||||
: "";
|
||||
|
||||
return (
|
||||
`<article class="analytics-pf-card" data-loading-report-id="${escapeHtml(item.loadingReportId)}">` +
|
||||
`<header class="analytics-pf-card__head">` +
|
||||
`<strong>${escapeHtml(item.recipeName)}</strong>` +
|
||||
`<span class="text-muted small">${when}</span>` +
|
||||
`</header>` +
|
||||
sectionHtml(
|
||||
COPY().comparison?.sectionLoading || "Загрузка",
|
||||
legendHtml() + compHtml
|
||||
) +
|
||||
unloadingSection +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const el = document.getElementById("analyticsPlanFactList");
|
||||
if (!el) return;
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
el.innerHTML = `<div class="empty-state zt-empty wesp-content-reveal"><p class="zt-empty__hint">${escapeHtml(COPY().comparison?.empty || "")}</p></div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<div class="analytics-pf-list">${items.map(renderItem).join("")}</div>`;
|
||||
el.querySelectorAll("[data-loading-report-id]").forEach((card) => {
|
||||
card.addEventListener("click", () => {
|
||||
const id = card.getAttribute("data-loading-report-id");
|
||||
if (id) global.WespFeedAlerts?.openReportFromAlert?.(id);
|
||||
});
|
||||
});
|
||||
global.WespZootechModals?.revealContent(el.querySelector(".analytics-pf-list"));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const el = document.getElementById("analyticsPlanFactList");
|
||||
if (!el) return;
|
||||
el.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(buildUrl());
|
||||
if (!resp.ok) throw new Error("Не удалось загрузить сравнение");
|
||||
render(await resp.json());
|
||||
} catch (err) {
|
||||
el.innerHTML = `<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function onFiltersApplied() {
|
||||
if (global.WespFeedAlerts?.getCurrentView?.() === "comparison") load();
|
||||
}
|
||||
|
||||
global.WespAnalyticsPlanFact = { load, onFiltersApplied };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Вкладка «Итоги» на /reports.
|
||||
*/
|
||||
(function (global) {
|
||||
const COPY = () => global.WespAnalyticsCopy || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function getDateParams() {
|
||||
const from = document.getElementById("date-from")?.value || "";
|
||||
const to = document.getElementById("date-to")?.value || "";
|
||||
return { date_from: from, date_to: to };
|
||||
}
|
||||
|
||||
function buildUrl() {
|
||||
const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(getDateParams());
|
||||
return `/api/analytics/finance?${params.toString()}`;
|
||||
}
|
||||
|
||||
function valueToneClass(kind) {
|
||||
if (kind === "loss") return "analytics-kpi-card__value--loss";
|
||||
if (kind === "risk") return "analytics-kpi-card__value--risk";
|
||||
return "";
|
||||
}
|
||||
|
||||
function netHint(data) {
|
||||
const c = COPY().summary || {};
|
||||
if (data.dominantIssue === "overload") return c.netHintOverload || "";
|
||||
if (data.dominantIssue === "underload") return c.netHintUnderload || "";
|
||||
return c.netHintBalanced || "";
|
||||
}
|
||||
|
||||
function overloadHint(data) {
|
||||
const c = COPY().summary || {};
|
||||
if (Number(data.overloadRub) > 0) return c.overloadHint || "";
|
||||
return c.overloadHintZero || c.overloadHint || "";
|
||||
}
|
||||
|
||||
function underloadHint(data) {
|
||||
const c = COPY().summary || {};
|
||||
if (Number(data.underloadRub) > 0) return c.underloadHint || "";
|
||||
return c.underloadHintZero || c.underloadHint || "";
|
||||
}
|
||||
|
||||
function netTone(data) {
|
||||
if (data.dominantIssue === "overload") return "loss";
|
||||
if (data.dominantIssue === "underload") return "risk";
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderTopBar(data) {
|
||||
const top = data.topComponents || [];
|
||||
if (!top.length) {
|
||||
return `<p class="text-muted small mb-0">${escapeHtml(COPY().summary?.empty || "")}</p>`;
|
||||
}
|
||||
const maxVal = Math.max(
|
||||
...top.map((row) => Math.max(row.overloadRub, row.underloadRub)),
|
||||
1
|
||||
);
|
||||
const rows = top
|
||||
.map((row) => {
|
||||
const isOverload = row.overloadRub > row.underloadRub;
|
||||
const main = isOverload
|
||||
? `${COPY().formatRub(row.overloadRub)} перерасход`
|
||||
: `${COPY().formatRub(row.underloadRub)} недогруз`;
|
||||
const tone = isOverload ? "analytics-top-row__value--loss" : "analytics-top-row__value--risk";
|
||||
const widthPct = Math.round(
|
||||
Math.min(100, (Math.max(row.overloadRub, row.underloadRub) / maxVal) * 1000)
|
||||
) / 10;
|
||||
return (
|
||||
`<div class="analytics-top-row">` +
|
||||
`<span class="analytics-top-row__name">${escapeHtml(row.name)}</span>` +
|
||||
`<span class="analytics-top-row__bar analytics-top-row__bar--${isOverload ? "loss" : "risk"}">` +
|
||||
`<span style="width:${widthPct}%"></span></span>` +
|
||||
`<span class="analytics-top-row__value ${tone}">${escapeHtml(main)}</span>` +
|
||||
`</div>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return `<div class="analytics-top-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const el = document.getElementById("analyticsSummaryList");
|
||||
if (!el) return;
|
||||
const c = COPY().summary || {};
|
||||
const overloadTone = Number(data.overloadRub) > 0 ? "loss" : "";
|
||||
const underloadTone = Number(data.underloadRub) > 0 ? "risk" : "";
|
||||
const netT = netTone(data);
|
||||
|
||||
el.innerHTML =
|
||||
`<div class="analytics-kpi-grid wesp-content-reveal">` +
|
||||
`<div class="analytics-kpi-card">` +
|
||||
`<div class="analytics-kpi-card__title">${escapeHtml(c.overloadTitle)}</div>` +
|
||||
`<div class="analytics-kpi-card__value ${valueToneClass(overloadTone)}">${escapeHtml(COPY().formatRub(data.overloadRub))}</div>` +
|
||||
`<div class="analytics-kpi-card__hint">${escapeHtml(overloadHint(data))}</div>` +
|
||||
`</div>` +
|
||||
`<div class="analytics-kpi-card">` +
|
||||
`<div class="analytics-kpi-card__title">${escapeHtml(c.underloadTitle)}</div>` +
|
||||
`<div class="analytics-kpi-card__value ${valueToneClass(underloadTone)}">${escapeHtml(COPY().formatRub(data.underloadRub))}</div>` +
|
||||
`<div class="analytics-kpi-card__hint">${escapeHtml(underloadHint(data))}</div>` +
|
||||
`</div>` +
|
||||
`<div class="analytics-kpi-card">` +
|
||||
`<div class="analytics-kpi-card__title">${escapeHtml(c.netTitle)}</div>` +
|
||||
`<div class="analytics-kpi-card__value ${valueToneClass(netT)}">${escapeHtml(COPY().formatRub(Math.abs(data.netRub)))}</div>` +
|
||||
`<div class="analytics-kpi-card__hint">${escapeHtml(netHint(data))}</div>` +
|
||||
`</div>` +
|
||||
`</div>` +
|
||||
`<div class="analytics-top-block zt-section wesp-content-reveal">` +
|
||||
`<h3 class="analytics-section-title">${escapeHtml(c.topTitle)}</h3>` +
|
||||
renderTopBar(data) +
|
||||
`</div>` +
|
||||
`<div class="analytics-links wesp-content-reveal">` +
|
||||
`<button type="button" class="btn btn-link btn-sm p-0" data-analytics-goto-alerts>${escapeHtml(c.allAlertsLink)}</button>` +
|
||||
`</div>`;
|
||||
el.querySelector("[data-analytics-goto-alerts]")?.addEventListener("click", () => {
|
||||
global.WespFeedAlerts?.openJournalAlerts?.();
|
||||
});
|
||||
global.WespZootechModals?.revealContent(el);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const el = document.getElementById("analyticsSummaryList");
|
||||
if (!el) return;
|
||||
el.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(buildUrl());
|
||||
if (!resp.ok) throw new Error("Не удалось загрузить итоги");
|
||||
render(await resp.json());
|
||||
} catch (err) {
|
||||
el.innerHTML = `<div class="empty-state zt-empty"><p class="zt-empty__hint">${escapeHtml(err.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function onFiltersApplied() {
|
||||
if (global.WespFeedAlerts?.getCurrentView?.() === "summary") load();
|
||||
}
|
||||
|
||||
function init() {
|
||||
/* экспорт — WespReportsExport */
|
||||
}
|
||||
|
||||
global.WespAnalyticsSummary = { init, load, onFiltersApplied };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* Коррекция СВ% компонентов в плане — overlay в стиле K-hub.
|
||||
* Редактируется только СВ%; превью через /api/recipes/calculate.
|
||||
*/
|
||||
(function (global) {
|
||||
function todayIso() {
|
||||
return global.WespDailyPlanSkipDuration?.todayIso?.() || new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return (
|
||||
global.WespDailyPlanSkipDuration?.escapeHtml?.(value) ||
|
||||
String(value ?? "").replace(/&/g, "&").replace(/</g, "<")
|
||||
);
|
||||
}
|
||||
|
||||
function fmtNum(value, digits) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
return n.toFixed(digits).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function roundWphNum(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return NaN;
|
||||
if (n > 0 && n < 0.01) return 0.01;
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function fmtRange(values, digits) {
|
||||
const nums = values.filter((n) => Number.isFinite(n));
|
||||
if (!nums.length) return "—";
|
||||
const min = Math.min(...nums);
|
||||
const max = Math.max(...nums);
|
||||
const a = fmtNum(min, digits);
|
||||
const b = fmtNum(max, digits);
|
||||
return a === b ? a : `${a}–${b}`;
|
||||
}
|
||||
|
||||
function usageEntries(item) {
|
||||
const usages = Array.isArray(item.usages) ? item.usages : [];
|
||||
if (usages.length) {
|
||||
return usages.map((u) => ({
|
||||
wph: Number(u.masterWeightPerHead),
|
||||
dmPh: Number(u.masterDryMatterPerHead),
|
||||
dryMatterLocked: !!u.dryMatterLocked,
|
||||
}));
|
||||
}
|
||||
return [
|
||||
{
|
||||
wph: Number(item.masterWeightPerHead),
|
||||
dmPh: Number(item.masterDryMatterPerHead),
|
||||
dryMatterLocked: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const PHYS_WEIGHT_HINT = "Этот компонент выдается строго по физическому весу";
|
||||
|
||||
function itemIsDmEditable(item) {
|
||||
return itemHasLockedUsage(item);
|
||||
}
|
||||
|
||||
function componentCellHtml(item) {
|
||||
const editable = itemIsDmEditable(item);
|
||||
const lockedCount = lockedUsageEntries(item).length;
|
||||
const trips = Number(item.usageCount);
|
||||
let meta = "";
|
||||
if (editable && lockedCount > 0) {
|
||||
meta = `<div class="zt-k-hub-plan-norms__meta">${lockedCount} рец. с замком СВ</div>`;
|
||||
} else if (!editable) {
|
||||
meta = `<div class="zt-k-hub-plan-norms__meta zt-k-hub-plan-norms__meta--weight">${escapeHtml(PHYS_WEIGHT_HINT)}</div>`;
|
||||
} else if (Number.isFinite(trips) && trips > 1) {
|
||||
meta = `<div class="zt-k-hub-plan-norms__meta">${trips} рейсов в плане</div>`;
|
||||
}
|
||||
const lockIcon = editable
|
||||
? ""
|
||||
: `<i class="fas fa-lock zt-k-hub-plan-norms__weight-lock" title="${escapeHtml(PHYS_WEIGHT_HINT)}" aria-hidden="true"></i>`;
|
||||
return (
|
||||
`<div class="zt-k-hub-plan-norms__component">` +
|
||||
`<div class="zt-k-hub-plan-norms__name-row">` +
|
||||
lockIcon +
|
||||
`<div class="zt-k-hub-plan-norms__name">${escapeHtml(item.componentName)}</div>` +
|
||||
`</div>` +
|
||||
meta +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function flashWeightLockedHint(input) {
|
||||
const cell = input?.closest(".zt-k-hub-plan-norms__dm-cell");
|
||||
if (!cell) return;
|
||||
let hint = cell.querySelector("[data-norms-weight-hint]");
|
||||
if (!hint) {
|
||||
hint = document.createElement("span");
|
||||
hint.className = "zt-k-hub-plan-norms__weight-hint";
|
||||
hint.dataset.normsWeightHint = "1";
|
||||
hint.textContent = PHYS_WEIGHT_HINT;
|
||||
cell.appendChild(hint);
|
||||
}
|
||||
hint.classList.add("is-visible");
|
||||
clearTimeout(hint._hideTimer);
|
||||
hint._hideTimer = setTimeout(() => hint.classList.remove("is-visible"), 2800);
|
||||
}
|
||||
|
||||
function notify(msg, type) {
|
||||
const n = global.WespZootechNotify?.createAdapter?.();
|
||||
if (!n) return;
|
||||
if (type === "error") n.error(msg, { skipRecord: true });
|
||||
else n.success(msg, { skipRecord: true });
|
||||
}
|
||||
|
||||
function overlayHost() {
|
||||
return document.getElementById("zootechNotificationCenterModal") || document.body;
|
||||
}
|
||||
|
||||
let overlayEl = null;
|
||||
let items = [];
|
||||
const rowPreviewTimers = new Map();
|
||||
let recalcSeq = 0;
|
||||
let lockedOnlyFilter = true;
|
||||
|
||||
function itemHasLockedUsage(item) {
|
||||
return usageEntries(item).some((entry) => entry.dryMatterLocked);
|
||||
}
|
||||
|
||||
function lockedUsageEntries(item) {
|
||||
return usageEntries(item).filter((entry) => entry.dryMatterLocked);
|
||||
}
|
||||
|
||||
function clearRowPreviewTimers() {
|
||||
rowPreviewTimers.forEach((timer) => clearTimeout(timer));
|
||||
rowPreviewTimers.clear();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
clearRowPreviewTimers();
|
||||
recalcSeq += 1;
|
||||
if (overlayEl?.isConnected) overlayEl.remove();
|
||||
overlayEl = null;
|
||||
}
|
||||
|
||||
function actionBtn(action, attrs, title, icon) {
|
||||
return (
|
||||
`<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-row__skip" ` +
|
||||
`data-${action}="1" ${attrs} title="${escapeHtml(title)}">` +
|
||||
`<i class="fas ${icon}" aria-hidden="true"></i></button>`
|
||||
);
|
||||
}
|
||||
|
||||
function masterPreviewForItem(item) {
|
||||
const wphs = [];
|
||||
const dmPhs = [];
|
||||
for (const entry of usageEntries(item)) {
|
||||
if (Number.isFinite(entry.wph)) wphs.push(roundWphNum(entry.wph));
|
||||
if (Number.isFinite(entry.dmPh)) dmPhs.push(entry.dmPh);
|
||||
}
|
||||
return {
|
||||
dmPh: fmtRange(dmPhs, 4),
|
||||
wph: fmtRange(wphs, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function previewCompareHtml(before, after) {
|
||||
const was = before || "—";
|
||||
const now = after || "—";
|
||||
if (was === "—" && now === "—") return "—";
|
||||
if (was === now) {
|
||||
return `<span class="zt-k-hub-plan-norms-preview__now">${escapeHtml(now)}</span>`;
|
||||
}
|
||||
return (
|
||||
`<span class="zt-k-hub-plan-norms-preview zt-k-hub-plan-norms-preview--compare">` +
|
||||
`<span class="zt-k-hub-plan-norms-preview__was">${escapeHtml(was)}</span>` +
|
||||
`<span class="zt-k-hub-plan-norms-preview__now">${escapeHtml(now)}</span>` +
|
||||
`</span>`
|
||||
);
|
||||
}
|
||||
|
||||
async function previewForItem(item, planPct) {
|
||||
const pct = Number(planPct);
|
||||
if (!Number.isFinite(pct) || pct < 0) return { dmPh: "—", wph: "—" };
|
||||
|
||||
const lockedEntries = lockedUsageEntries(item);
|
||||
const wphs = [];
|
||||
const dmPhs = [];
|
||||
|
||||
await Promise.all(
|
||||
lockedEntries.map(async (entry) => {
|
||||
if (!Number.isFinite(entry.dmPh)) return;
|
||||
try {
|
||||
const response = await fetch("/api/recipes/calculate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
ingredients: [
|
||||
{
|
||||
component_id: item.componentId,
|
||||
dryMatter: pct,
|
||||
dryMatterPerHead: entry.dmPh,
|
||||
},
|
||||
],
|
||||
headsCount: 1,
|
||||
tripPercent: 100,
|
||||
calculateFromDryMatter: true,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return;
|
||||
const result = await response.json();
|
||||
const calc = result.ingredients?.[0];
|
||||
if (!calc) return;
|
||||
dmPhs.push(Number(entry.dmPh));
|
||||
const rawWph = calc.weightPerHead !== undefined ? calc.weightPerHead : calc.weight_per_head;
|
||||
if (rawWph != null) wphs.push(roundWphNum(rawWph));
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
for (const entry of usageEntries(item)) {
|
||||
if (entry.dryMatterLocked) continue;
|
||||
if (!Number.isFinite(entry.wph)) continue;
|
||||
wphs.push(roundWphNum(entry.wph));
|
||||
if (pct >= 0) dmPhs.push(entry.wph * (pct / 100));
|
||||
}
|
||||
|
||||
return {
|
||||
dmPh: fmtRange(dmPhs, 4),
|
||||
wph: fmtRange(wphs, 2),
|
||||
};
|
||||
}
|
||||
|
||||
function planEditValue(item) {
|
||||
if (item.planDryMatterPct != null) return item.planDryMatterPct;
|
||||
return item.masterDryMatterPct ?? item.dryMatterPct;
|
||||
}
|
||||
|
||||
function masterBaseDryMatterLabel(item) {
|
||||
const catalog = Number(item.dryMatterPct);
|
||||
if (Number.isFinite(catalog)) {
|
||||
return `${fmtNum(catalog, 2)}%`;
|
||||
}
|
||||
const min = Number(item.masterDryMatterPctMin);
|
||||
const max = Number(item.masterDryMatterPctMax);
|
||||
if (Number.isFinite(min) && Number.isFinite(max)) {
|
||||
const a = fmtNum(min, 2);
|
||||
const b = fmtNum(max, 2);
|
||||
return a === b ? `${a}%` : `${a}–${b}%`;
|
||||
}
|
||||
const master = Number(item.masterDryMatterPct);
|
||||
if (Number.isFinite(master)) return `${fmtNum(master, 2)}%`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function baseDryMatterHintHtml(item) {
|
||||
const label = masterBaseDryMatterLabel(item);
|
||||
if (!label) return "";
|
||||
return (
|
||||
`<span class="zt-k-hub-plan-norms__base" data-norms-base title="СВ% в справочнике компонентов">` +
|
||||
`База: ${escapeHtml(label)}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
function setRowPreviewLoading(row) {
|
||||
const dmPhEl = row.querySelector("[data-norms-preview-dmph]");
|
||||
const wphEl = row.querySelector("[data-norms-preview-wph]");
|
||||
if (dmPhEl) {
|
||||
dmPhEl.textContent = "…";
|
||||
dmPhEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
|
||||
}
|
||||
if (wphEl) {
|
||||
wphEl.textContent = "…";
|
||||
wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
|
||||
}
|
||||
}
|
||||
|
||||
function setPreviewCell(el, before, after) {
|
||||
if (!el) return;
|
||||
const was = before || "—";
|
||||
const now = after || "—";
|
||||
if (was === now) {
|
||||
el.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
|
||||
el.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(now)}</span>`;
|
||||
return;
|
||||
}
|
||||
el.classList.add("zt-k-hub-plan-norms-preview-cell--compare");
|
||||
el.innerHTML = previewCompareHtml(was, now);
|
||||
}
|
||||
|
||||
async function updateRowPreview(row, seq) {
|
||||
const componentId = row.dataset.componentId;
|
||||
const item = items.find((x) => String(x.componentId) === String(componentId));
|
||||
if (!item) return;
|
||||
const input = row.querySelector("[data-norms-input]");
|
||||
const dmPhEl = row.querySelector("[data-norms-preview-dmph]");
|
||||
const wphEl = row.querySelector("[data-norms-preview-wph]");
|
||||
const masterPrev = masterPreviewForItem(item);
|
||||
if (row.dataset.normsEditable !== "1") {
|
||||
if (dmPhEl) {
|
||||
dmPhEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
|
||||
dmPhEl.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(masterPrev.dmPh)}</span>`;
|
||||
}
|
||||
if (wphEl) {
|
||||
wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare");
|
||||
wphEl.innerHTML = `<span class="zt-k-hub-plan-norms-preview">${escapeHtml(masterPrev.wph)}</span>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const raw = input?.value;
|
||||
if (raw === "" || raw == null) {
|
||||
setPreviewCell(dmPhEl, null, null);
|
||||
setPreviewCell(wphEl, null, null);
|
||||
return;
|
||||
}
|
||||
setRowPreviewLoading(row);
|
||||
const next = await previewForItem(item, raw);
|
||||
if (seq !== recalcSeq || !overlayEl?.contains(row)) return;
|
||||
setPreviewCell(dmPhEl, masterPrev.dmPh, next.dmPh);
|
||||
setPreviewCell(wphEl, masterPrev.wph, next.wph);
|
||||
}
|
||||
|
||||
function scheduleRowPreview(row, debounceMs) {
|
||||
if (!row) return;
|
||||
const componentId = row.dataset.componentId;
|
||||
if (!componentId) return;
|
||||
const existing = rowPreviewTimers.get(componentId);
|
||||
if (existing) clearTimeout(existing);
|
||||
const seq = recalcSeq;
|
||||
const delay = debounceMs == null ? 300 : debounceMs;
|
||||
if (delay <= 0) {
|
||||
rowPreviewTimers.delete(componentId);
|
||||
updateRowPreview(row, seq).catch((err) => console.error("Ошибка при расчете:", err));
|
||||
return;
|
||||
}
|
||||
rowPreviewTimers.set(
|
||||
componentId,
|
||||
setTimeout(() => {
|
||||
rowPreviewTimers.delete(componentId);
|
||||
updateRowPreview(row, seq).catch((err) => console.error("Ошибка при расчете:", err));
|
||||
}, delay)
|
||||
);
|
||||
}
|
||||
|
||||
function updateAllPreviews() {
|
||||
overlayEl?.querySelectorAll("[data-norms-row]").forEach((row) => scheduleRowPreview(row, 0));
|
||||
}
|
||||
|
||||
function buildRowHtml(item) {
|
||||
const editable = itemIsDmEditable(item);
|
||||
const val = planEditValue(item);
|
||||
const inputVal = val != null && Number.isFinite(Number(val)) ? Number(val) : "";
|
||||
const undo = item.adjustedToday
|
||||
? actionBtn("norms-undo", "", "Вернуть как в рецепте", "fa-undo")
|
||||
: "";
|
||||
const replaceBtn = actionBtn(
|
||||
"norms-replace",
|
||||
"",
|
||||
"Заменить компонент во всех рецептах",
|
||||
"fa-exchange-alt"
|
||||
);
|
||||
const applyBtn = editable ? actionBtn("norms-apply", "", "Сохранить для плана", "fa-check") : "";
|
||||
const rowMods = [
|
||||
item.adjustedToday ? "ingredient-row--adjusted" : "",
|
||||
editable ? "" : "ingredient-row--weight-locked",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const inputClass = editable
|
||||
? "form-control zt-k-hub-plan-norms-input dry-matter-percent-input"
|
||||
: "form-control zt-k-hub-plan-norms-input zt-k-hub-plan-norms-input--locked dry-matter-percent-input";
|
||||
const inputAttrs = editable
|
||||
? `data-norms-input aria-label="СВ% на план"`
|
||||
: `readonly tabindex="-1" data-norms-input data-norms-input-locked aria-label="СВ% — только чтение" aria-disabled="true" title="${escapeHtml(PHYS_WEIGHT_HINT)}"`;
|
||||
return (
|
||||
`<div class="zt-data-grid__row ingredient-row${rowMods ? " " + rowMods : ""}" ` +
|
||||
`data-norms-row data-component-id="${escapeHtml(item.componentId)}" ` +
|
||||
`data-norms-editable="${editable ? "1" : "0"}" role="row">` +
|
||||
`<div class="zt-data-grid__cell ingredient-detail-cell zt-k-hub-plan-norms__component-cell" data-label="Компонент" role="gridcell">` +
|
||||
componentCellHtml(item) +
|
||||
"</div>" +
|
||||
`<div class="zt-data-grid__cell ingredient-detail-cell dry-matter-percent zt-data-grid__cell--num" data-label="СВ, %" role="gridcell">` +
|
||||
`<div class="zt-k-hub-plan-norms__dm-cell">` +
|
||||
`<input type="number" class="${inputClass}" step="0.01" min="0" max="100" ` +
|
||||
`value="${inputVal === "" ? "" : escapeHtml(String(inputVal))}" inputmode="decimal" ${inputAttrs}>` +
|
||||
baseDryMatterHintHtml(item) +
|
||||
`</div></div>` +
|
||||
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--num" data-label="СВ/гол, кг" role="gridcell">` +
|
||||
`<span class="text-cell zt-k-hub-plan-norms-preview-cell" data-norms-preview-dmph>…</span></div>` +
|
||||
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--num" data-label="Вес/гол, кг" role="gridcell">` +
|
||||
`<span class="text-cell zt-k-hub-plan-norms-preview-cell" data-norms-preview-wph>…</span></div>` +
|
||||
`<div class="zt-data-grid__cell ingredient-detail-cell zt-data-grid__cell--actions recipe-table-actions-cell" data-label="Действия" role="gridcell">` +
|
||||
`<div class="d-flex align-items-center justify-content-end flex-nowrap recipe-table-row-actions" role="group">` +
|
||||
applyBtn +
|
||||
replaceBtn +
|
||||
undo +
|
||||
"</div></div></div>"
|
||||
);
|
||||
}
|
||||
|
||||
function ensureOverlay() {
|
||||
closeModal();
|
||||
overlayEl = document.createElement("div");
|
||||
overlayEl.className = "zt-k-hub-plan-overlay zt-k-hub-plan-norms";
|
||||
overlayEl.innerHTML =
|
||||
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-norms__dialog" role="dialog" aria-modal="true">' +
|
||||
'<div class="zt-k-hub-plan-subdialog__header">' +
|
||||
'<h3 class="zt-k-hub-plan-subdialog__title">Коррекция СВ% в плане</h3>' +
|
||||
'<button type="button" class="wesp-shell-bsmodal-close" data-norms-close aria-label="Закрыть">×</button>' +
|
||||
"</div>" +
|
||||
'<p class="zt-k-hub-plan-norms__lead">Только на выбранный срок. Рецепты в справочнике не меняются. Пересчёт кг/гол — только где в рецепте включён замок СВ.</p>' +
|
||||
'<label class="zt-k-hub-plan-replace__search-label">Поиск компонента</label>' +
|
||||
'<input type="search" class="form-control zt-k-hub-plan-replace__search" data-norms-search placeholder="Начните вводить название…" autocomplete="off">' +
|
||||
'<label class="zt-k-hub-plan-norms__filter">' +
|
||||
'<span class="zt-checkbox">' +
|
||||
'<input type="checkbox" class="zt-checkbox__input" data-norms-locked-only checked>' +
|
||||
'<span class="zt-checkbox__box" aria-hidden="true"></span>' +
|
||||
"</span>" +
|
||||
'<span class="zt-k-hub-plan-norms__filter-text">Только компоненты в рецептах с замком СВ</span></label>' +
|
||||
'<div class="zt-k-hub-plan-norms__table-wrap">' +
|
||||
'<div class="zt-data-grid zt-data-grid--norms" id="planNormsGrid" role="grid" aria-label="Коррекция норм">' +
|
||||
'<div class="zt-data-grid__head" role="row">' +
|
||||
'<div class="zt-data-grid__cell zt-data-grid__cell--head" role="columnheader">Компонент</div>' +
|
||||
'<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">СВ, %</div>' +
|
||||
'<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">СВ/гол, кг</div>' +
|
||||
'<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--num" role="columnheader">Вес/гол, кг</div>' +
|
||||
'<div class="zt-data-grid__cell zt-data-grid__cell--head zt-data-grid__cell--actions" role="columnheader">Действия</div>' +
|
||||
"</div>" +
|
||||
'<div class="zt-data-grid__body" data-norms-body role="rowgroup"></div>' +
|
||||
"</div></div></div>";
|
||||
|
||||
overlayEl.querySelector("[data-norms-close]")?.addEventListener("click", closeModal);
|
||||
overlayEl.addEventListener("click", (e) => {
|
||||
if (e.target === overlayEl) closeModal();
|
||||
});
|
||||
overlayEl.querySelector("[data-norms-search]")?.addEventListener("input", renderRows);
|
||||
overlayEl.querySelector("[data-norms-locked-only]")?.addEventListener("change", (event) => {
|
||||
lockedOnlyFilter = !!event.target.checked;
|
||||
renderRows();
|
||||
});
|
||||
overlayEl.querySelector("[data-norms-body]")?.addEventListener("input", (event) => {
|
||||
const row = event.target.closest("[data-norms-row]");
|
||||
if (row && event.target.matches("[data-norms-input]:not([data-norms-input-locked])")) {
|
||||
scheduleRowPreview(row);
|
||||
}
|
||||
});
|
||||
overlayEl.querySelector("[data-norms-body]")?.addEventListener("click", onTableClick);
|
||||
overlayEl.setAttribute("data-daily-plan-overlay", "1");
|
||||
const host = overlayHost();
|
||||
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
|
||||
if (el !== overlayEl) el.remove();
|
||||
});
|
||||
host.appendChild(overlayEl);
|
||||
return overlayEl;
|
||||
}
|
||||
|
||||
function filteredItems() {
|
||||
let list = items;
|
||||
if (lockedOnlyFilter) {
|
||||
list = list.filter(itemHasLockedUsage);
|
||||
}
|
||||
const q = (overlayEl?.querySelector("[data-norms-search]")?.value || "").trim().toLowerCase();
|
||||
if (!q) return list;
|
||||
return list.filter((item) => {
|
||||
if ((item.componentName || "").toLowerCase().includes(q)) return true;
|
||||
return (item.recipes || []).some((name) => String(name).toLowerCase().includes(q));
|
||||
});
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const body = overlayEl?.querySelector("[data-norms-body]");
|
||||
if (!body) return;
|
||||
const rows = filteredItems();
|
||||
if (!rows.length) {
|
||||
body.innerHTML =
|
||||
'<div class="zt-k-hub-plan-replace__empty zt-k-hub-plan-norms__empty">' +
|
||||
(lockedOnlyFilter
|
||||
? "Нет компонентов в рецептах с замком СВ на эту дату"
|
||||
: "Нет компонентов в плане на эту дату") +
|
||||
"</div>";
|
||||
return;
|
||||
}
|
||||
body.innerHTML = rows.map((item) => buildRowHtml(item)).join("");
|
||||
updateAllPreviews();
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
const dispenserId = global.WespDailyPlanPanel?.getSelectedDispenserId?.() || "";
|
||||
if (!dispenserId) {
|
||||
notify("Сначала выберите кормораздатчик в плане", "error");
|
||||
return false;
|
||||
}
|
||||
const date = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
|
||||
const r = await fetch(
|
||||
"/api/daily-plan/component-norms?dispenser_id=" +
|
||||
encodeURIComponent(dispenserId) +
|
||||
"&date=" +
|
||||
encodeURIComponent(date),
|
||||
{ credentials: "same-origin" }
|
||||
);
|
||||
if (!r.ok) {
|
||||
notify("Не удалось загрузить компоненты плана", "error");
|
||||
return false;
|
||||
}
|
||||
const data = await r.json();
|
||||
items = Array.isArray(data.items) ? data.items : [];
|
||||
return true;
|
||||
}
|
||||
|
||||
async function applyRow(row) {
|
||||
if (row.dataset.normsEditable !== "1") {
|
||||
flashWeightLockedHint(row.querySelector("[data-norms-input-locked]"));
|
||||
return;
|
||||
}
|
||||
const componentId = row.dataset.componentId;
|
||||
const input = row.querySelector("[data-norms-input]");
|
||||
const raw = input?.value;
|
||||
if (raw === "" || raw == null) {
|
||||
notify("Введите СВ%", "error");
|
||||
return;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0 || value > 100) {
|
||||
notify("СВ% должно быть от 0 до 100", "error");
|
||||
return;
|
||||
}
|
||||
const skip = global.WespDailyPlanSkipDuration;
|
||||
if (!skip) return;
|
||||
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
|
||||
const payload = await skip.skipDurationBody(
|
||||
planDate,
|
||||
{ componentId, dryMatter: value },
|
||||
"На какой срок сохранить коррекцию?",
|
||||
{
|
||||
overlayHost: overlayHost(),
|
||||
keepOverlays: [".zt-k-hub-plan-norms"],
|
||||
onError: (msg) => notify(msg, "error"),
|
||||
}
|
||||
);
|
||||
if (!payload) return;
|
||||
const r = await fetch("/api/daily-plan/adjustments/components", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
notify(data.message || "Ошибка сохранения", "error");
|
||||
return;
|
||||
}
|
||||
notify("Коррекция сохранена");
|
||||
await refreshAfterChange();
|
||||
}
|
||||
|
||||
async function undoRow(row) {
|
||||
const componentId = row.dataset.componentId;
|
||||
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
|
||||
const r = await fetch(
|
||||
"/api/daily-plan/adjustments/components?component_id=" +
|
||||
encodeURIComponent(componentId) +
|
||||
"&date=" +
|
||||
encodeURIComponent(planDate),
|
||||
{ method: "DELETE", credentials: "same-origin" }
|
||||
);
|
||||
if (!r.ok) {
|
||||
const data = await r.json().catch(() => ({}));
|
||||
notify(data.message || "Не удалось сбросить", "error");
|
||||
return;
|
||||
}
|
||||
notify("СВ% как в рецепте");
|
||||
await refreshAfterChange();
|
||||
}
|
||||
|
||||
async function replaceRow(row) {
|
||||
const componentId = row.dataset.componentId;
|
||||
const item = items.find((x) => String(x.componentId) === String(componentId));
|
||||
const label = item?.componentName || "Компонент";
|
||||
const replaceModal = global.WespDailyPlanReplaceModal;
|
||||
const skip = global.WespDailyPlanSkipDuration;
|
||||
if (!replaceModal || !skip) return;
|
||||
const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso();
|
||||
const dispenserId = global.WespDailyPlanPanel?.getSelectedDispenserId?.() || "";
|
||||
if (!dispenserId) {
|
||||
notify("Сначала выберите кормораздатчик в плане", "error");
|
||||
return;
|
||||
}
|
||||
const durationHost = overlayHost();
|
||||
await replaceModal.open({
|
||||
componentId,
|
||||
label,
|
||||
overlayHost: overlayHost(),
|
||||
keepOverlays: [".zt-k-hub-plan-norms"],
|
||||
subtitle: "Замена применится во всех рецептах плана, где используется этот компонент.",
|
||||
onError: (msg) => notify(msg, "error"),
|
||||
onPick: async (replacementComponentId, replacementName) => {
|
||||
const body = await skip.skipDurationBody(
|
||||
planDate,
|
||||
{ componentId, replacementComponentId, dispenserId },
|
||||
"На какой срок заменить?",
|
||||
{
|
||||
overlayHost: durationHost,
|
||||
keepOverlays: [".zt-k-hub-plan-norms"],
|
||||
onError: (msg) => notify(msg, "error"),
|
||||
}
|
||||
);
|
||||
if (!body) return;
|
||||
const resp = await fetch("/api/daily-plan/replacements/components", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.message || "Не удалось заменить компонент");
|
||||
}
|
||||
const count = Number(data.count) || 0;
|
||||
notify(
|
||||
count > 1
|
||||
? `Компонент заменён на «${replacementName}» в ${count} рецептах`
|
||||
: `Компонент заменён на «${replacementName}»`
|
||||
);
|
||||
await refreshAfterChange();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshAfterChange() {
|
||||
await loadItems();
|
||||
renderRows();
|
||||
global.WespDailyPlanPanel?.reload?.();
|
||||
if (typeof global.reloadAfterSkipRestore === "function") global.reloadAfterSkipRestore();
|
||||
}
|
||||
|
||||
function onTableClick(event) {
|
||||
const lockedInput = event.target.closest("[data-norms-input-locked]");
|
||||
if (lockedInput) {
|
||||
event.preventDefault();
|
||||
flashWeightLockedHint(lockedInput);
|
||||
return;
|
||||
}
|
||||
const row = event.target.closest("[data-norms-row]");
|
||||
if (!row) return;
|
||||
if (event.target.closest("[data-norms-apply]")) {
|
||||
event.preventDefault();
|
||||
applyRow(row);
|
||||
} else if (event.target.closest("[data-norms-replace]")) {
|
||||
event.preventDefault();
|
||||
replaceRow(row);
|
||||
} else if (event.target.closest("[data-norms-undo]")) {
|
||||
event.preventDefault();
|
||||
undoRow(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function openModal() {
|
||||
lockedOnlyFilter = true;
|
||||
ensureOverlay();
|
||||
const ok = await loadItems();
|
||||
if (ok) renderRows();
|
||||
}
|
||||
|
||||
global.openDailyPlanComponentNormsModal = openModal;
|
||||
global.closeDailyPlanComponentNormsModal = closeModal;
|
||||
})(window);
|
||||
@@ -0,0 +1,930 @@
|
||||
/**
|
||||
* Панель «План на день» внутри hub-модалки «К».
|
||||
*/
|
||||
(function (global) {
|
||||
const DAILY_PLAN_DISPENSER_KEY = "wesp-daily-plan-dispenser";
|
||||
const ALL_DISPENSERS_ID = "__all_dispensers__";
|
||||
const ALL_MILLS_ID = "__all_mills__";
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatDistribution(group) {
|
||||
if (group?.distributionLabel) return group.distributionLabel;
|
||||
const type = group?.distributionType || "percent";
|
||||
const value = group?.value ?? "";
|
||||
if (type === "heads") return `${value} гол.`;
|
||||
return `${value}%`;
|
||||
}
|
||||
|
||||
function todayIso() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function createDailyPlanPanel() {
|
||||
let rootEl = null;
|
||||
let dateEl = null;
|
||||
let dispenserEl = null;
|
||||
let contentEl = null;
|
||||
let dispensers = [];
|
||||
let undoToastTimer = null;
|
||||
|
||||
function getSelectedDispenserId() {
|
||||
return dispenserEl?.value || "";
|
||||
}
|
||||
|
||||
function getSelectedPlanDate() {
|
||||
return planDateValue();
|
||||
}
|
||||
|
||||
function notifySuccess(message) {
|
||||
try {
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.(message, { skipRecord: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function notifyError(message, fallback) {
|
||||
try {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(message, {
|
||||
fallback: fallback || message,
|
||||
skipRecord: true,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolbar() {
|
||||
return (
|
||||
'<div class="zt-k-hub-plan__toolbar">' +
|
||||
'<div class="zt-k-hub-plan__field">' +
|
||||
'<label class="form-label">Дата</label>' +
|
||||
'<input type="date" class="form-control form-control-sm" data-daily-plan-date>' +
|
||||
"</div>" +
|
||||
'<div class="zt-k-hub-plan__field">' +
|
||||
'<label class="form-label">Кормораздатчик</label>' +
|
||||
'<select class="form-control form-control-sm" data-daily-plan-dispenser>' +
|
||||
'<option value="">Загрузка…</option></select>' +
|
||||
"</div>" +
|
||||
'<button type="button" class="btn btn-primary btn-sm zt-k-hub-plan__pdf" data-action="daily-plan-pdf">' +
|
||||
'<i class="fas fa-file-pdf"></i> Скачать PDF</button>' +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function mount(container) {
|
||||
rootEl = container;
|
||||
rootEl.innerHTML =
|
||||
'<div class="zt-k-hub-plan">' +
|
||||
renderToolbar() +
|
||||
'<div class="zt-k-hub-plan__content" data-daily-plan-content>' +
|
||||
'<div class="zt-k-hub-plan__loading text-muted small">Загрузка плана…</div>' +
|
||||
"</div></div>";
|
||||
dateEl = rootEl.querySelector("[data-daily-plan-date]");
|
||||
dispenserEl = rootEl.querySelector("[data-daily-plan-dispenser]");
|
||||
contentEl = rootEl.querySelector("[data-daily-plan-content]");
|
||||
if (dateEl) dateEl.value = todayIso();
|
||||
dateEl?.addEventListener("change", () => loadPlan());
|
||||
dispenserEl?.addEventListener("change", () => {
|
||||
if (dispenserEl.value) {
|
||||
try {
|
||||
localStorage.setItem(DAILY_PLAN_DISPENSER_KEY, dispenserEl.value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
loadPlan();
|
||||
});
|
||||
rootEl.querySelector("[data-action='daily-plan-pdf']")?.addEventListener("click", downloadPdf);
|
||||
rootEl.addEventListener("click", onPanelClick);
|
||||
loadDispensers();
|
||||
}
|
||||
|
||||
function planDateValue() {
|
||||
return dateEl?.value || todayIso();
|
||||
}
|
||||
|
||||
async function onPanelClick(event) {
|
||||
const btn = event.target.closest("[data-action]");
|
||||
if (!btn || !rootEl?.contains(btn)) return;
|
||||
const action = btn.dataset.action;
|
||||
if (action === "daily-plan-skip-trip") {
|
||||
event.preventDefault();
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
const recipeName = btn.dataset.recipeName || "Рейс";
|
||||
if (recipeId) await skipTrip(recipeId, recipeName);
|
||||
return;
|
||||
}
|
||||
if (action === "daily-plan-unskip-trip") {
|
||||
event.preventDefault();
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
if (recipeId) await unskipTrip(recipeId);
|
||||
return;
|
||||
}
|
||||
if (action === "daily-plan-skip-ingredient") {
|
||||
event.preventDefault();
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
const ingredientId = btn.dataset.ingredientId;
|
||||
const label = btn.dataset.label || "Компонент";
|
||||
if (recipeId && ingredientId) await skipIngredient(recipeId, ingredientId, label);
|
||||
return;
|
||||
}
|
||||
if (action === "daily-plan-unskip-ingredient") {
|
||||
event.preventDefault();
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
const ingredientId = btn.dataset.ingredientId;
|
||||
if (recipeId && ingredientId) await unskipIngredient(recipeId, ingredientId);
|
||||
return;
|
||||
}
|
||||
if (action === "daily-plan-skip-unloading-group") {
|
||||
event.preventDefault();
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
const groupId = btn.dataset.groupId;
|
||||
const label = btn.dataset.label || "Группа";
|
||||
if (recipeId && groupId) await skipUnloadingGroup(recipeId, groupId, label);
|
||||
return;
|
||||
}
|
||||
if (action === "daily-plan-unskip-unloading-group") {
|
||||
event.preventDefault();
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
const groupId = btn.dataset.groupId;
|
||||
if (recipeId && groupId) await unskipUnloadingGroup(recipeId, groupId);
|
||||
return;
|
||||
}
|
||||
if (action === "daily-plan-replace-ingredient") {
|
||||
event.preventDefault();
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
const ingredientId = btn.dataset.ingredientId;
|
||||
const componentId = btn.dataset.componentId;
|
||||
const label = btn.dataset.label || "Компонент";
|
||||
if (recipeId && ingredientId && componentId) {
|
||||
await openReplaceIngredientModal(recipeId, ingredientId, componentId, label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "daily-plan-undo-replace-ingredient") {
|
||||
event.preventDefault();
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
const ingredientId = btn.dataset.ingredientId;
|
||||
if (recipeId && ingredientId) await undoReplaceIngredient(recipeId, ingredientId);
|
||||
return;
|
||||
}
|
||||
if (action === "daily-plan-undo-adjust-norm") {
|
||||
event.preventDefault();
|
||||
const componentId = btn.dataset.componentId;
|
||||
if (componentId) await undoComponentNormAdjustment(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
function formatKg(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "—";
|
||||
if (Number.isInteger(n)) return String(n);
|
||||
return n.toFixed(2).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function replacementRowTitle(ing) {
|
||||
if (!ing?.replacedToday) return "";
|
||||
if (ing.recalculationMode === "dry_matter") {
|
||||
return (
|
||||
`Пересчёт по СВ: ${formatKg(ing.originalDryMatterPerHead)} кг/гол сохранено, ` +
|
||||
`вес ${formatKg(ing.originalWeightPerHead)} → ${formatKg(ing.weightPerHead)} кг/гол ` +
|
||||
`(СВ ${formatKg(ing.originalDryMatterPct)}% → ${formatKg(ing.dryMatterPct)}%)`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`Пересчёт по весу: ${formatKg(ing.weightPerHead)} кг/гол сохранено, ` +
|
||||
`СВ/гол ${formatKg(ing.originalDryMatterPerHead)} → ${formatKg(ing.dryMatterPerHead)} кг/гол`
|
||||
);
|
||||
}
|
||||
|
||||
function adjustmentRowTitle(ing) {
|
||||
if (!ing?.adjustedToday) return "";
|
||||
return (
|
||||
`Норма в плане: ${formatKg(ing.originalWeightPerHead)} → ${formatKg(ing.weightPerHead)} кг/гол, ` +
|
||||
`СВ/гол ${formatKg(ing.originalDryMatterPerHead)} → ${formatKg(ing.dryMatterPerHead)}`
|
||||
);
|
||||
}
|
||||
|
||||
function skippedIngredientHint(ing) {
|
||||
const wph = Number(ing?.baselineWeightPerHead ?? ing?.weightPerHead);
|
||||
const total = Number(ing?.baselineTotalKg);
|
||||
if (!Number.isFinite(wph) || !Number.isFinite(total)) return "";
|
||||
return `Было: ${formatKg(wph)} кг/гол (${formatKg(total)} кг)`;
|
||||
}
|
||||
|
||||
function renderSkippedValueCell(hint) {
|
||||
if (!hint) {
|
||||
return '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>';
|
||||
}
|
||||
return (
|
||||
`<td class="zt-k-hub-plan-row--skipped text-muted zt-k-hub-plan-cell--hint" ` +
|
||||
`data-hint="${escapeHtml(hint)}" title="${escapeHtml(hint)}" aria-label="${escapeHtml(hint)}">—</td>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderPlanDot(title) {
|
||||
return (
|
||||
`<span class="recipe-skip-badge__dot zt-k-hub-plan-row__skip-dot" ` +
|
||||
`title="${escapeHtml(title)}" aria-hidden="true"></span>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderSkipDot() {
|
||||
return renderPlanDot("Исключён из плана на этот день");
|
||||
}
|
||||
|
||||
function renderReplaceDot(title) {
|
||||
return renderPlanDot(title || "Сегодня заменили компонент в плане");
|
||||
}
|
||||
|
||||
function renderAdjustDot(title = "Сегодня изменили норму в плане") {
|
||||
return `<span class="zt-k-hub-plan-row__adjust-dot" title="${escapeHtml(title)}" aria-hidden="true"></span>`;
|
||||
}
|
||||
|
||||
function renderRowActionBtn(action, attrs, title, iconClass) {
|
||||
return (
|
||||
`<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-row__skip" ` +
|
||||
`data-action="${action}" ${attrs} title="${escapeHtml(title)}">` +
|
||||
`<i class="fas ${iconClass}"></i></button>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderSkipActionBtn(action, attrs, title) {
|
||||
return renderRowActionBtn(action, attrs, title, "fa-times");
|
||||
}
|
||||
|
||||
function hubModalEl() {
|
||||
return (
|
||||
rootEl?.closest(".modal.wesp-shell-modal") ||
|
||||
document.getElementById("zootechNotificationCenterModal")
|
||||
);
|
||||
}
|
||||
|
||||
function planOverlayHost() {
|
||||
return hubModalEl() || rootEl?.closest(".modal-content") || rootEl;
|
||||
}
|
||||
|
||||
function dismissAllPlanOverlays() {
|
||||
hubModalEl()
|
||||
?.querySelectorAll(".zt-k-hub-plan-overlay")
|
||||
.forEach((el) => el.remove());
|
||||
}
|
||||
|
||||
function appendPlanOverlay(overlay) {
|
||||
dismissAllPlanOverlays();
|
||||
overlay.setAttribute("data-daily-plan-overlay", "1");
|
||||
planOverlayHost()?.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function promptSkipDuration(planDate, title = "На какой срок исключить?") {
|
||||
const skip = global.WespDailyPlanSkipDuration;
|
||||
if (!skip) return Promise.resolve(null);
|
||||
return skip.promptSkipDuration({
|
||||
planDate,
|
||||
title,
|
||||
overlayHost: planOverlayHost() || document.body,
|
||||
beforeAppend: dismissAllPlanOverlays,
|
||||
onError: notifyError,
|
||||
});
|
||||
}
|
||||
|
||||
function skipDurationBody(planDate, extra = {}, title) {
|
||||
const skip = global.WespDailyPlanSkipDuration;
|
||||
if (!skip) return Promise.resolve(null);
|
||||
return skip.skipDurationBody(planDate, extra, title);
|
||||
}
|
||||
|
||||
async function openReplaceIngredientModal(recipeId, ingredientId, componentId, label) {
|
||||
const planDate = planDateValue();
|
||||
const replaceModal = global.WespDailyPlanReplaceModal;
|
||||
if (!replaceModal) return;
|
||||
await replaceModal.open({
|
||||
componentId,
|
||||
label,
|
||||
overlayHost: planOverlayHost() || document.body,
|
||||
onError: notifyError,
|
||||
onPick: async (replacementComponentId, replacementName) => {
|
||||
const body = await skipDurationBody(
|
||||
planDate,
|
||||
{ recipeId, ingredientId, replacementComponentId },
|
||||
"На какой срок заменить?"
|
||||
);
|
||||
if (!body) return;
|
||||
const resp = await fetch("/api/daily-plan/replacements/ingredients", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось заменить компонент");
|
||||
}
|
||||
await loadPlan();
|
||||
notifySuccess(`Компонент заменён на «${replacementName}»`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function undoReplaceIngredient(recipeId, ingredientId) {
|
||||
const planDate = planDateValue();
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
recipe_id: recipeId,
|
||||
ingredient_id: ingredientId,
|
||||
date: planDate,
|
||||
});
|
||||
const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params.toString()}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось отменить замену");
|
||||
}
|
||||
await loadPlan();
|
||||
notifySuccess("Замена отменена");
|
||||
} catch (err) {
|
||||
notifyError(err.message, "Не удалось отменить замену");
|
||||
}
|
||||
}
|
||||
|
||||
async function undoComponentNormAdjustment(componentId) {
|
||||
const planDate = planDateValue();
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
component_id: componentId,
|
||||
date: planDate,
|
||||
});
|
||||
const resp = await fetch(`/api/daily-plan/adjustments/components?${params.toString()}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось вернуть норму");
|
||||
}
|
||||
await loadPlan();
|
||||
notifySuccess("Норма возвращена к мастеру");
|
||||
if (typeof global.reloadAfterSkipRestore === "function") {
|
||||
global.reloadAfterSkipRestore();
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err.message, "Не удалось вернуть норму");
|
||||
}
|
||||
}
|
||||
|
||||
async function skipTrip(recipeId, recipeName) {
|
||||
const planDate = planDateValue();
|
||||
const body = await skipDurationBody(planDate, { recipeId });
|
||||
if (!body) return;
|
||||
try {
|
||||
const resp = await fetch("/api/daily-plan/skips", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось исключить рейс");
|
||||
}
|
||||
await loadPlan();
|
||||
showUndoToast(
|
||||
"daily-plan-unskip-trip",
|
||||
`data-recipe-id="${escapeHtml(recipeId)}"`,
|
||||
`Рейс «${escapeHtml(recipeName)}» убран из плана.`
|
||||
);
|
||||
} catch (err) {
|
||||
notifyError(err.message, "Не удалось исключить рейс");
|
||||
}
|
||||
}
|
||||
|
||||
async function unskipTrip(recipeId) {
|
||||
const planDate = planDateValue();
|
||||
try {
|
||||
const params = new URLSearchParams({ recipe_id: recipeId, date: planDate });
|
||||
const resp = await fetch(`/api/daily-plan/skips?${params.toString()}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось вернуть рейс");
|
||||
}
|
||||
dismissUndoToast();
|
||||
await loadPlan();
|
||||
notifySuccess("Рейс снова в плане");
|
||||
} catch (err) {
|
||||
notifyError(err.message, "Не удалось вернуть рейс");
|
||||
}
|
||||
}
|
||||
|
||||
async function skipIngredient(recipeId, ingredientId, label) {
|
||||
const planDate = planDateValue();
|
||||
const body = await skipDurationBody(planDate, { recipeId, ingredientId });
|
||||
if (!body) return;
|
||||
try {
|
||||
const resp = await fetch("/api/daily-plan/skips/ingredients", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось убрать компонент");
|
||||
}
|
||||
await loadPlan();
|
||||
showUndoToast(
|
||||
"daily-plan-unskip-ingredient",
|
||||
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`,
|
||||
`Компонент «${escapeHtml(label)}» убран из плана.`
|
||||
);
|
||||
} catch (err) {
|
||||
notifyError(err.message, "Не удалось убрать компонент");
|
||||
}
|
||||
}
|
||||
|
||||
async function unskipIngredient(recipeId, ingredientId) {
|
||||
const planDate = planDateValue();
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
recipe_id: recipeId,
|
||||
ingredient_id: ingredientId,
|
||||
date: planDate,
|
||||
});
|
||||
const resp = await fetch(`/api/daily-plan/skips/ingredients?${params.toString()}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось вернуть компонент");
|
||||
}
|
||||
dismissUndoToast();
|
||||
await loadPlan();
|
||||
notifySuccess("Компонент снова в плане");
|
||||
} catch (err) {
|
||||
notifyError(err.message, "Не удалось вернуть компонент");
|
||||
}
|
||||
}
|
||||
|
||||
async function skipUnloadingGroup(recipeId, groupId, label) {
|
||||
const planDate = planDateValue();
|
||||
const body = await skipDurationBody(planDate, { recipeId, unloadingGroupId: groupId });
|
||||
if (!body) return;
|
||||
try {
|
||||
const resp = await fetch("/api/daily-plan/skips/unloading-groups", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось убрать группу");
|
||||
}
|
||||
await loadPlan();
|
||||
showUndoToast(
|
||||
"daily-plan-unskip-unloading-group",
|
||||
`data-recipe-id="${escapeHtml(recipeId)}" data-group-id="${escapeHtml(groupId)}"`,
|
||||
`Группа «${escapeHtml(label)}» убрана из плана.`
|
||||
);
|
||||
} catch (err) {
|
||||
notifyError(err.message, "Не удалось убрать группу");
|
||||
}
|
||||
}
|
||||
|
||||
async function unskipUnloadingGroup(recipeId, groupId) {
|
||||
const planDate = planDateValue();
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
recipe_id: recipeId,
|
||||
unloading_group_id: groupId,
|
||||
date: planDate,
|
||||
});
|
||||
const resp = await fetch(
|
||||
`/api/daily-plan/skips/unloading-groups?${params.toString()}`,
|
||||
{ method: "DELETE", headers: { Accept: "application/json" } }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось вернуть группу");
|
||||
}
|
||||
dismissUndoToast();
|
||||
await loadPlan();
|
||||
notifySuccess("Группа снова в плане");
|
||||
} catch (err) {
|
||||
notifyError(err.message, "Не удалось вернуть группу");
|
||||
}
|
||||
}
|
||||
|
||||
function dismissUndoToast() {
|
||||
if (undoToastTimer) {
|
||||
clearTimeout(undoToastTimer);
|
||||
undoToastTimer = null;
|
||||
}
|
||||
rootEl?.querySelector("[data-daily-plan-undo]")?.remove();
|
||||
}
|
||||
|
||||
function showUndoToast(unskipAction, dataAttrs, message) {
|
||||
dismissUndoToast();
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "zt-k-hub-plan__undo-toast";
|
||||
toast.dataset.dailyPlanUndo = "1";
|
||||
toast.innerHTML =
|
||||
`<span>${message}</span>` +
|
||||
`<button type="button" class="btn btn-sm btn-link zt-k-hub-plan__undo-btn" ` +
|
||||
`data-action="${unskipAction}" ${dataAttrs}>Отменить</button>`;
|
||||
rootEl?.querySelector(".zt-k-hub-plan")?.appendChild(toast);
|
||||
undoToastTimer = setTimeout(() => dismissUndoToast(), 8000);
|
||||
}
|
||||
|
||||
async function loadDispensers() {
|
||||
try {
|
||||
const resp = await fetch("/api/feed_dispensers?limit=200");
|
||||
if (!resp.ok) throw new Error("dispensers");
|
||||
const data = await resp.json();
|
||||
dispensers = Array.isArray(data) ? data : [];
|
||||
if (!dispenserEl) return;
|
||||
if (!dispensers.length) {
|
||||
dispenserEl.innerHTML = '<option value="">Нет кормораздатчиков</option>';
|
||||
renderEmpty("Добавьте кормораздатчик в разделе «Кормораздатчики».");
|
||||
return;
|
||||
}
|
||||
const saved = (() => {
|
||||
try {
|
||||
return localStorage.getItem(DAILY_PLAN_DISPENSER_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
})();
|
||||
const dispenserItems = dispensers.filter((d) => d.type !== "mill");
|
||||
const millItems = dispensers.filter((d) => d.type === "mill");
|
||||
let options =
|
||||
`<option value="${ALL_DISPENSERS_ID}"${saved === ALL_DISPENSERS_ID ? " selected" : ""}>Все кормораздатчики</option>`;
|
||||
if (dispenserItems.length) {
|
||||
options += '<optgroup label="Кормораздатчики">';
|
||||
options += dispenserItems
|
||||
.map((d) => {
|
||||
const id = escapeHtml(d.id);
|
||||
const name = escapeHtml(d.name || d.id);
|
||||
const selected = d.id === saved ? " selected" : "";
|
||||
return `<option value="${id}"${selected}>${name}</option>`;
|
||||
})
|
||||
.join("");
|
||||
options += "</optgroup>";
|
||||
}
|
||||
options += `<option value="${ALL_MILLS_ID}"${saved === ALL_MILLS_ID ? " selected" : ""}>Все кормоцеха</option>`;
|
||||
if (millItems.length) {
|
||||
options += '<optgroup label="Кормоцеха">';
|
||||
options += millItems
|
||||
.map((d) => {
|
||||
const id = escapeHtml(d.id);
|
||||
const name = escapeHtml(d.name || d.id);
|
||||
const selected = d.id === saved ? " selected" : "";
|
||||
return `<option value="${id}"${selected}>${name}</option>`;
|
||||
})
|
||||
.join("");
|
||||
options += "</optgroup>";
|
||||
}
|
||||
dispenserEl.innerHTML = options;
|
||||
if (!dispenserEl.value) {
|
||||
dispenserEl.value = saved || ALL_DISPENSERS_ID;
|
||||
}
|
||||
loadPlan();
|
||||
} catch {
|
||||
if (dispenserEl) {
|
||||
dispenserEl.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
}
|
||||
renderEmpty("Не удалось загрузить список кормораздатчиков.");
|
||||
}
|
||||
}
|
||||
|
||||
function buildPlanUrl(pdf) {
|
||||
const params = new URLSearchParams();
|
||||
if (dateEl?.value) params.set("date", dateEl.value);
|
||||
if (dispenserEl?.value) params.set("dispenser_id", dispenserEl.value);
|
||||
const base = pdf ? "/api/daily-plan/pdf" : "/api/daily-plan";
|
||||
return `${base}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function loadPlan() {
|
||||
if (!contentEl) return;
|
||||
if (!dispenserEl?.value) {
|
||||
renderEmpty("Выберите кормораздатчик.");
|
||||
return;
|
||||
}
|
||||
contentEl.innerHTML =
|
||||
'<div class="zt-k-hub-plan__loading text-muted small">Загрузка плана…</div>';
|
||||
try {
|
||||
const resp = await fetch(buildPlanUrl(false));
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Ошибка загрузки");
|
||||
}
|
||||
const plan = await resp.json();
|
||||
renderPlan(plan);
|
||||
} catch (err) {
|
||||
renderEmpty(global.WespUserMessages?.safe?.(err.message, "Не удалось загрузить план.") || "Не удалось загрузить план.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderEmpty(message) {
|
||||
if (!contentEl) return;
|
||||
contentEl.innerHTML =
|
||||
'<div class="zt-k-hub-plan__empty">' +
|
||||
'<i class="fas fa-calendar-day"></i>' +
|
||||
`<p>${escapeHtml(message)}</p></div>`;
|
||||
}
|
||||
|
||||
function renderReplaceBtn(recipeId, ing) {
|
||||
if (!ing.componentId) return "";
|
||||
const label = ing.originalName || ing.name || "Компонент";
|
||||
return renderRowActionBtn(
|
||||
"daily-plan-replace-ingredient",
|
||||
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ing.id)}" ` +
|
||||
`data-component-id="${escapeHtml(ing.componentId)}" data-label="${escapeHtml(label)}"`,
|
||||
"Заменить компонент",
|
||||
"fa-exchange-alt"
|
||||
);
|
||||
}
|
||||
|
||||
function renderTotalsColumn(totals, grandTotalKg) {
|
||||
if (!totals.length) {
|
||||
return (
|
||||
'<aside class="zt-k-hub-plan__totals-col">' +
|
||||
'<h3 class="zt-k-hub-plan__col-title">Итого по компонентам</h3>' +
|
||||
'<p class="zt-k-hub-plan__col-empty text-muted">Нет данных</p></aside>'
|
||||
);
|
||||
}
|
||||
let rows = "";
|
||||
totals.forEach((row) => {
|
||||
rows += `<tr><td>${escapeHtml(row.name)}</td><td>${escapeHtml(formatKg(row.totalKg))}</td></tr>`;
|
||||
});
|
||||
const grand =
|
||||
grandTotalKg ??
|
||||
totals.reduce((sum, row) => sum + Number(row.totalKg || 0), 0);
|
||||
return (
|
||||
'<aside class="zt-k-hub-plan__totals-col">' +
|
||||
'<h3 class="zt-k-hub-plan__col-title">Итого по компонентам</h3>' +
|
||||
'<div class="zt-k-hub-plan__col-scroll">' +
|
||||
'<table class="zt-k-hub-plan-table zt-k-hub-plan-table--totals">' +
|
||||
"<thead><tr><th>Компонент</th><th>Всего, кг</th></tr></thead>" +
|
||||
`<tbody>${rows}</tbody>` +
|
||||
`<tfoot><tr class="zt-k-hub-plan-table__total-row"><th>Итого</th><th>${escapeHtml(formatKg(grand))}</th></tr></tfoot>` +
|
||||
"</table></div></aside>"
|
||||
);
|
||||
}
|
||||
|
||||
function renderSkippedSection(plan) {
|
||||
const skippedTrips = plan.skippedTrips || [];
|
||||
if (!skippedTrips.length) return "";
|
||||
let rows = "";
|
||||
skippedTrips.forEach((trip) => {
|
||||
rows +=
|
||||
'<li class="zt-k-hub-plan-skipped__item">' +
|
||||
`<span>${renderSkipDot()}<span class="text-muted">Рейс:</span> ${escapeHtml(trip.recipeName)}</span>` +
|
||||
'<button type="button" class="btn btn-sm btn-outline-success" ' +
|
||||
'data-action="daily-plan-unskip-trip" ' +
|
||||
`data-recipe-id="${escapeHtml(trip.recipeId)}" title="Вернуть в план">` +
|
||||
'<i class="fas fa-undo"></i> Вернуть</button></li>';
|
||||
});
|
||||
return (
|
||||
'<section class="zt-k-hub-plan-skipped">' +
|
||||
'<h3 class="zt-k-hub-plan-skipped__title">Исключено из плана</h3>' +
|
||||
`<ul class="zt-k-hub-plan-skipped__list">${rows}</ul></section>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderTripsColumn(periods) {
|
||||
let html = '<div class="zt-k-hub-plan__trips-col"><div class="zt-k-hub-plan__col-scroll">';
|
||||
periods.forEach((period) => {
|
||||
const trips = period.trips || [];
|
||||
if (!trips.length) return;
|
||||
html +=
|
||||
`<section class="zt-k-hub-plan__period"><h3 class="zt-k-hub-plan__period-title">${escapeHtml(period.name)}</h3><div class="zt-k-hub-plan__trips">`;
|
||||
trips.forEach((trip) => {
|
||||
const recipeId = escapeHtml(trip.recipeId);
|
||||
const hasAdjustedNorm = (trip.ingredients || []).some((ing) => ing.adjustedToday);
|
||||
const hasReplacedIngredient = (trip.ingredients || []).some((ing) => ing.replacedToday);
|
||||
const adjustBadge = hasAdjustedNorm
|
||||
? ' <span class="recipe-adjust-badge" title="Сегодня изменили норму в плане">' +
|
||||
'<span class="zt-k-hub-plan-row__adjust-dot" aria-hidden="true"></span>сегодня изменили норму</span>'
|
||||
: "";
|
||||
const replaceBadge = hasReplacedIngredient
|
||||
? ' <span class="recipe-skip-badge" title="Сегодня заменили компонент в плане">' +
|
||||
'<span class="recipe-skip-badge__dot" aria-hidden="true"></span>заменён компонент</span>'
|
||||
: "";
|
||||
const ings = (trip.ingredients || [])
|
||||
.map((ing) => {
|
||||
const ingId = escapeHtml(ing.id);
|
||||
const name = escapeHtml(ing.name);
|
||||
const skipped = Boolean(ing.skippedToday);
|
||||
const replaced = Boolean(ing.replacedToday);
|
||||
const adjusted = Boolean(ing.adjustedToday);
|
||||
const rowClass = skipped
|
||||
? "zt-k-hub-plan-row--skipped"
|
||||
: replaced
|
||||
? "zt-k-hub-plan-row--replaced"
|
||||
: adjusted
|
||||
? "zt-k-hub-plan-row--adjusted"
|
||||
: "";
|
||||
const replaceTitle = replacementRowTitle(ing);
|
||||
const adjustTitle = adjustmentRowTitle(ing);
|
||||
const skipHint = skipped ? skippedIngredientHint(ing) : "";
|
||||
const nameCell = skipped
|
||||
? `<td class="zt-k-hub-plan-row--skipped">${renderSkipDot()}${name}</td>`
|
||||
: replaced
|
||||
? `<td class="zt-k-hub-plan-row--replaced" title="${escapeHtml(replaceTitle)}">${renderReplaceDot(replaceTitle)}${name}</td>`
|
||||
: adjusted
|
||||
? `<td class="zt-k-hub-plan-row--adjusted" title="${escapeHtml(adjustTitle)}">${renderAdjustDot(adjustTitle)}${name}</td>`
|
||||
: `<td>${name}</td>`;
|
||||
const weightCell = skipped
|
||||
? renderSkippedValueCell(skipHint)
|
||||
: `<td>${escapeHtml(formatKg(ing.weightPerHead))}</td>`;
|
||||
const totalCell = skipped
|
||||
? renderSkippedValueCell(skipHint)
|
||||
: `<td>${escapeHtml(formatKg(ing.totalKg))}</td>`;
|
||||
let actionCell = "";
|
||||
if (ing.id) {
|
||||
if (skipped) {
|
||||
actionCell = renderRowActionBtn(
|
||||
"daily-plan-unskip-ingredient",
|
||||
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`,
|
||||
"Вернуть в план",
|
||||
"fa-undo"
|
||||
);
|
||||
} else {
|
||||
if (replaced) {
|
||||
actionCell += renderRowActionBtn(
|
||||
"daily-plan-undo-replace-ingredient",
|
||||
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`,
|
||||
"Отменить замену",
|
||||
"fa-undo"
|
||||
);
|
||||
} else if (adjusted && ing.componentId) {
|
||||
actionCell += renderRowActionBtn(
|
||||
"daily-plan-undo-adjust-norm",
|
||||
`data-component-id="${escapeHtml(ing.componentId)}"`,
|
||||
"Вернуть норму",
|
||||
"fa-undo"
|
||||
);
|
||||
} else {
|
||||
actionCell += renderReplaceBtn(recipeId, ing);
|
||||
}
|
||||
actionCell += renderSkipActionBtn(
|
||||
"daily-plan-skip-ingredient",
|
||||
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}" data-label="${escapeHtml(ing.originalName || ing.name)}"`,
|
||||
"Убрать компонент из плана на этот день"
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
`<tr class="${rowClass}">` +
|
||||
`${nameCell}${weightCell}${totalCell}` +
|
||||
`<td class="zt-k-hub-plan-table__actions zt-k-hub-plan-table__actions--multi">${actionCell}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
const groups = (trip.unloadingGroups || [])
|
||||
.map((g) => {
|
||||
const groupId = escapeHtml(g.id);
|
||||
const name = escapeHtml(g.name);
|
||||
const skipped = Boolean(g.skippedToday);
|
||||
const nameCell = skipped
|
||||
? `<td class="zt-k-hub-plan-row--skipped">${renderSkipDot()}${name}</td>`
|
||||
: `<td>${name}</td>`;
|
||||
const weightCell = skipped
|
||||
? '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>'
|
||||
: `<td>${escapeHtml(g.weightKg)}</td>`;
|
||||
const distCell = skipped
|
||||
? '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>'
|
||||
: `<td>${escapeHtml(formatDistribution(g))}</td>`;
|
||||
let actionCell = "";
|
||||
if (g.id) {
|
||||
if (skipped) {
|
||||
actionCell = renderRowActionBtn(
|
||||
"daily-plan-unskip-unloading-group",
|
||||
`data-recipe-id="${recipeId}" data-group-id="${groupId}"`,
|
||||
"Вернуть в план",
|
||||
"fa-undo"
|
||||
);
|
||||
} else {
|
||||
actionCell = renderSkipActionBtn(
|
||||
"daily-plan-skip-unloading-group",
|
||||
`data-recipe-id="${recipeId}" data-group-id="${groupId}" data-label="${name}"`,
|
||||
"Убрать группу из плана на этот день"
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
`<tr class="${skipped ? "zt-k-hub-plan-row--skipped" : ""}">` +
|
||||
`${nameCell}${weightCell}${distCell}` +
|
||||
`<td class="zt-k-hub-plan-table__actions">${actionCell}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
html +=
|
||||
'<article class="zt-k-hub-plan-trip zt-card">' +
|
||||
'<div class="zt-k-hub-plan-trip__head">' +
|
||||
`<h4 class="zt-k-hub-plan-trip__title">${escapeHtml(trip.recipeName)}${replaceBadge}${adjustBadge}` +
|
||||
` <span class="text-muted">(${escapeHtml(trip.headsPerTrip)} гол., ${escapeHtml(trip.mixingTimeSec)} с)</span></h4>` +
|
||||
'<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-trip__skip" ' +
|
||||
'data-action="daily-plan-skip-trip" ' +
|
||||
`data-recipe-id="${recipeId}" ` +
|
||||
`data-recipe-name="${escapeHtml(trip.recipeName)}" ` +
|
||||
'title="Убрать из плана на этот день">' +
|
||||
'<i class="fas fa-trash"></i></button></div>' +
|
||||
'<table class="zt-k-hub-plan-table"><thead><tr><th>Компонент</th><th>кг/гол</th><th>Всего, кг</th><th></th></tr></thead>' +
|
||||
`<tbody>${ings || '<tr><td colspan="4" class="text-muted">—</td></tr>'}</tbody>` +
|
||||
`<tfoot><tr class="zt-k-hub-plan-table__total-row"><td>Итого по рейсу</td><td></td><td>${escapeHtml(formatKg(trip.totalWeightKg))}</td><td></td></tr></tfoot></table>`;
|
||||
if (groups) {
|
||||
html +=
|
||||
'<p class="zt-k-hub-plan-trip__sub">Выгрузка</p>' +
|
||||
'<table class="zt-k-hub-plan-table"><thead><tr><th>Группа</th><th>кг</th><th>Распределение</th><th></th></tr></thead>' +
|
||||
`<tbody>${groups}</tbody></table>`;
|
||||
}
|
||||
html += "</article>";
|
||||
});
|
||||
html += "</div></section>";
|
||||
});
|
||||
html += "</div></div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPlan(plan) {
|
||||
if (!contentEl) return;
|
||||
const periods = plan.periods || [];
|
||||
const skippedTrips = plan.skippedTrips || [];
|
||||
const hasTrips = periods.some((p) => (p.trips || []).length);
|
||||
const hasSkipped = skippedTrips.length;
|
||||
if (!hasTrips && !hasSkipped) {
|
||||
renderEmpty("Нет периодов или рейсов для выбранного кормораздатчика.");
|
||||
return;
|
||||
}
|
||||
|
||||
contentEl.innerHTML =
|
||||
'<div class="zt-k-hub-plan__split">' +
|
||||
renderTotalsColumn(plan.ingredientTotals || [], plan.ingredientGrandTotalKg) +
|
||||
renderTripsColumn(periods) +
|
||||
"</div>" +
|
||||
renderSkippedSection(plan);
|
||||
}
|
||||
|
||||
function downloadPdf() {
|
||||
if (!dispenserEl?.value) return;
|
||||
global.open(buildPlanUrl(true), "_blank", "noopener");
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
dismissUndoToast();
|
||||
dismissAllPlanOverlays();
|
||||
if (rootEl) {
|
||||
rootEl.removeEventListener("click", onPanelClick);
|
||||
rootEl.innerHTML = "";
|
||||
}
|
||||
rootEl = null;
|
||||
dateEl = null;
|
||||
dispenserEl = null;
|
||||
contentEl = null;
|
||||
}
|
||||
|
||||
return {
|
||||
mount,
|
||||
destroy,
|
||||
dismissAllPlanOverlays,
|
||||
reload: loadPlan,
|
||||
getSelectedDispenserId,
|
||||
getSelectedPlanDate,
|
||||
};
|
||||
}
|
||||
|
||||
let activePanel = null;
|
||||
|
||||
global.WespDailyPlanPanel = {
|
||||
createDailyPlanPanel,
|
||||
_setActivePanel(panel) {
|
||||
activePanel = panel || null;
|
||||
},
|
||||
getSelectedDispenserId() {
|
||||
return activePanel?.getSelectedDispenserId?.() || "";
|
||||
},
|
||||
getSelectedPlanDate() {
|
||||
return activePanel?.getSelectedPlanDate?.() || todayIso();
|
||||
},
|
||||
reload() {
|
||||
return activePanel?.reload?.();
|
||||
},
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Контроль отклонений — лента событий (не дубль отчётов).
|
||||
*/
|
||||
(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);
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Модалка настроек контроля отклонений (глобальные пороги и уведомления).
|
||||
*/
|
||||
const MODAL_ID = "feedQualitySettingsModal";
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function sectionHtml(id, title, fieldsHtml, checksHtml) {
|
||||
return (
|
||||
`<section class="fq-settings-section" data-fq-section="${escapeHtml(id)}">` +
|
||||
'<div class="fq-settings-section__head">' +
|
||||
`<h4 class="fq-settings-section__title">${escapeHtml(title)}</h4>` +
|
||||
'<label class="fq-settings-check">' +
|
||||
`<input type="checkbox" data-fq-field="enabled" data-fq-section="${escapeHtml(id)}" checked>` +
|
||||
" Учитывать</label></div>" +
|
||||
`<div class="fq-settings-grid">${fieldsHtml}</div>` +
|
||||
`<div class="fq-settings-checks">${checksHtml}</div></section>`
|
||||
);
|
||||
}
|
||||
|
||||
function fieldHtml(section, key, label, value, step) {
|
||||
return (
|
||||
'<div class="fq-settings-field">' +
|
||||
`<label>${escapeHtml(label)}</label>` +
|
||||
`<input type="number" class="form-control form-control-sm" data-fq-field="${escapeHtml(key)}" ` +
|
||||
`data-fq-section="${escapeHtml(section)}" value="${escapeHtml(value)}" step="${escapeHtml(step)}" min="0">` +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function notifyChecks(section) {
|
||||
return (
|
||||
'<label class="fq-settings-check">' +
|
||||
`<input type="checkbox" data-fq-field="notify_warning" data-fq-section="${escapeHtml(section)}" checked>` +
|
||||
" Уведомление: предупреждение</label>" +
|
||||
'<label class="fq-settings-check">' +
|
||||
`<input type="checkbox" data-fq-field="notify_critical" data-fq-section="${escapeHtml(section)}" checked>` +
|
||||
" Уведомление: критичное</label>"
|
||||
);
|
||||
}
|
||||
|
||||
function modalTemplate() {
|
||||
const loading = sectionHtml(
|
||||
"loading",
|
||||
"Загрузка компонентов",
|
||||
fieldHtml("loading", "warning_pct", "Порог предупреждения, %", 10, 0.1) +
|
||||
fieldHtml("loading", "critical_pct", "Порог критичного, %", 15, 0.1) +
|
||||
fieldHtml("loading", "warning_min_sec", "Слишком быстро, предупр., с", 3, 1) +
|
||||
fieldHtml("loading", "critical_min_sec", "Слишком быстро, критично, с", 1, 1) +
|
||||
fieldHtml("loading", "warning_max_sec", "Слишком долго, предупр., с", 180, 1) +
|
||||
fieldHtml("loading", "critical_max_sec", "Слишком долго, критично, с", 600, 1),
|
||||
notifyChecks("loading")
|
||||
);
|
||||
const unloading = sectionHtml(
|
||||
"unloading",
|
||||
"Выгрузка по группам",
|
||||
fieldHtml("unloading", "warning_pct", "Порог предупреждения, %", 5, 0.1) +
|
||||
fieldHtml("unloading", "critical_pct", "Порог критичного, %", 10, 0.1),
|
||||
notifyChecks("unloading")
|
||||
);
|
||||
const mix = sectionHtml(
|
||||
"mix_time",
|
||||
"Время смешивания",
|
||||
fieldHtml("mix_time", "warning_delta_sec", "Предупреждение, с", 30, 1) +
|
||||
fieldHtml("mix_time", "critical_delta_sec", "Критично, с", 60, 1),
|
||||
notifyChecks("mix_time")
|
||||
);
|
||||
const mixer = sectionHtml(
|
||||
"left_in_mixer",
|
||||
"Остаток в миксере",
|
||||
fieldHtml("left_in_mixer", "warning_min_kg", "Предупреждение, кг", 5, 0.1) +
|
||||
fieldHtml("left_in_mixer", "warning_min_pct", "Предупреждение, % от партии", 2, 0.1) +
|
||||
fieldHtml("left_in_mixer", "critical_min_kg", "Критично, кг", 15, 0.1),
|
||||
notifyChecks("left_in_mixer")
|
||||
);
|
||||
|
||||
return (
|
||||
`<div class="modal wesp-shell-modal fq-settings-modal" id="${MODAL_ID}" tabindex="-1" aria-hidden="true">` +
|
||||
'<div class="modal-dialog modal-dialog--shell modal-dialog--shell-wide modal-dialog--shell-fq">' +
|
||||
'<div class="modal-content wesp-shell-bsmodal border-0 shadow-none fq-settings-modal__content">' +
|
||||
'<div class="wesp-shell-bsmodal-title-row">' +
|
||||
'<h2 class="wesp-shell-bsmodal-title">Контроль отклонений</h2>' +
|
||||
'<button type="button" class="wesp-shell-bsmodal-close" data-bs-dismiss="modal" aria-label="Закрыть">×</button>' +
|
||||
"</div>" +
|
||||
'<div class="wesp-shell-bsmodal-body">' +
|
||||
`<form id="feedQualitySettingsForm">${loading}${unloading}${mix}${mixer}</form>` +
|
||||
"</div>" +
|
||||
'<div class="wesp-shell-bsmodal-actions">' +
|
||||
'<button type="button" class="wesp-shell-bsmodal-btn" data-bs-dismiss="modal">Отмена</button>' +
|
||||
'<button type="button" class="wesp-shell-bsmodal-btn wesp-shell-bsmodal-btn-primary" data-action="save-feed-quality-settings">' +
|
||||
"Сохранить</button>" +
|
||||
"</div></div></div></div>"
|
||||
);
|
||||
}
|
||||
|
||||
let modalEl = null;
|
||||
let notyfAdapter = null;
|
||||
let currentSettings = null;
|
||||
let modalStackWired = false;
|
||||
|
||||
/** Настройки зоотехника — z-index 3000; shell-модалка Bootstrap по умолчанию ниже. */
|
||||
const FQ_MODAL_Z = 3100;
|
||||
const FQ_BACKDROP_Z = 3099;
|
||||
|
||||
function isSettingsModalOpen() {
|
||||
const settings = document.getElementById("settingsModal");
|
||||
return Boolean(settings && settings.style.display === "block");
|
||||
}
|
||||
|
||||
function raiseModalAboveSettings() {
|
||||
if (!modalEl) return;
|
||||
modalEl.style.zIndex = String(FQ_MODAL_Z);
|
||||
const backdrops = document.querySelectorAll(".modal-backdrop");
|
||||
const backdrop = backdrops[backdrops.length - 1];
|
||||
if (backdrop) backdrop.style.zIndex = String(FQ_BACKDROP_Z);
|
||||
}
|
||||
|
||||
function restoreBodyAfterClose() {
|
||||
const anyBootstrapModalOpen = document.querySelector(".modal.show");
|
||||
if (!anyBootstrapModalOpen) {
|
||||
document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove());
|
||||
document.body.classList.remove("modal-open");
|
||||
document.body.style.removeProperty("padding-right");
|
||||
document.body.style.removeProperty("overflow");
|
||||
}
|
||||
if (isSettingsModalOpen()) {
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
}
|
||||
|
||||
function wireModalStackHandlers() {
|
||||
if (!modalEl || modalStackWired) return;
|
||||
modalStackWired = true;
|
||||
modalEl.addEventListener("shown.bs.modal", raiseModalAboveSettings);
|
||||
modalEl.addEventListener("hidden.bs.modal", restoreBodyAfterClose);
|
||||
}
|
||||
|
||||
function ensureModal() {
|
||||
if (modalEl && modalEl.isConnected) return modalEl;
|
||||
modalEl = document.getElementById(MODAL_ID);
|
||||
if (!modalEl) {
|
||||
document.body.insertAdjacentHTML("beforeend", modalTemplate());
|
||||
modalEl = document.getElementById(MODAL_ID);
|
||||
}
|
||||
globalThis.WespZootechModals?.wireAllShellModals?.();
|
||||
wireModalStackHandlers();
|
||||
modalEl.querySelector("[data-action='save-feed-quality-settings']")?.addEventListener("click", saveSettings);
|
||||
return modalEl;
|
||||
}
|
||||
|
||||
function applySettingsToForm(settings) {
|
||||
const form = document.getElementById("feedQualitySettingsForm");
|
||||
if (!form || !settings) return;
|
||||
form.querySelectorAll("[data-fq-section][data-fq-field]").forEach((el) => {
|
||||
const section = el.getAttribute("data-fq-section");
|
||||
const field = el.getAttribute("data-fq-field");
|
||||
const block = settings[section];
|
||||
if (!block || field == null) return;
|
||||
const val = block[field];
|
||||
if (el.type === "checkbox") {
|
||||
el.checked = Boolean(val);
|
||||
} else if (val != null) {
|
||||
el.value = String(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collectSettingsFromForm() {
|
||||
const form = document.getElementById("feedQualitySettingsForm");
|
||||
const out = { loading: {}, unloading: {}, mix_time: {}, left_in_mixer: {} };
|
||||
if (!form) return out;
|
||||
form.querySelectorAll("[data-fq-section][data-fq-field]").forEach((el) => {
|
||||
const section = el.getAttribute("data-fq-section");
|
||||
const field = el.getAttribute("data-fq-field");
|
||||
if (!out[section]) out[section] = {};
|
||||
if (el.type === "checkbox") {
|
||||
out[section][field] = el.checked;
|
||||
} else {
|
||||
out[section][field] = el.value;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const resp = await fetch("/api/feed-quality/settings");
|
||||
if (!resp.ok) throw new Error("Не удалось загрузить настройки");
|
||||
const data = await resp.json();
|
||||
currentSettings = data.settings || data;
|
||||
applySettingsToForm(currentSettings);
|
||||
}
|
||||
|
||||
async function openModal() {
|
||||
ensureModal();
|
||||
try {
|
||||
await loadSettings();
|
||||
} catch (err) {
|
||||
notyfAdapter?.error?.(err.message || "Ошибка загрузки");
|
||||
return;
|
||||
}
|
||||
const inst = globalThis.bootstrap?.Modal?.getOrCreateInstance(modalEl);
|
||||
inst?.show();
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(raiseModalAboveSettings);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const payload = collectSettingsFromForm();
|
||||
try {
|
||||
const resp = await fetch("/api/feed-quality/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: payload }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.message || "Ошибка сохранения");
|
||||
notyfAdapter?.success?.(
|
||||
data.reevaluatedReports != null
|
||||
? `Настройки сохранены (переоценено ${data.reevaluatedReports} рейсов)`
|
||||
: "Настройки сохранены"
|
||||
);
|
||||
globalThis.WespFeedAlerts?.refreshSummary?.();
|
||||
if (globalThis.WespFeedAlerts?.getCurrentView?.() === "alerts") {
|
||||
globalThis.WespFeedAlerts.loadAlerts();
|
||||
}
|
||||
globalThis.bootstrap?.Modal?.getInstance(modalEl)?.hide();
|
||||
} catch (err) {
|
||||
notyfAdapter?.error?.(err.message || "Ошибка сохранения");
|
||||
}
|
||||
}
|
||||
|
||||
export function initFeedQualitySettingsModal(notyf) {
|
||||
notyfAdapter = notyf;
|
||||
ensureModal();
|
||||
document.addEventListener("click", (event) => {
|
||||
const el = event.target.closest('[data-action="open-feed-quality-settings"]');
|
||||
if (!el) return;
|
||||
event.preventDefault();
|
||||
openModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.WespFeedQualitySettings = {
|
||||
init: initFeedQualitySettingsModal,
|
||||
open: openModal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const CREATE_NEW = "__create_new__";
|
||||
const ACCEPT_EXT = [".xml", ".pdf", ".xlsx", ".xls"];
|
||||
|
||||
let parseResult = null;
|
||||
let components = [];
|
||||
let selectedFile = null;
|
||||
|
||||
const WARN_LABELS = {
|
||||
omd_from_tdn: "ВРХ через TDN — не lab-grade",
|
||||
omd_missing: "нет ВРХ — подставим дефолт",
|
||||
dm_missing: "нет СВ — chemistry on hold",
|
||||
};
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function showEl(el) {
|
||||
if (!el) return;
|
||||
el.hidden = false;
|
||||
el.classList.remove("lab-agrostar-reveal");
|
||||
requestAnimationFrame(() => el.classList.add("lab-agrostar-reveal"));
|
||||
}
|
||||
|
||||
function hideEl(el) {
|
||||
if (!el) return;
|
||||
el.hidden = true;
|
||||
el.classList.remove("lab-agrostar-reveal");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function importKind() {
|
||||
return parseResult?.kind || "lab_samples";
|
||||
}
|
||||
|
||||
function isLabSamples() {
|
||||
return importKind() === "lab_samples";
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const resp = await fetch(path, opts);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || data.error) {
|
||||
throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadComponents(force) {
|
||||
if (components.length && !force) return components;
|
||||
const rows = await api("/api/components?limit=2000");
|
||||
components = Array.isArray(rows) ? rows : [];
|
||||
return components;
|
||||
}
|
||||
|
||||
function getSampleByNo(sampleNo) {
|
||||
return (parseResult?.samples || []).find((s) => s.sampleNo === sampleNo);
|
||||
}
|
||||
|
||||
function buildComponentOptions(matchSource, selectedId) {
|
||||
const suggested = matchSource?.suggestedComponents || [];
|
||||
const seen = new Set();
|
||||
const opts = [
|
||||
'<option value="">— какой реагент? —</option>',
|
||||
`<option value="${CREATE_NEW}">+ добавить новый реагент</option>`,
|
||||
];
|
||||
|
||||
for (const m of suggested) {
|
||||
if (!m.componentId || seen.has(m.componentId)) continue;
|
||||
seen.add(m.componentId);
|
||||
const sel = m.componentId === selectedId ? " selected" : "";
|
||||
opts.push(
|
||||
`<option value="${escapeHtml(m.componentId)}"${sel}>${escapeHtml(m.name)} (${Math.round(m.score * 100)}%)</option>`,
|
||||
);
|
||||
}
|
||||
for (const c of components) {
|
||||
if (!c.id || seen.has(c.id)) continue;
|
||||
const sel = c.id === selectedId ? " selected" : "";
|
||||
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
|
||||
}
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function selectComponentForSample(sampleNo, componentId) {
|
||||
const sel = document.querySelector(
|
||||
`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`,
|
||||
);
|
||||
const sample = getSampleByNo(sampleNo);
|
||||
if (!sel || !sample) return;
|
||||
sel.innerHTML = buildComponentOptions(sample, componentId);
|
||||
sel.value = componentId;
|
||||
}
|
||||
|
||||
function formatUnsupportedNotice(storage) {
|
||||
if (!storage?.unsupportedCount) return "";
|
||||
return storage.unsupportedMessage || `${storage.unsupportedCount} показат. AgroStar без полей в WESP`;
|
||||
}
|
||||
|
||||
function notifyCatalogChanged() {
|
||||
try {
|
||||
global.dispatchEvent(new CustomEvent("wesp:components-catalog-changed"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function createComponentFromSample(sampleNo, sel) {
|
||||
const sample = getSampleByNo(sampleNo);
|
||||
if (!sample || !sel) return;
|
||||
|
||||
const preview = sample.preview || {};
|
||||
const name = (preview.suggestedName || sample.label || "").trim();
|
||||
const type = preview.suggestedType || "Сочные корма";
|
||||
if (!name) {
|
||||
sel.value = "";
|
||||
setResult("Нет названия для нового реагента", true);
|
||||
return;
|
||||
}
|
||||
|
||||
sel.disabled = true;
|
||||
setResult(`Создаём «${name}»…`);
|
||||
try {
|
||||
const data = await api("/api/components", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
type,
|
||||
dry_matter: sample.dryMatterPct || 0,
|
||||
nutrients: sample.nutrients || {},
|
||||
}),
|
||||
});
|
||||
await loadComponents(true);
|
||||
selectComponentForSample(sampleNo, data.id);
|
||||
const storage = preview.storage || {};
|
||||
const skipNote = formatUnsupportedNotice(storage);
|
||||
setResult(
|
||||
`Реагент «${name}» создан — СВ и ${storage.nutrientCount || Object.keys(sample.nutrients || {}).length} показат.` +
|
||||
(skipNote ? ` ${skipNote}` : ""),
|
||||
false,
|
||||
);
|
||||
notifyCatalogChanged();
|
||||
} catch (err) {
|
||||
sel.value = "";
|
||||
setResult(err.message || "Не удалось создать реагент", true);
|
||||
} finally {
|
||||
sel.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onComponentSelectChange(e) {
|
||||
const sel = e.target;
|
||||
if (!sel.classList?.contains("lab-agrostar-component")) return;
|
||||
if (sel.value !== CREATE_NEW) return;
|
||||
const sampleNo = sel.getAttribute("data-sample-no") || "";
|
||||
createComponentFromSample(sampleNo, sel);
|
||||
}
|
||||
|
||||
function prime(reason) {
|
||||
global.WespLabHeisenbergPrime?.error?.(reason, "agrostar");
|
||||
}
|
||||
|
||||
function setStatus(text, isError) {
|
||||
const el = $("labAgrostarStatus");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "small mt-2 mb-0" + (isError ? " lab-agrostar-result--err" : " text-muted");
|
||||
if (isError && text) prime(text);
|
||||
}
|
||||
|
||||
function setResult(text, isError) {
|
||||
const el = $("labAgrostarResult");
|
||||
if (!el) return;
|
||||
el.textContent = text || "";
|
||||
el.className = "small mt-2 " + (isError ? "lab-agrostar-result--err" : "lab-agrostar-result--ok");
|
||||
if (isError && text) prime(text);
|
||||
}
|
||||
|
||||
function formatWarnings(warnings) {
|
||||
if (!warnings?.length) return "—";
|
||||
return warnings
|
||||
.map((w) => {
|
||||
const key = String(w).split("=")[0];
|
||||
const label = WARN_LABELS[key] || w;
|
||||
return `<span class="lab-agrostar-warn">${escapeHtml(label)}</span>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getSelectedComponentLabel(sampleNo) {
|
||||
const sel = document.querySelector(`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`);
|
||||
if (!sel?.value || sel.value === CREATE_NEW) return null;
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
return opt ? opt.text.replace(/\s*\(\d+%\)\s*$/, "").trim() : null;
|
||||
}
|
||||
|
||||
function renderMetaLine(extra) {
|
||||
const meta = $("labAgrostarMeta");
|
||||
if (!meta || !parseResult) return;
|
||||
showEl(meta);
|
||||
const label = parseResult.sourceLabel || parseResult.labName || "Импорт";
|
||||
meta.innerHTML =
|
||||
`<strong>${escapeHtml(label)}</strong>${extra || ""}` +
|
||||
(parseResult.warnings?.length ? ` · ${escapeHtml(parseResult.warnings.join("; "))}` : "");
|
||||
}
|
||||
|
||||
function setActionButtons() {
|
||||
const previewBtn = $("labAgrostarPreview");
|
||||
const applyBtn = $("labAgrostarApply");
|
||||
const clearBtn = $("labAgrostarClear");
|
||||
if (previewBtn) previewBtn.disabled = false;
|
||||
if (clearBtn) clearBtn.disabled = false;
|
||||
if (applyBtn) {
|
||||
applyBtn.disabled = !isLabSamples();
|
||||
applyBtn.title = isLabSamples() ? "" : "Запись в каталог только для лабораторных проб AgroStar";
|
||||
}
|
||||
}
|
||||
|
||||
function renderLabSamplesTable() {
|
||||
const wrap = $("labAgrostarTableWrap");
|
||||
const body = $("labAgrostarTableBody");
|
||||
const head = $("labImportTableHead");
|
||||
const title = $("labImportTableTitle");
|
||||
if (!wrap || !body || !parseResult) return;
|
||||
|
||||
const samples = parseResult.samples || [];
|
||||
if (!samples.length) {
|
||||
hideEl(wrap);
|
||||
return;
|
||||
}
|
||||
|
||||
if (title) title.textContent = "Кому в каталог";
|
||||
if (head) {
|
||||
head.innerHTML = `<tr>
|
||||
<th>Образец</th><th>СВ, %</th><th>Реагент</th><th>Косяки</th>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
renderMetaLine(` · образцов: ${samples.length}`);
|
||||
|
||||
body.innerHTML = samples
|
||||
.map((sample) => {
|
||||
const sid = escapeHtml(sample.sampleNo);
|
||||
const defaultId = sample.suggestedComponents?.[0]?.componentId || "";
|
||||
return `<tr data-sample-no="${sid}">
|
||||
<td>
|
||||
<div class="fw-semibold">${escapeHtml(sample.label || sample.desc1 || sample.sampleNo)}</div>
|
||||
<div class="text-muted small">${sid} · ${escapeHtml(sample.datePrinted || "")}</div>
|
||||
</td>
|
||||
<td>${sample.dryMatterPct != null ? escapeHtml(sample.dryMatterPct) : "—"}</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm lab-agrostar-component" data-sample-no="${sid}">
|
||||
${buildComponentOptions(sample, defaultId)}
|
||||
</select>
|
||||
</td>
|
||||
<td>${formatWarnings(sample.warnings)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
showEl(wrap);
|
||||
setActionButtons();
|
||||
}
|
||||
|
||||
function renderRationCompositionTable() {
|
||||
const wrap = $("labAgrostarTableWrap");
|
||||
const body = $("labAgrostarTableBody");
|
||||
const head = $("labImportTableHead");
|
||||
const title = $("labImportTableTitle");
|
||||
const lines = parseResult?.feedLines || [];
|
||||
if (!wrap || !body || !lines.length) return;
|
||||
|
||||
if (title) title.textContent = "Состав рациона";
|
||||
if (head) {
|
||||
head.innerHTML = `<tr>
|
||||
<th>Корм</th><th>кг/сут</th><th>руб.</th><th>Реагент WESP</th>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
const meta = parseResult.meta || {};
|
||||
const extra = [
|
||||
meta.group ? escapeHtml(meta.group) : "",
|
||||
meta.rationDate ? escapeHtml(meta.rationDate) : "",
|
||||
meta.totalCostRub ? `Σ ${escapeHtml(meta.totalCostRub)} руб.` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
renderMetaLine(` · ${lines.length} корм(ов)${extra ? " · " + extra : ""}`);
|
||||
|
||||
body.innerHTML = lines
|
||||
.map((line, idx) => {
|
||||
const defaultId = line.suggestedComponents?.[0]?.componentId || "";
|
||||
return `<tr>
|
||||
<td>${escapeHtml(line.feedName)}</td>
|
||||
<td>${escapeHtml(line.dailyKg)}</td>
|
||||
<td>${escapeHtml(line.costRub)}</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm lab-agrostar-component lab-agrostar-component--readonly" data-feed-idx="${idx}" disabled>
|
||||
${buildComponentOptions(line, defaultId)}
|
||||
</select>
|
||||
</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
showEl(wrap);
|
||||
setActionButtons();
|
||||
}
|
||||
|
||||
function renderRationIndicatorsTable() {
|
||||
const wrap = $("labAgrostarTableWrap");
|
||||
const body = $("labAgrostarTableBody");
|
||||
const head = $("labImportTableHead");
|
||||
const title = $("labImportTableTitle");
|
||||
const rows = parseResult?.indicators || [];
|
||||
if (!wrap || !body || !rows.length) return;
|
||||
|
||||
if (title) title.textContent = "Показатели рациона";
|
||||
if (head) {
|
||||
head.innerHTML = `<tr>
|
||||
<th>Показатель</th><th>Норма</th><th>Текущий</th><th>Δ</th>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
const meta = parseResult.meta || {};
|
||||
renderMetaLine(
|
||||
` · ${rows.length} показат.${meta.group ? " · " + escapeHtml(meta.group) : ""}${meta.calcDate ? " · " + escapeHtml(meta.calcDate) : ""}`,
|
||||
);
|
||||
|
||||
body.innerHTML = rows
|
||||
.map((row) => {
|
||||
const delta =
|
||||
row.norm != null && row.current != null ? (row.current - row.norm).toFixed(2) : "—";
|
||||
return `<tr>
|
||||
<td>${escapeHtml(row.name)}</td>
|
||||
<td>${row.norm != null ? escapeHtml(row.norm) : "—"}</td>
|
||||
<td>${row.current != null ? escapeHtml(row.current) : "—"}</td>
|
||||
<td>${escapeHtml(delta)}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
showEl(wrap);
|
||||
setActionButtons();
|
||||
}
|
||||
|
||||
function renderAfterParse() {
|
||||
hideSniffPreview();
|
||||
hideEl($("labAgrostarTableWrap"));
|
||||
const kind = importKind();
|
||||
if (kind === "lab_samples") renderLabSamplesTable();
|
||||
else if (kind === "ration_composition") renderRationCompositionTable();
|
||||
else if (kind === "ration_indicators") renderRationIndicatorsTable();
|
||||
}
|
||||
|
||||
function renderSniffPreview() {
|
||||
const panel = $("labAgrostarSniff");
|
||||
const body = $("labAgrostarSniffBody");
|
||||
if (!panel || !body || !parseResult) {
|
||||
setResult("Нечего нюхать — сначала кинь файл", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = importKind();
|
||||
if (kind === "ration_composition") {
|
||||
renderSniffRationComposition(panel, body);
|
||||
return;
|
||||
}
|
||||
if (kind === "ration_indicators") {
|
||||
renderSniffRationIndicators(panel, body);
|
||||
return;
|
||||
}
|
||||
renderSniffLabSamples(panel, body);
|
||||
}
|
||||
|
||||
function renderSniffRationComposition(panel, body) {
|
||||
const lines = parseResult.feedLines || [];
|
||||
const meta = parseResult.meta || {};
|
||||
const metaRows = Object.entries(meta)
|
||||
.map(([k, v]) => `<tr><th>${escapeHtml(k)}</th><td>${escapeHtml(v)}</td></tr>`)
|
||||
.join("");
|
||||
body.innerHTML = `<article class="lab-agrostar-sniff-card">
|
||||
<header class="lab-agrostar-sniff-card__head">
|
||||
<h3 class="h6 mb-0">ПЛИНОР — состав рациона</h3>
|
||||
<span class="badge text-bg-secondary">${lines.length} корм(ов)</span>
|
||||
</header>
|
||||
<p class="small text-muted">Превью · запись рациона в WESP — отдельным шагом</p>
|
||||
<table class="table table-sm lab-agrostar-sniff-nutrients mb-2">
|
||||
<thead><tr><th>Корм</th><th class="text-end">кг</th><th class="text-end">руб.</th></tr></thead>
|
||||
<tbody>${lines
|
||||
.map(
|
||||
(l) =>
|
||||
`<tr><td>${escapeHtml(l.feedName)}</td><td class="text-end">${escapeHtml(l.dailyKg)}</td><td class="text-end">${escapeHtml(l.costRub)}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody>
|
||||
</table>
|
||||
${metaRows ? `<table class="table table-sm lab-agrostar-sniff-meta mb-0"><tbody>${metaRows}</tbody></table>` : ""}
|
||||
</article>`;
|
||||
showEl(panel);
|
||||
setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${lines.length} строк`, false);
|
||||
}
|
||||
|
||||
function renderSniffRationIndicators(panel, body) {
|
||||
const rows = parseResult.indicators || [];
|
||||
body.innerHTML = `<article class="lab-agrostar-sniff-card">
|
||||
<header class="lab-agrostar-sniff-card__head">
|
||||
<h3 class="h6 mb-0">ПЛИНОР — зоопоказатели</h3>
|
||||
<span class="badge text-bg-secondary">${rows.length} показат.</span>
|
||||
</header>
|
||||
<table class="table table-sm lab-agrostar-sniff-nutrients mb-0">
|
||||
<thead><tr><th>Показатель</th><th class="text-end">Норма</th><th class="text-end">Текущий</th></tr></thead>
|
||||
<tbody>${rows
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr><td>${escapeHtml(r.name)}</td><td class="text-end">${escapeHtml(r.norm)}</td><td class="text-end fw-semibold">${escapeHtml(r.current)}</td></tr>`,
|
||||
)
|
||||
.join("")}</tbody>
|
||||
</table>
|
||||
</article>`;
|
||||
showEl(panel);
|
||||
setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${rows.length} показат.`, false);
|
||||
}
|
||||
|
||||
function renderSniffLabSamples(panel, body) {
|
||||
if (!parseResult?.samples?.length) {
|
||||
setResult("Нечего нюхать — нет проб", true);
|
||||
return;
|
||||
}
|
||||
|
||||
const labName = parseResult.labName || parseResult.sourceLabel || "AgroStar";
|
||||
const cards = parseResult.samples.map((sample) => {
|
||||
const preview = sample.preview;
|
||||
if (!preview) return "";
|
||||
|
||||
const selectedName = getSelectedComponentLabel(sample.sampleNo);
|
||||
const targetLine = selectedName
|
||||
? `<p class="lab-agrostar-sniff__target mb-2"><span class="text-muted">Куда запишем:</span> <strong>${escapeHtml(selectedName)}</strong></p>`
|
||||
: `<p class="lab-agrostar-sniff__target lab-agrostar-sniff__target--warn mb-2">Реагент не выбран — «+ добавить новый реагент»</p>`;
|
||||
|
||||
const metaRows = (preview.meta || [])
|
||||
.map((row) => `<tr><th scope="row">${escapeHtml(row.label)}</th><td>${escapeHtml(row.value)}</td></tr>`)
|
||||
.join("");
|
||||
const nutrientRows = (preview.willWrite || [])
|
||||
.map((row) => {
|
||||
const sourceCell =
|
||||
row.sourceValue != null
|
||||
? `<td class="text-end">${escapeHtml(row.sourceValue)} <span class="text-muted small">${escapeHtml(row.sourceUnit || "")}</span></td>`
|
||||
: `<td class="text-end text-muted">—</td>`;
|
||||
return `<tr><td>${escapeHtml(row.label)}</td>${sourceCell}<td class="text-end fw-semibold">${escapeHtml(row.value)}</td><td class="text-muted small">${escapeHtml(row.unit)}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
const notes = (preview.notes || []).map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||
const notesBlock = notes
|
||||
? `<div class="lab-agrostar-sniff__notes mt-3"><div class="small text-muted mb-1">Замечания</div><ul class="small mb-0">${notes}</ul></div>`
|
||||
: "";
|
||||
const storage = preview.storage || {};
|
||||
const skipNote = formatUnsupportedNotice(storage);
|
||||
const storageBlock = skipNote
|
||||
? `<p class="small lab-agrostar-sniff__skip mt-3 mb-0">${escapeHtml(skipNote)}</p>`
|
||||
: `<p class="small text-muted mt-3 mb-0">В каталог: СВ + ${storage.nutrientCount || 0} показат.</p>`;
|
||||
|
||||
return `<article class="lab-agrostar-sniff-card">
|
||||
<header class="lab-agrostar-sniff-card__head">
|
||||
<h3 class="h6 mb-0">${escapeHtml(preview.title || sample.label)}</h3>
|
||||
<span class="badge text-bg-secondary">${escapeHtml(preview.feedTypeRu || "—")}</span>
|
||||
</header>
|
||||
${targetLine}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6"><div class="small text-muted mb-1">Из файла</div>
|
||||
<table class="table table-sm lab-agrostar-sniff-meta mb-0"><tbody>${metaRows}</tbody></table>
|
||||
</div>
|
||||
<div class="col-md-6"><div class="small text-muted mb-1">WESP · ${preview.recognizedCount || 0} показат. · % из документа → г/кг СВ (×10)</div>
|
||||
<table class="table table-sm lab-agrostar-sniff-nutrients mb-0">
|
||||
<thead><tr><th>Показатель</th><th class="text-end">Документ</th><th class="text-end">WESP</th><th></th></tr></thead>
|
||||
<tbody>${nutrientRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
${notesBlock}${storageBlock}
|
||||
</article>`;
|
||||
});
|
||||
|
||||
body.innerHTML = cards.join("");
|
||||
showEl(panel);
|
||||
setResult(`Понюхали · ${labName} · ${parseResult.samples.length} образц(ов)`, false);
|
||||
}
|
||||
|
||||
function hideSniffPreview() {
|
||||
const panel = $("labAgrostarSniff");
|
||||
const body = $("labAgrostarSniffBody");
|
||||
hideEl(panel);
|
||||
if (body) body.innerHTML = "";
|
||||
}
|
||||
|
||||
function collectAssignments() {
|
||||
if (!parseResult?.samples?.length) return [];
|
||||
const byNo = new Map(parseResult.samples.map((s) => [s.sampleNo, s]));
|
||||
const assignments = [];
|
||||
document.querySelectorAll(".lab-agrostar-component[data-sample-no]").forEach((sel) => {
|
||||
const sampleNo = sel.getAttribute("data-sample-no") || "";
|
||||
const sample = byNo.get(sampleNo);
|
||||
if (!sample) return;
|
||||
const componentId = sel.value && sel.value !== CREATE_NEW ? sel.value : null;
|
||||
assignments.push({
|
||||
sampleNo,
|
||||
componentId,
|
||||
dryMatterPct: sample.dryMatterPct,
|
||||
nutrients: sample.nutrients || {},
|
||||
warnings: sample.warnings || [],
|
||||
});
|
||||
});
|
||||
return assignments;
|
||||
}
|
||||
|
||||
function fileAllowed(name) {
|
||||
const lower = (name || "").toLowerCase();
|
||||
return ACCEPT_EXT.some((ext) => lower.endsWith(ext));
|
||||
}
|
||||
|
||||
async function uploadFile(file) {
|
||||
if (!file) return;
|
||||
if (!fileAllowed(file.name)) {
|
||||
setStatus("Формат не тот — xml, pdf или xlsx", true);
|
||||
return;
|
||||
}
|
||||
selectedFile = file;
|
||||
const fileNameEl = $("labAgrostarFileName");
|
||||
if (fileNameEl) {
|
||||
showEl(fileNameEl);
|
||||
fileNameEl.textContent = file.name;
|
||||
}
|
||||
setStatus("Разбираем поставку…");
|
||||
setResult("");
|
||||
hideSniffPreview();
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
parseResult = await api("/api/lab/import/parse", { method: "POST", body: fd });
|
||||
await loadComponents();
|
||||
renderAfterParse();
|
||||
const kind = importKind();
|
||||
if (kind === "lab_samples") {
|
||||
setStatus(`На столе: ${parseResult.sampleCount || 0} образц(ов) · ${parseResult.sourceLabel || ""}`);
|
||||
} else if (kind === "ration_composition") {
|
||||
setStatus(`Рацион: ${parseResult.feedCount || 0} корм(ов) · ${parseResult.sourceLabel || ""}`);
|
||||
} else {
|
||||
setStatus(`Показатели: ${parseResult.indicatorCount || 0} · ${parseResult.sourceLabel || ""}`);
|
||||
}
|
||||
} catch (err) {
|
||||
parseResult = null;
|
||||
hideEl($("labAgrostarTableWrap"));
|
||||
hideEl($("labAgrostarMeta"));
|
||||
setStatus(err.message || "Файл не разобрался", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function runApply() {
|
||||
if (!isLabSamples()) {
|
||||
setResult("Запись в каталог — только для лабораторных проб AgroStar", true);
|
||||
return;
|
||||
}
|
||||
const assignments = collectAssignments();
|
||||
if (!assignments.length) {
|
||||
setResult("Нечего готовить — сначала кинь файл", true);
|
||||
return;
|
||||
}
|
||||
const missing = assignments.filter((a) => !a.componentId);
|
||||
if (missing.length) {
|
||||
setResult(`Укажи реагент для ${missing.length} образц(ов) — Jesse бы не забыл`, true);
|
||||
return;
|
||||
}
|
||||
setResult("Пишем в каталог…");
|
||||
try {
|
||||
const data = await api("/api/lab/import/agrostar-xml/apply", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ assignments, dryRun: false }),
|
||||
});
|
||||
const lines = (data.results || []).map(
|
||||
(r) => `${r.sampleNo}: ${r.skipped ? "мимо" : r.componentName || "—"} (${r.nutrientCount} показат.)`,
|
||||
);
|
||||
setResult(
|
||||
`Запомнили · в деле ${data.applied}, мимо ${data.skipped}` +
|
||||
(lines.length ? "\n" + lines.join("\n") : ""),
|
||||
false,
|
||||
);
|
||||
notifyCatalogChanged();
|
||||
} catch (err) {
|
||||
setResult(err.message || "Наука не сработала — импорт сгорел", true);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
parseResult = null;
|
||||
selectedFile = null;
|
||||
const input = $("labAgrostarFile");
|
||||
if (input) input.value = "";
|
||||
const fileNameEl = $("labAgrostarFileName");
|
||||
if (fileNameEl) {
|
||||
hideEl(fileNameEl);
|
||||
fileNameEl.textContent = "";
|
||||
}
|
||||
hideEl($("labAgrostarTableWrap"));
|
||||
hideEl($("labAgrostarMeta"));
|
||||
hideSniffPreview();
|
||||
$("labAgrostarPreview").disabled = true;
|
||||
$("labAgrostarApply").disabled = true;
|
||||
$("labAgrostarClear").disabled = true;
|
||||
setStatus("");
|
||||
setResult("");
|
||||
}
|
||||
|
||||
function bindDropZone() {
|
||||
const drop = $("labAgrostarDrop");
|
||||
const input = $("labAgrostarFile");
|
||||
if (!drop || !input) return;
|
||||
|
||||
drop.addEventListener("click", () => input.click());
|
||||
drop.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
input.click();
|
||||
}
|
||||
});
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) uploadFile(file);
|
||||
});
|
||||
|
||||
["dragenter", "dragover"].forEach((ev) => {
|
||||
drop.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drop.classList.add("lab-agrostar-drop--active");
|
||||
});
|
||||
});
|
||||
["dragleave", "drop"].forEach((ev) => {
|
||||
drop.addEventListener(ev, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
drop.classList.remove("lab-agrostar-drop--active");
|
||||
});
|
||||
});
|
||||
drop.addEventListener("drop", (e) => {
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file) uploadFile(file);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!$("labAgrostarDrop")) return;
|
||||
bindDropZone();
|
||||
$("labAgrostarTableWrap")?.addEventListener("change", onComponentSelectChange);
|
||||
$("labAgrostarPreview")?.addEventListener("click", renderSniffPreview);
|
||||
$("labAgrostarApply")?.addEventListener("click", runApply);
|
||||
$("labAgrostarClear")?.addEventListener("click", clearAll);
|
||||
loadComponents().catch(() => {});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(window);
|
||||
@@ -0,0 +1,104 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function openLabAnimalProfilesModal() {
|
||||
const resp = await fetch("/api/lab/animal-profiles");
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(data.message || "Ошибка загрузки профилей");
|
||||
return;
|
||||
}
|
||||
const profiles = data.profiles || [];
|
||||
const options = profiles
|
||||
.map(
|
||||
(p) =>
|
||||
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const html =
|
||||
`<div class="lab-profiles-editor">` +
|
||||
`<p class="small text-muted mb-2">Профили норм стада (зоотех). Выберите для просмотра или создайте новый.</p>` +
|
||||
`<label class="form-label">Существующие</label>` +
|
||||
`<select class="form-select mb-2" id="labProfilePick"><option value="">—</option>${options}</select>` +
|
||||
`<div id="labProfileView" class="small mb-3 text-muted" hidden></div>` +
|
||||
`<hr>` +
|
||||
`<label class="form-label">Новый / редактирование</label>` +
|
||||
`<input class="form-control form-control-sm mb-1" id="labProfileKey" placeholder="Ключ (beef_grow)">` +
|
||||
`<input class="form-control form-control-sm mb-1" id="labProfileLabel" placeholder="Название">` +
|
||||
`<select class="form-select form-select-sm mb-1" id="labProfileType">` +
|
||||
`<option value="BEEF">Мясное</option><option value="DAIRY">Дойное</option></select>` +
|
||||
`<textarea class="form-control form-control-sm" id="labProfileNorms" rows="4" placeholder='{"dry_matter":{"min":4000,"max":5000}}'></textarea>` +
|
||||
`</div>`;
|
||||
|
||||
const ok = await global.WespDialog?.confirm?.(html, {
|
||||
title: "Профили норм стада",
|
||||
html: true,
|
||||
confirmText: "Сохранить",
|
||||
cancelText: "Закрыть",
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
const pick = document.getElementById("labProfilePick")?.value;
|
||||
const key = document.getElementById("labProfileKey")?.value?.trim();
|
||||
const label = document.getElementById("labProfileLabel")?.value?.trim();
|
||||
const rationType = document.getElementById("labProfileType")?.value || "BEEF";
|
||||
let normsData = {};
|
||||
try {
|
||||
const raw = document.getElementById("labProfileNorms")?.value?.trim();
|
||||
if (raw) normsData = JSON.parse(raw);
|
||||
} catch (_) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.("Некорректный JSON норм");
|
||||
return;
|
||||
}
|
||||
if (!key || !label) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.("Укажите ключ и название");
|
||||
return;
|
||||
}
|
||||
|
||||
const method = pick ? "PUT" : "POST";
|
||||
const url = pick
|
||||
? `/api/lab/animal-profiles/${encodeURIComponent(pick)}`
|
||||
: "/api/lab/animal-profiles";
|
||||
try {
|
||||
const saveResp = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profileKey: key, label, rationType, normsData }),
|
||||
});
|
||||
const saveData = await saveResp.json();
|
||||
if (!saveResp.ok) throw new Error(saveData.message || "Ошибка сохранения");
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Профиль сохранён");
|
||||
} catch (e) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось сохранить профиль" });
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("change", (ev) => {
|
||||
if (ev.target?.id !== "labProfilePick") return;
|
||||
const id = ev.target.value;
|
||||
const view = document.getElementById("labProfileView");
|
||||
if (!id || !view) return;
|
||||
fetch(`/api/lab/animal-profiles/${encodeURIComponent(id)}`)
|
||||
.then((r) => r.json())
|
||||
.then((p) => {
|
||||
document.getElementById("labProfileKey").value = p.profileKey || "";
|
||||
document.getElementById("labProfileLabel").value = p.label || "";
|
||||
document.getElementById("labProfileType").value = p.rationType || "BEEF";
|
||||
document.getElementById("labProfileNorms").value = JSON.stringify(p.normsData || {}, null, 2);
|
||||
view.hidden = false;
|
||||
view.textContent = `${p.profileKey} · ${p.rationType}`;
|
||||
})
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
global.openLabAnimalProfilesModal = openLabAnimalProfilesModal;
|
||||
})(window);
|
||||
@@ -0,0 +1,330 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
/** Показатели для зоотех-расчёта рациона (weighted average). */
|
||||
const NUTRIENT_ROWS = [
|
||||
{
|
||||
label: "Осн. корм",
|
||||
unit: "г/кг СВ",
|
||||
keys: ["Осн.Корм"],
|
||||
hint: "СВ этого сырья, учитываемое как объёмный (основной) корм в норме dm_main. 0 — концентрат/добавка.",
|
||||
},
|
||||
{ label: "Сыр. протеин", unit: "г/кг", keys: ["Сыр. Протеин"] },
|
||||
{ label: "уСП", unit: "г/кг", keys: ["уСП"] },
|
||||
{ label: "RNB", unit: "г/кг", keys: ["RNB", "БРА", " БРА "] },
|
||||
{ label: "ЧЭЛ — КРС", unit: "МДж/кг", keys: ["ЧЭЛ- КРС", " ЧЭЛ- КРС"] },
|
||||
{ label: "ОЭ — КРС", unit: "МДж/кг", keys: ["ОЭ-КРС", " ОЭ-КРС"] },
|
||||
{ label: "Сырая клетчатка", unit: "г/кг", keys: ["Сырая клетч", "Сырая клетчатка"] },
|
||||
{ label: "Структур. клетч.", unit: "г/кг", keys: ["Структур клетч", "Структур. клетч", "Структур. клетчатка"] },
|
||||
{ label: "Сырой жир", unit: "г/кг", keys: ["Сырой жир"] },
|
||||
];
|
||||
|
||||
/** Лаб. ввод для native derive (см. LAB_FORMULAS.md). СВ — только component.dry_matter (%). */
|
||||
const LAB_INPUT_ROWS = [
|
||||
{ label: "Сырая зола", unit: "г/кг", keys: ["Сырая зола"] },
|
||||
{
|
||||
label: "ВРХ орг. вещ.",
|
||||
unit: "%",
|
||||
keys: ["ВРХ Орг Вещ"],
|
||||
hint: "Обязательно для грубых и сочных кормов. Без значения подставится дефолт WESP (65% / 72%).",
|
||||
},
|
||||
{
|
||||
label: "уСП в ОР",
|
||||
unit: "г/кг",
|
||||
keys: ["уСП в ОР", "уСП в ОВ"],
|
||||
hint: "Лабораторное уСП: при значении > 1 заменяет расчёт по формуле Lebzien.",
|
||||
},
|
||||
{ label: "КРС протеин", unit: "%", keys: ["КРС Протеин"] },
|
||||
{ label: "КРС сырой жир", unit: "%", keys: ["КРС Сырой жир"] },
|
||||
{ label: "КРС сырая клетч.", unit: "%", keys: ["КРС Сырая клетч"] },
|
||||
{ label: "КРС БЭВ", unit: "%", keys: ["КРС БЭВ"] },
|
||||
{ label: "НДК", unit: "г/кг", keys: ["НДК", "НДК Общ"] },
|
||||
{ label: "NFC", unit: "г/кг", keys: ["NFC", "БЕР"] },
|
||||
];
|
||||
|
||||
/** Минералы, аминокислоты и прочий лаб. ввод из AgroStar / импорта. */
|
||||
const EXTRA_LAB_ROWS = [
|
||||
{ label: "КДК (ADF)", unit: "г/кг", keys: ["КДК", "КДК общ"] },
|
||||
{ label: "Ca", unit: "г/кг", keys: ["Ca"] },
|
||||
{ label: "P", unit: "г/кг", keys: ["P"] },
|
||||
{ label: "Mg", unit: "г/кг", keys: ["Mg"] },
|
||||
{ label: "K", unit: "г/кг", keys: ["K"] },
|
||||
{ label: "S", unit: "г/кг", keys: ["S"] },
|
||||
{ label: "Cl", unit: "г/кг", keys: ["CL", "Cl"] },
|
||||
{ label: "Сахар", unit: "г/кг", keys: ["Сахар"] },
|
||||
{ label: "Крахмал", unit: "г/кг", keys: ["Крахмал"] },
|
||||
{ label: "Нераств. протеин", unit: "%", keys: ["% нераствор протеин"] },
|
||||
{ label: "Лизин", unit: "г/кг", keys: ["Лизин"] },
|
||||
{ label: "Метионин", unit: "г/кг", keys: ["Метионин"] },
|
||||
{ label: "Лейцин", unit: "г/кг", keys: ["Лейцин"] },
|
||||
{ label: "Изолейцин", unit: "г/кг", keys: ["Изолейцин"] },
|
||||
{ label: "Валин", unit: "г/кг", keys: ["Валин"] },
|
||||
];
|
||||
|
||||
const ALL_FORM_ROWS = [...NUTRIENT_ROWS, ...LAB_INPUT_ROWS, ...EXTRA_LAB_ROWS];
|
||||
|
||||
const CARD_INFO_COLUMNS = [
|
||||
{
|
||||
label: "Сухое вещество",
|
||||
value(component) {
|
||||
const dm = component.dryMatter ?? component.dry_matter;
|
||||
return dm == null || dm === "" ? "—" : `${dm}%`;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "№ справочника",
|
||||
value(component) {
|
||||
const n = component.externalNo ?? component.external_no;
|
||||
return n == null || n === "" ? "—" : String(n);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Цена",
|
||||
value(component) {
|
||||
const price = component.price;
|
||||
return price == null || price === "" ? "—" : `${price} руб/кг`;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Осн. корм",
|
||||
value(_component, nutrients) {
|
||||
const val = readNutrientValue(nutrients, ["Осн.Корм"]);
|
||||
return val == null ? "—" : `${val} г/кг`;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Сыр. протеин",
|
||||
value(_component, nutrients) {
|
||||
const val = readNutrientValue(nutrients, ["Сыр. Протеин"]);
|
||||
return val == null ? "—" : `${val} г/кг`;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "ОЭ — КРС",
|
||||
value(_component, nutrients) {
|
||||
const val = readNutrientValue(nutrients, ["ОЭ-КРС", " ОЭ-КРС"]);
|
||||
return val == null ? "—" : `${val} МДж/кг`;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "СВ (расч.)",
|
||||
value(component) {
|
||||
const dm = component.dryMatter ?? component.dry_matter;
|
||||
if (dm == null || dm === "") return "—";
|
||||
const n = Number(dm);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return `${(n * 10).toFixed(1)} г/кг`;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Показателей",
|
||||
value(_component, nutrients) {
|
||||
return String(Object.keys(nutrients || {}).length);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeKey(s) {
|
||||
return String(s || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function parseNutrients(raw) {
|
||||
if (!raw) return {};
|
||||
if (typeof raw === "object") return { ...raw };
|
||||
try {
|
||||
return JSON.parse(raw) || {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readNutrientValue(nutrients, keys) {
|
||||
const map = nutrients || {};
|
||||
for (const search of keys) {
|
||||
const target = normalizeKey(search);
|
||||
for (const [k, v] of Object.entries(map)) {
|
||||
if (normalizeKey(k) !== target) continue;
|
||||
const n = Number(v);
|
||||
if (!Number.isNaN(n)) return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function canonicalKey(keys) {
|
||||
return keys[0];
|
||||
}
|
||||
|
||||
function buildNutrientsPayload(root) {
|
||||
const payload = {};
|
||||
if (!root) return payload;
|
||||
root.querySelectorAll("[data-nutrient-key]").forEach((input) => {
|
||||
const key = input.getAttribute("data-nutrient-key");
|
||||
const val = input.value.trim();
|
||||
if (key && val !== "") payload[key] = Number(val);
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
function rowFieldsHtml(rows, nutrients) {
|
||||
return rows
|
||||
.map((row) => {
|
||||
const key = canonicalKey(row.keys);
|
||||
const val = readNutrientValue(nutrients, row.keys);
|
||||
const hint = row.hint ? ` title="${row.hint}"` : "";
|
||||
return (
|
||||
`<div class="lab-nutrient-field"${hint}>` +
|
||||
`<label class="form-label small mb-0 lab-nutrient-field__label">${row.label}` +
|
||||
`<span class="text-muted"> (${row.unit})</span></label>` +
|
||||
`<input type="number" step="0.01" class="form-control form-control-sm" ` +
|
||||
`data-nutrient-key="${key}" value="${val == null ? "" : val}" placeholder="—">` +
|
||||
`</div>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
const _OMD_FEED_TYPES = new Set(["грубые корма", "сочные корма", "объемные корма", "объёмные корма"]);
|
||||
|
||||
function feedGroupNeedsOmd(component) {
|
||||
if (!component || !component.type) return false;
|
||||
return _OMD_FEED_TYPES.has(normalizeKey(component.type));
|
||||
}
|
||||
|
||||
function omdWarningHtml(component, nutrients) {
|
||||
if (!feedGroupNeedsOmd(component)) return "";
|
||||
if (readNutrientValue(nutrients, ["ВРХ Орг Вещ", "КРС Орг Вещ"]) != null) return "";
|
||||
return (
|
||||
`<div class="alert alert-warning py-2 px-3 small mb-2" role="status">` +
|
||||
`Для грубых и сочных кормов укажите <strong>ВРХ орг. вещ.</strong> — ` +
|
||||
`без анализа расчёт энергии и уСП будет ненадёжным.` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function nutrientsFormHtml(nutrients, component) {
|
||||
const calcRows = rowFieldsHtml(NUTRIENT_ROWS, nutrients);
|
||||
const labRows = rowFieldsHtml(LAB_INPUT_ROWS, nutrients);
|
||||
const extraRows = rowFieldsHtml(EXTRA_LAB_ROWS, nutrients);
|
||||
const warn = omdWarningHtml(component, nutrients);
|
||||
return (
|
||||
warn +
|
||||
`<div class="lab-nutrients-block" data-lab-nutrients-block>` +
|
||||
`<div class="lab-nutrients-block__head">` +
|
||||
`<i class="fas fa-flask text-primary me-1"></i>` +
|
||||
`<span class="lab-nutrients-block__title">Показатели для расчёта рациона</span>` +
|
||||
`</div>` +
|
||||
`<div class="lab-nutrients-row lab-nutrients-row--calc">${calcRows}</div>` +
|
||||
`</div>` +
|
||||
`<div class="lab-nutrients-block lab-nutrients-block--lab-input mt-3" data-lab-nutrients-lab>` +
|
||||
`<div class="lab-nutrients-block__head">` +
|
||||
`<i class="fas fa-vial text-secondary me-1"></i>` +
|
||||
`<span class="lab-nutrients-block__title">Лабораторный ввод (derive)</span>` +
|
||||
`</div>` +
|
||||
`<div class="lab-nutrients-row lab-nutrients-row--lab">${labRows}</div>` +
|
||||
`</div>` +
|
||||
`<div class="lab-nutrients-block lab-nutrients-block--extra mt-3" data-lab-nutrients-extra>` +
|
||||
`<div class="lab-nutrients-block__head">` +
|
||||
`<i class="fas fa-atom text-secondary me-1"></i>` +
|
||||
`<span class="lab-nutrients-block__title">Минералы и аминокислоты</span>` +
|
||||
`</div>` +
|
||||
`<div class="lab-nutrients-row lab-nutrients-row--extra">${extraRows}</div>` +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function mountCreateForm() {
|
||||
const mount = document.getElementById("labNutrientsCreateMount");
|
||||
if (!mount) return;
|
||||
mount.innerHTML = nutrientsFormHtml({});
|
||||
mount.dataset.mounted = "1";
|
||||
}
|
||||
|
||||
function mountEditForm() {
|
||||
const mount = document.getElementById("labNutrientsEditMount");
|
||||
if (!mount) return;
|
||||
mount.innerHTML = nutrientsFormHtml({});
|
||||
mount.dataset.mounted = "1";
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
const mount = document.getElementById("labNutrientsCreateMount");
|
||||
if (!mount) return;
|
||||
mount.innerHTML = nutrientsFormHtml({});
|
||||
mount.dataset.mounted = "1";
|
||||
}
|
||||
|
||||
function fillCreateFromComponent(component) {
|
||||
const mount = document.getElementById("labNutrientsCreateMount");
|
||||
if (!mount || !component) return;
|
||||
const nutrients = parseNutrients(component.nutrients);
|
||||
mount.innerHTML = nutrientsFormHtml(nutrients, component);
|
||||
mount.dataset.mounted = "1";
|
||||
}
|
||||
|
||||
function fillEditNutrients(component) {
|
||||
const mount = document.getElementById("labNutrientsEditMount");
|
||||
if (!mount) return;
|
||||
mount.innerHTML = nutrientsFormHtml(parseNutrients(component.nutrients), component);
|
||||
mount.dataset.mounted = "1";
|
||||
}
|
||||
|
||||
function formatCardItems(component) {
|
||||
const nutrients = parseNutrients(component.nutrients);
|
||||
return CARD_INFO_COLUMNS.map((col) => {
|
||||
const value = col.value(component, nutrients);
|
||||
return {
|
||||
label: col.label,
|
||||
value,
|
||||
empty: value === "—",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderCardInfoHtml(component) {
|
||||
if (!global.WESP_CAN_LAB) return "";
|
||||
return formatCardItems(component)
|
||||
.map(
|
||||
(item) =>
|
||||
`<div class="info-item${item.empty ? " info-item--empty" : ""}">` +
|
||||
`<span class="info-label">${item.label}</span>` +
|
||||
`<span class="info-value">${item.value}</span>` +
|
||||
`</div>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function collectFromCreateForm() {
|
||||
return buildNutrientsPayload(document.getElementById("labNutrientsCreateMount"));
|
||||
}
|
||||
|
||||
function collectFromEditForm() {
|
||||
return buildNutrientsPayload(document.getElementById("labNutrientsEditMount"));
|
||||
}
|
||||
|
||||
global.WespLabComponentsNutrients = {
|
||||
NUTRIENT_ROWS,
|
||||
LAB_INPUT_ROWS,
|
||||
EXTRA_LAB_ROWS,
|
||||
normalizeKey,
|
||||
readNutrientValue,
|
||||
parseNutrients,
|
||||
nutrientsFormHtml,
|
||||
mountCreateForm,
|
||||
mountEditForm,
|
||||
resetCreateForm,
|
||||
fillCreateFromComponent,
|
||||
fillEditNutrients,
|
||||
renderCardInfoHtml,
|
||||
collectFromCreateForm,
|
||||
collectFromEditForm,
|
||||
init() {
|
||||
if (!global.WESP_CAN_LAB) return;
|
||||
mountCreateForm();
|
||||
mountEditForm();
|
||||
},
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,601 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_OPTIMIZE_KEYS = [
|
||||
"dry_matter",
|
||||
"usp",
|
||||
"nel",
|
||||
"crude_protein",
|
||||
"rnb",
|
||||
"nfc_pct_dm_uk",
|
||||
];
|
||||
|
||||
let catalog = [];
|
||||
let groups = [];
|
||||
let profiles = [];
|
||||
let currentStep = 1;
|
||||
const selections = {};
|
||||
|
||||
function formulateError(message) {
|
||||
global.WespLabHeisenbergPrime?.error?.(message, "formulate");
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const resp = await fetch(path, opts);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatNum(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function diffClass(diff) {
|
||||
if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na";
|
||||
const d = Number(diff);
|
||||
if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok";
|
||||
return "lab-sandbox-diff--bad";
|
||||
}
|
||||
|
||||
function totalSteps() {
|
||||
return groups.length + 1;
|
||||
}
|
||||
|
||||
function groupById(id) {
|
||||
return groups.find((g) => g.id === id);
|
||||
}
|
||||
|
||||
function selectedForGroup(gid) {
|
||||
return Array.from(document.querySelectorAll(`.lab-formulate-gcb[data-group="${gid}"]:checked`)).map(
|
||||
(el) => el.value
|
||||
);
|
||||
}
|
||||
|
||||
function allSelections() {
|
||||
const out = {};
|
||||
groups.forEach((g) => {
|
||||
out[g.id] = selectedForGroup(g.id);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function totalPicked() {
|
||||
return Object.values(allSelections()).reduce((n, arr) => n + arr.length, 0);
|
||||
}
|
||||
|
||||
function componentsForGroup(gid, filterText) {
|
||||
const q = (filterText || "").trim().toLowerCase();
|
||||
return catalog.filter((c) => {
|
||||
if (!c.eligible || c.feedGroup !== gid) return false;
|
||||
if (q && !String(c.name || "").toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderGroupTable(gid, filterText) {
|
||||
const body = document.querySelector(`#labFormulatePane-${gid} tbody`);
|
||||
if (!body) return;
|
||||
const picked = new Set(selectedForGroup(gid));
|
||||
const rows = componentsForGroup(gid, filterText);
|
||||
if (!rows.length) {
|
||||
body.innerHTML =
|
||||
'<tr><td colspan="4" class="lab-sandbox-empty">В этой фракции пусто — нечего готовить</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = rows
|
||||
.map((c) => {
|
||||
const checked = picked.has(c.id) ? " checked" : "";
|
||||
return (
|
||||
`<tr>` +
|
||||
`<td><input type="checkbox" class="form-check-input lab-formulate-gcb" data-group="${escapeHtml(gid)}" value="${escapeHtml(c.id)}"${checked}></td>` +
|
||||
`<td>${escapeHtml(c.name)}</td>` +
|
||||
`<td>${formatNum(c.mainFeedDmGPerKg)}</td>` +
|
||||
`<td>${formatNum(c.dryMatterPct)}%</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildWizard() {
|
||||
const stepsEl = document.getElementById("labFormulateSteps");
|
||||
const panesEl = document.getElementById("labFormulatePanes");
|
||||
if (!stepsEl || !panesEl) return;
|
||||
|
||||
stepsEl.innerHTML =
|
||||
groups
|
||||
.map(
|
||||
(g, i) =>
|
||||
`<button type="button" class="lab-formulate-step${i === 0 ? " lab-formulate-step--active" : ""}" data-step="${i + 1}">${i + 1}. ${escapeHtml(g.shortLabel)}</button>`
|
||||
)
|
||||
.join("") +
|
||||
`<button type="button" class="lab-formulate-step" data-step="${groups.length + 1}">${groups.length + 1}. Кристалл</button>`;
|
||||
|
||||
panesEl.innerHTML = groups
|
||||
.map((g) => {
|
||||
const req = g.required
|
||||
? `<span class="lab-formulate-badge lab-formulate-badge--ok">без этого никак</span>`
|
||||
: `<span class="lab-formulate-badge">не мешай</span>`;
|
||||
return (
|
||||
`<div id="labFormulatePane-${g.id}" class="lab-formulate-pane${g.step === 1 ? "" : " d-none"}" data-group="${g.id}">` +
|
||||
`<div class="d-flex justify-content-between align-items-center mb-2">` +
|
||||
`<div class="lab-sandbox-panel__title mb-0">${escapeHtml(g.label)}</div>${req}</div>` +
|
||||
`<p class="small text-muted mb-2">${escapeHtml(g.hint)}</p>` +
|
||||
`<p class="small mb-2 lab-formulate-group-count" data-group="${g.id}">В партии: 0</p>` +
|
||||
`<div class="lab-sandbox-field">` +
|
||||
`<input type="search" class="form-control form-control-sm lab-formulate-filter" data-group="${g.id}" placeholder="Найти реагент">` +
|
||||
`</div>` +
|
||||
`<div class="table-responsive lab-formulate-pool-wrap">` +
|
||||
`<table class="table table-sm lab-sandbox-table mb-0">` +
|
||||
`<thead><tr><th></th><th>Реагент</th><th>Осн.</th><th>СВ%</th></tr></thead>` +
|
||||
`<tbody></tbody></table></div>` +
|
||||
`<div class="lab-sandbox-actions mt-2">` +
|
||||
(g.step > 1
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary lab-formulate-back" data-step="${g.step}">← Откат</button>`
|
||||
: "") +
|
||||
(!g.required
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary lab-formulate-skip" data-step="${g.step}">Не мешай</button>`
|
||||
: "") +
|
||||
`<button type="button" class="btn btn-sm btn-primary lab-formulate-next" data-step="${g.step}">Вперёд →</button>` +
|
||||
`</div></div>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
groups.forEach((g) => renderGroupTable(g.id, ""));
|
||||
}
|
||||
|
||||
function updateCounters() {
|
||||
const sel = allSelections();
|
||||
groups.forEach((g) => {
|
||||
const el = document.querySelector(`.lab-formulate-group-count[data-group="${g.id}"]`);
|
||||
if (el) {
|
||||
const n = (sel[g.id] || []).length;
|
||||
el.textContent = g.required ? `В партии: ${n} (мин. ${g.minPick})` : `В партии: ${n}`;
|
||||
}
|
||||
});
|
||||
const total = totalPicked();
|
||||
const summaryEl = document.getElementById("labFormulateSummary");
|
||||
const prefilter = document.getElementById("labFormulatePrefilterNote");
|
||||
const roughOk = (sel.rough || []).length >= 1;
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = roughOk && total >= 3
|
||||
? `Партия из ${total} реагентов — можно готовить`
|
||||
: `Нужна база (≥1) и ≥3 реагента всего — сейчас ${total}. Нам нужно готовить.`;
|
||||
}
|
||||
if (prefilter) prefilter.classList.toggle("d-none", total <= 18);
|
||||
const runBtn = document.getElementById("labFormulateRun");
|
||||
if (runBtn) runBtn.disabled = !(roughOk && total >= 3 && currentStep === totalSteps());
|
||||
renderReview();
|
||||
}
|
||||
|
||||
function renderReview() {
|
||||
const el = document.getElementById("labFormulateReview");
|
||||
if (!el) return;
|
||||
const sel = allSelections();
|
||||
let html = "";
|
||||
groups.forEach((g) => {
|
||||
const ids = sel[g.id] || [];
|
||||
html += `<li><strong>${escapeHtml(g.shortLabel)}</strong> (${ids.length})</li>`;
|
||||
ids.forEach((id) => {
|
||||
const name = catalog.find((c) => c.id === id)?.name || id;
|
||||
html += `<li class="ms-3">${escapeHtml(name)}</li>`;
|
||||
});
|
||||
});
|
||||
el.innerHTML = html || "<li>—</li>";
|
||||
}
|
||||
|
||||
function setStep(step) {
|
||||
currentStep = step;
|
||||
const calcStep = totalSteps();
|
||||
groups.forEach((g) => {
|
||||
document.getElementById(`labFormulatePane-${g.id}`)?.classList.toggle("d-none", g.step !== step);
|
||||
});
|
||||
document.getElementById("labFormulateReviewPane")?.classList.toggle("d-none", step !== calcStep);
|
||||
document.querySelector(".lab-formulate-step-panel--calc")?.classList.toggle("d-none", step !== calcStep);
|
||||
document.querySelectorAll(".lab-formulate-step").forEach((btn) => {
|
||||
btn.classList.toggle("lab-formulate-step--active", Number(btn.dataset.step) === step);
|
||||
});
|
||||
updateCounters();
|
||||
}
|
||||
|
||||
function canGoToStep(step) {
|
||||
if (step <= 1) return true;
|
||||
const sel = allSelections();
|
||||
for (const g of groups) {
|
||||
if (g.step >= step) break;
|
||||
if (g.required && (sel[g.id] || []).length < (g.minPick || 1)) return false;
|
||||
}
|
||||
if (step === totalSteps()) {
|
||||
return (sel.rough || []).length >= 1 && totalPicked() >= 3;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readNumInput(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
const raw = String(el.value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function readNormsMethod() {
|
||||
return document.getElementById("labFormulateNormsMethod")?.value || "wesp";
|
||||
}
|
||||
|
||||
function readNormsParams() {
|
||||
const method = readNormsMethod();
|
||||
if (method === "wesp") return {};
|
||||
const params = {
|
||||
milkFatPct: readNumInput("labFormulateFat") ?? 4,
|
||||
lactationNo: readNumInput("labFormulateLactation") ?? 2,
|
||||
};
|
||||
if (method === "racion_piter") {
|
||||
params.koncOeSv = readNumInput("labFormulateKonc") ?? 10.3;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function syncNormsMethodUi() {
|
||||
const method = readNormsMethod();
|
||||
const racionBox = document.getElementById("labFormulateRacionFields");
|
||||
const piterOnly = document.querySelectorAll(".lab-formulate-piter-only");
|
||||
if (racionBox) racionBox.classList.toggle("d-none", method === "wesp");
|
||||
piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter"));
|
||||
}
|
||||
|
||||
function readFormulateParams() {
|
||||
return {
|
||||
profileId: document.getElementById("labFormulateProfile")?.value || "",
|
||||
massKg: readNumInput("labFormulateMass"),
|
||||
milkYieldKg: readNumInput("labFormulateMilk"),
|
||||
totalKgPerHead: readNumInput("labFormulateTotalKg") ?? 7.3,
|
||||
costWeight: readNumInput("labFormulateCostWeight") ?? 100,
|
||||
optimizeKeys: Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value),
|
||||
groupSelections: allSelections(),
|
||||
normsMethod: readNormsMethod(),
|
||||
normsParams: readNormsParams(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMassMilk(profile) {
|
||||
return {
|
||||
mass: readNumInput("labFormulateMass") ?? profile?.massKg ?? null,
|
||||
milk: readNumInput("labFormulateMilk") ?? profile?.milkYieldKg ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function normsForOptimizeDisplay(profile) {
|
||||
const method = readNormsMethod();
|
||||
let norms =
|
||||
method === "wesp"
|
||||
? { ...(profile?.norms || profile?.normsData?.indicators || {}) }
|
||||
: {};
|
||||
const { mass, milk } = resolveMassMilk(profile);
|
||||
if (method !== "wesp" && (mass == null || milk == null)) return norms;
|
||||
if (mass == null && milk == null) return norms;
|
||||
try {
|
||||
const q = new URLSearchParams({ method });
|
||||
const profileId = document.getElementById("labFormulateProfile")?.value;
|
||||
if (profileId) q.set("profile_id", profileId);
|
||||
if (mass != null) q.set("mass_kg", String(mass));
|
||||
if (milk != null) q.set("milk_yield_kg", String(milk));
|
||||
const np = readNormsParams();
|
||||
if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct));
|
||||
if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo));
|
||||
if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv));
|
||||
const preview = await api(`/api/lab/norms-preview?${q.toString()}`);
|
||||
const resolved = preview.resolvedIndicators || {};
|
||||
const dynamic = preview.dynamicNorms || {};
|
||||
const merged = { ...resolved };
|
||||
Object.keys(dynamic).forEach((key) => {
|
||||
const d = dynamic[key];
|
||||
if (d?.min != null) merged[key] = { ...(merged[key] || {}), min: d.min };
|
||||
});
|
||||
if (method === "wesp") {
|
||||
Object.assign(norms, merged);
|
||||
} else {
|
||||
norms = merged;
|
||||
}
|
||||
} catch (_err) {
|
||||
/* preview optional */
|
||||
}
|
||||
return norms;
|
||||
}
|
||||
|
||||
async function refreshOptimizeKeysForInputs() {
|
||||
const profileId = document.getElementById("labFormulateProfile")?.value;
|
||||
const profile = profiles.find((p) => String(p.id) === String(profileId));
|
||||
if (!profile) return;
|
||||
const norms = await normsForOptimizeDisplay(profile);
|
||||
renderOptimizeKeys({ ...profile, norms });
|
||||
}
|
||||
|
||||
let optimizeKeysRefreshTimer = null;
|
||||
function scheduleOptimizeKeysRefresh() {
|
||||
if (optimizeKeysRefreshTimer) clearTimeout(optimizeKeysRefreshTimer);
|
||||
optimizeKeysRefreshTimer = setTimeout(() => {
|
||||
optimizeKeysRefreshTimer = null;
|
||||
refreshOptimizeKeysForInputs().catch(() => {});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function profileOptions(selectedId) {
|
||||
const opts = ['<option value="">— стандарт чистоты —</option>'];
|
||||
profiles.forEach((p) => {
|
||||
const sel = String(p.id) === String(selectedId) ? " selected" : "";
|
||||
const key = p.profileKey ? `[${p.profileKey}] ` : "";
|
||||
opts.push(
|
||||
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(key)}${escapeHtml(p.label)}</option>`
|
||||
);
|
||||
});
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function fillProfileFields(profileId) {
|
||||
const profile = profiles.find((p) => String(p.id) === String(profileId));
|
||||
if (!profile) return;
|
||||
const massEl = document.getElementById("labFormulateMass");
|
||||
const milkEl = document.getElementById("labFormulateMilk");
|
||||
if (massEl && profile.massKg != null) massEl.value = profile.massKg;
|
||||
if (milkEl && profile.milkYieldKg != null) milkEl.value = profile.milkYieldKg;
|
||||
refreshOptimizeKeysForInputs().catch(() => renderOptimizeKeys(profile));
|
||||
}
|
||||
|
||||
function renderOptimizeKeys(profile) {
|
||||
const box = document.getElementById("labFormulateOptimizeKeys");
|
||||
if (!box) return;
|
||||
const norms = profile?.norms || profile?.normsData?.indicators || {};
|
||||
const keys = Object.keys(norms).filter((k) => norms[k]?.min != null || norms[k]?.max != null);
|
||||
const useKeys = keys.length ? keys : DEFAULT_OPTIMIZE_KEYS;
|
||||
const prevChecked = new Set(
|
||||
Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value)
|
||||
);
|
||||
const hasPrev = prevChecked.size > 0;
|
||||
box.innerHTML = useKeys
|
||||
.map((key) => {
|
||||
const checked =
|
||||
(hasPrev ? prevChecked.has(key) : DEFAULT_OPTIMIZE_KEYS.includes(key)) ? " checked" : "";
|
||||
const b = norms[key] || {};
|
||||
const bounds =
|
||||
b.min != null && b.max != null
|
||||
? `${formatNum(b.min)}–${formatNum(b.max)}`
|
||||
: b.max != null
|
||||
? `≤ ${formatNum(b.max)}`
|
||||
: b.min != null
|
||||
? `≥ ${formatNum(b.min)}`
|
||||
: "";
|
||||
return (
|
||||
`<label class="lab-formulate-opt-key">` +
|
||||
`<input type="checkbox" class="form-check-input lab-formulate-opt-cb" value="${escapeHtml(key)}"${checked}>` +
|
||||
`<span>${escapeHtml(key)}</span>` +
|
||||
`<small class="text-muted">${escapeHtml(bounds)}</small>` +
|
||||
`</label>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
const el = document.getElementById("labFormulateResult");
|
||||
if (!el) return;
|
||||
if (!data) {
|
||||
el.innerHTML = '<div class="lab-sandbox-empty">Скажи моё имя и жми кнопку</div>';
|
||||
return;
|
||||
}
|
||||
const stats = data.searchStats || {};
|
||||
const optimizeSet = new Set(data.optimizeKeys || []);
|
||||
let html =
|
||||
`<div class="lab-formulate-result-meta">` +
|
||||
`<span>Реагентов ${data.candidatePoolSize}</span>` +
|
||||
`<span>Троек ${stats.tripletsEvaluated || 0}</span>` +
|
||||
`<span>${stats.durationMs || 0} мс — время деньги</span></div>` +
|
||||
`<table class="table table-sm lab-sandbox-table mb-3"><thead><tr><th>Реагент</th><th>кг/сут</th><th>%</th></tr></thead><tbody>` +
|
||||
(data.lines || [])
|
||||
.map(
|
||||
(line) =>
|
||||
`<tr><td>${escapeHtml(line.name)}</td><td>${formatNum(line.dailyKg)}</td><td>${formatNum(line.sharePct)}</td></tr>`
|
||||
)
|
||||
.join("") +
|
||||
`</tbody></table>`;
|
||||
const rows = (data.indicators || []).filter(
|
||||
(ind) => ind.min != null || ind.max != null || ind.content != null
|
||||
);
|
||||
html +=
|
||||
`<table class="table table-sm lab-sandbox-table"><thead><tr><th>Показатель</th><th>Факт</th><th>Стандарт</th><th>Δ</th></tr></thead><tbody>` +
|
||||
rows
|
||||
.map((ind) => {
|
||||
const target = optimizeSet.has(ind.key) ? " lab-formulate-row--target" : "";
|
||||
const dc = ind.min != null || ind.max != null ? diffClass(ind.diff) : "lab-sandbox-diff--na";
|
||||
const norm =
|
||||
ind.min != null && ind.max != null
|
||||
? `${formatNum(ind.min)}–${formatNum(ind.max)}`
|
||||
: ind.max != null
|
||||
? `≤ ${formatNum(ind.max)}`
|
||||
: ind.min != null
|
||||
? `≥ ${formatNum(ind.min)}`
|
||||
: "—";
|
||||
return (
|
||||
`<tr class="${target}"><td>${escapeHtml(ind.label || ind.key)}</td>` +
|
||||
`<td>${formatNum(ind.content)}</td><td>${norm}</td>` +
|
||||
`<td class="${dc}">${formatNum(ind.diff)}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join("") +
|
||||
`</tbody></table>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadCatalogs() {
|
||||
const [compData, profData] = await Promise.all([
|
||||
api("/api/lab/formulate/components"),
|
||||
api("/api/lab/animal-profiles?ration_type=DAIRY"),
|
||||
]);
|
||||
catalog = compData.components || [];
|
||||
groups = (compData.groups || []).slice().sort((a, b) => a.step - b.step);
|
||||
profiles = profData.profiles || [];
|
||||
buildWizard();
|
||||
const profileSel = document.getElementById("labFormulateProfile");
|
||||
if (profileSel) {
|
||||
const labDefault = profiles.find((p) => p.profileKey === "lab_math_dairy_01") || profiles[0];
|
||||
profileSel.innerHTML = profileOptions(labDefault?.id);
|
||||
if (labDefault) fillProfileFields(labDefault.id);
|
||||
}
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
async function labFormulateRun() {
|
||||
const params = readFormulateParams();
|
||||
const profile = profiles.find((p) => String(p.id) === String(params.profileId));
|
||||
if (!params.profileId) throw new Error("Выбери стандарт чистоты — я предупреждал");
|
||||
if (params.normsMethod !== "wesp") {
|
||||
const { mass, milk } = resolveMassMilk(profile);
|
||||
if (mass == null || milk == null) {
|
||||
throw new Error("Для Москва/Петербург нужны масса и удой — заполни поля или выбери профиль с удоем");
|
||||
}
|
||||
params.massKg = mass;
|
||||
params.milkYieldKg = milk;
|
||||
}
|
||||
if ((params.groupSelections.rough || []).length < 1) {
|
||||
throw new Error("Без базы не готовим. Минимум один грубый корм");
|
||||
}
|
||||
if (totalPicked() < 3) throw new Error("Минимум три реагента. Нам нужно готовить");
|
||||
const data = await api("/api/lab/formulate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
profileId: params.profileId,
|
||||
groupSelections: params.groupSelections,
|
||||
massKg: params.massKg,
|
||||
milkYieldKg: params.milkYieldKg,
|
||||
totalKgPerHead: params.totalKgPerHead,
|
||||
costWeight: params.costWeight,
|
||||
optimizeKeys: params.optimizeKeys,
|
||||
normsMethod: params.normsMethod,
|
||||
normsParams: params.normsParams,
|
||||
}),
|
||||
});
|
||||
renderResult(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function selectLabForGroup(gid) {
|
||||
catalog
|
||||
.filter((c) => c.eligible && c.feedGroup === gid && String(c.name || "").startsWith("LAB тест —"))
|
||||
.forEach((c) => {
|
||||
const cb = document.querySelector(
|
||||
`.lab-formulate-gcb[data-group="${CSS.escape(gid)}"][value="${CSS.escape(c.id)}"]`
|
||||
);
|
||||
if (cb) cb.checked = true;
|
||||
});
|
||||
updateCounters();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.getElementById("labFormulateRun")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await labFormulateRun();
|
||||
} catch (err) {
|
||||
formulateError(String(err.message || err));
|
||||
} finally {
|
||||
updateCounters();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("labFormulateProfile")?.addEventListener("change", (e) => {
|
||||
fillProfileFields(e.target.value);
|
||||
});
|
||||
|
||||
for (const id of [
|
||||
"labFormulateMass",
|
||||
"labFormulateMilk",
|
||||
"labFormulateTotalKg",
|
||||
"labFormulateCostWeight",
|
||||
"labFormulateFat",
|
||||
"labFormulateLactation",
|
||||
"labFormulateKonc",
|
||||
]) {
|
||||
document.getElementById(id)?.addEventListener("input", scheduleOptimizeKeysRefresh);
|
||||
}
|
||||
document.getElementById("labFormulateNormsMethod")?.addEventListener("change", () => {
|
||||
syncNormsMethodUi();
|
||||
scheduleOptimizeKeysRefresh();
|
||||
});
|
||||
|
||||
document.getElementById("labFormulatePanes")?.addEventListener("input", (e) => {
|
||||
if (e.target.classList.contains("lab-formulate-filter")) {
|
||||
renderGroupTable(e.target.dataset.group, e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("labFormulatePanes")?.addEventListener("change", (e) => {
|
||||
if (e.target.classList.contains("lab-formulate-gcb")) updateCounters();
|
||||
});
|
||||
|
||||
document.getElementById("labFormulatePanes")?.addEventListener("click", (e) => {
|
||||
const next = e.target.closest(".lab-formulate-next");
|
||||
const back = e.target.closest(".lab-formulate-back");
|
||||
const skip = e.target.closest(".lab-formulate-skip");
|
||||
if (next) {
|
||||
const step = Number(next.dataset.step);
|
||||
const g = groups.find((x) => x.step === step);
|
||||
if (g?.required && selectedForGroup(g.id).length < (g.minPick || 1)) {
|
||||
formulateError(`В «${g.label}» нужно минимум ${g.minPick}. Всё под контролем — выбери`);
|
||||
return;
|
||||
}
|
||||
setStep(step + 1);
|
||||
}
|
||||
if (back) setStep(Number(back.dataset.step) - 1);
|
||||
if (skip) setStep(Number(skip.dataset.step) + 1);
|
||||
});
|
||||
|
||||
document.getElementById("labFormulateSteps")?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".lab-formulate-step");
|
||||
if (!btn) return;
|
||||
const step = Number(btn.dataset.step);
|
||||
if (!canGoToStep(step)) return;
|
||||
setStep(step);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.altKey && e.key === "l") {
|
||||
selectLabForGroup("rough");
|
||||
selectLabForGroup("succulent");
|
||||
selectLabForGroup("concentrate");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
syncNormsMethodUi();
|
||||
loadCatalogs().catch((err) => {
|
||||
const msg = err.message || String(err);
|
||||
formulateError(msg);
|
||||
const panes = document.getElementById("labFormulatePanes");
|
||||
if (panes) panes.innerHTML = `<p class="text-danger">${escapeHtml(msg)}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
global.labFormulateRun = labFormulateRun;
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(window);
|
||||
@@ -0,0 +1,93 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const MAX_MS = 5000;
|
||||
const MIN_MS = 2400;
|
||||
const DEFAULT_MS = 4000;
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
agrostar: "Поставка AgroStar",
|
||||
sandbox: "Песочница рациона",
|
||||
formulate: "Автоготовка",
|
||||
lab: "Лаборатория",
|
||||
};
|
||||
|
||||
let overlay = null;
|
||||
let hideTimer = null;
|
||||
|
||||
function ensureOverlay() {
|
||||
if (overlay) return overlay;
|
||||
overlay = document.createElement("div");
|
||||
overlay.id = "labHeisenbergPrime";
|
||||
overlay.className = "lab-heisenberg-prime-overlay";
|
||||
overlay.hidden = true;
|
||||
overlay.innerHTML =
|
||||
'<div class="lab-heisenberg-prime__vignette" aria-hidden="true"></div>' +
|
||||
'<div class="lab-heisenberg-prime__eyes" aria-hidden="true">' +
|
||||
'<span class="lab-heisenberg-prime__eye lab-heisenberg-prime__eye--left"></span>' +
|
||||
'<span class="lab-heisenberg-prime__eye lab-heisenberg-prime__eye--right"></span>' +
|
||||
"</div>" +
|
||||
'<div class="lab-heisenberg-prime__glow" aria-hidden="true"></div>' +
|
||||
'<div class="lab-heisenberg-prime__scanlines" aria-hidden="true"></div>' +
|
||||
'<div class="lab-heisenberg-prime__banner" role="alert" aria-live="assertive">' +
|
||||
'<div class="lab-heisenberg-prime__title">В нашем деле - или ты, или тебя.</div>' +
|
||||
"</div>";
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function sourceLabel(source) {
|
||||
const key = String(source || "lab").toLowerCase();
|
||||
return SOURCE_LABELS[key] || SOURCE_LABELS.lab;
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = null;
|
||||
}
|
||||
document.body.classList.remove("lab-heisenberg-prime-active");
|
||||
if (overlay) overlay.hidden = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} reason — почему сработал prime (показываем пользователю)
|
||||
* @param {{ source?: string, durationMs?: number }} [options]
|
||||
*/
|
||||
function trigger(reason, options) {
|
||||
if (!document.body.classList.contains("lab-sandbox-page")) return;
|
||||
|
||||
const opts = options || {};
|
||||
const text = String(reason || "Что-то пошло не по плану").trim();
|
||||
const duration = Math.min(MAX_MS, Math.max(MIN_MS, Number(opts.durationMs) || DEFAULT_MS));
|
||||
|
||||
global.WespZootechNotify?.dismissAll?.();
|
||||
|
||||
dismiss();
|
||||
|
||||
const el = ensureOverlay();
|
||||
const reasonEl = el.querySelector(".lab-heisenberg-prime__reason");
|
||||
const sourceEl = el.querySelector(".lab-heisenberg-prime__source");
|
||||
if (reasonEl) reasonEl.textContent = text;
|
||||
|
||||
el.hidden = false;
|
||||
requestAnimationFrame(() => {
|
||||
document.body.classList.add("lab-heisenberg-prime-active");
|
||||
});
|
||||
|
||||
hideTimer = setTimeout(dismiss, duration);
|
||||
}
|
||||
|
||||
/** Ошибка на /lab: только prime (без toast/alert — они ломают сцену). */
|
||||
function error(reason, source) {
|
||||
trigger(reason, { source: source || "lab" });
|
||||
}
|
||||
|
||||
global.WespLabHeisenbergPrime = {
|
||||
trigger,
|
||||
dismiss,
|
||||
error,
|
||||
MAX_MS,
|
||||
SOURCE_LABELS,
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,272 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function createLabMasterPanel() {
|
||||
let root = null;
|
||||
let recipeId = null;
|
||||
let state = null;
|
||||
let components = [];
|
||||
let profiles = [];
|
||||
async function api(path, opts) {
|
||||
const resp = await fetch(path, opts);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.message || "Ошибка API");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function ensureCatalogs(rationType) {
|
||||
const [compResp, profData] = await Promise.all([
|
||||
components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()),
|
||||
api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`),
|
||||
]);
|
||||
if (Array.isArray(compResp)) components = compResp;
|
||||
profiles = profData.profiles || [];
|
||||
}
|
||||
|
||||
async function loadRecipes() {
|
||||
const data = await api("/api/lab/recipes");
|
||||
return data.recipes || [];
|
||||
}
|
||||
|
||||
async function loadRation(id) {
|
||||
return api(`/api/lab/rations/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
function componentOptionsHtml(selectedId) {
|
||||
const opts = ['<option value="">— компонент —</option>'];
|
||||
components.forEach((c) => {
|
||||
const sel = String(c.id) === String(selectedId) ? " selected" : "";
|
||||
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
|
||||
});
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function profileOptionsHtml(selectedId) {
|
||||
const opts = ['<option value="">— профиль норм —</option>'];
|
||||
profiles.forEach((p) => {
|
||||
const sel = String(p.id) === String(selectedId) ? " selected" : "";
|
||||
opts.push(
|
||||
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
|
||||
);
|
||||
});
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!root) return;
|
||||
const indicators = (state?.rationResults?.indicators || []).slice(0, 8);
|
||||
const lines = state?.lines || [];
|
||||
root.innerHTML =
|
||||
`<div class="zt-k-hub-lab">` +
|
||||
`<div class="zt-k-hub-lab__toolbar">` +
|
||||
`<label class="zt-k-hub-lab__field">Рецепт <select data-lab-recipe-select class="form-select form-select-sm"></select></label>` +
|
||||
`<label class="zt-k-hub-lab__field">Профиль <select data-lab-profile-select class="form-select form-select-sm">${profileOptionsHtml(state?.animalProfileId)}</select></label>` +
|
||||
`<label class="zt-k-hub-lab__field">Тип <select data-lab-ration-type class="form-select form-select-sm">` +
|
||||
`<option value="BEEF" ${state?.rationType === "BEEF" ? "selected" : ""}>Мясное</option>` +
|
||||
`<option value="DAIRY" ${state?.rationType === "DAIRY" ? "selected" : ""}>Дойное</option>` +
|
||||
`</select></label>` +
|
||||
`<button type="button" class="btn btn-sm btn-primary" data-action="lab-recalculate">Пересчёт</button>` +
|
||||
`<button type="button" class="btn btn-sm btn-outline-primary" data-action="lab-save">Сохранить</button>` +
|
||||
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-add-line">+ Строка</button>` +
|
||||
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-print-ration" title="Печать рациона">Печать</button>` +
|
||||
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-print-compound" title="Печать комбикорма">Комб.</button>` +
|
||||
`</div>` +
|
||||
`<div class="zt-k-hub-lab__subtitle">Зоотехнический мастер · ${escapeHtml(state?.recipeName || "")}</div>` +
|
||||
`<table class="table table-sm zt-k-hub-lab__table"><thead><tr>` +
|
||||
`<th>Компонент WESP</th><th>кг/день</th><th>В рац.</th><th>Комб.</th><th></th></tr></thead><tbody>` +
|
||||
(lines.length
|
||||
? lines
|
||||
.map(
|
||||
(l, i) =>
|
||||
`<tr data-line-idx="${i}">` +
|
||||
`<td><select class="form-select form-select-sm" data-field="componentId">${componentOptionsHtml(l.componentId)}</select></td>` +
|
||||
`<td><input class="form-control form-control-sm" data-field="dailyKg" type="number" step="0.01" value="${l.dailyKg ?? ""}"></td>` +
|
||||
`<td class="text-center"><input type="checkbox" data-field="inRation" ${l.inRation ? "checked" : ""}></td>` +
|
||||
`<td class="text-center"><input type="checkbox" data-field="inCompound" ${l.inCompound ? "checked" : ""}></td>` +
|
||||
`<td><button type="button" class="btn btn-sm btn-link text-danger p-0" data-action="lab-remove-line" data-line-idx="${i}" title="Удалить">×</button></td>` +
|
||||
`</tr>`
|
||||
)
|
||||
.join("")
|
||||
: `<tr><td colspan="5" class="text-muted text-center">Нет строк — добавьте или создайте из рецепта</td></tr>`) +
|
||||
`</tbody></table>` +
|
||||
`<div class="zt-k-hub-lab__indicators">` +
|
||||
indicators
|
||||
.map(
|
||||
(ind) =>
|
||||
`<span class="badge bg-light text-dark me-1 mb-1">${escapeHtml(ind.label)}: ${escapeHtml(ind.content)} ${escapeHtml(ind.unit)}</span>`
|
||||
)
|
||||
.join("") +
|
||||
`</div></div>`;
|
||||
|
||||
const sel = root.querySelector("[data-lab-recipe-select]");
|
||||
if (sel && sel.options.length === 0) {
|
||||
loadRecipes().then((recipes) => {
|
||||
sel.innerHTML =
|
||||
'<option value="">—</option>' +
|
||||
recipes
|
||||
.map(
|
||||
(r) =>
|
||||
`<option value="${escapeHtml(r.id)}" ${r.id === recipeId ? "selected" : ""}>${escapeHtml(r.name)}</option>`
|
||||
)
|
||||
.join("");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (!recipeId) return;
|
||||
try {
|
||||
state = await loadRation(recipeId);
|
||||
await ensureCatalogs(state.rationType);
|
||||
if (!state.exists) {
|
||||
const ok = await global.WespDialog?.confirm?.(
|
||||
"Создать мастер из текущего рецепта?",
|
||||
{ title: "Мастер рациона" }
|
||||
);
|
||||
if (ok) {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, {
|
||||
method: "POST",
|
||||
});
|
||||
state = await loadRation(recipeId);
|
||||
await ensureCatalogs(state.rationType);
|
||||
} else {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, {
|
||||
method: "POST",
|
||||
});
|
||||
state = await loadRation(recipeId);
|
||||
await ensureCatalogs(state.rationType);
|
||||
}
|
||||
}
|
||||
render();
|
||||
} catch (e) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
|
||||
}
|
||||
}
|
||||
|
||||
function collectLines() {
|
||||
if (!root || !state) return [];
|
||||
return (state.lines || []).map((line, i) => {
|
||||
const row = root.querySelector(`tr[data-line-idx="${i}"]`);
|
||||
if (!row) return line;
|
||||
const componentId = row.querySelector('[data-field="componentId"]')?.value || null;
|
||||
const comp = components.find((c) => String(c.id) === String(componentId));
|
||||
return {
|
||||
...line,
|
||||
componentId,
|
||||
ingredientName: comp?.name || line.ingredientName,
|
||||
dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null,
|
||||
inRation: row.querySelector('[data-field="inRation"]')?.checked,
|
||||
inCompound: row.querySelector('[data-field="inCompound"]')?.checked,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function collectPayload() {
|
||||
const rationType = root.querySelector("[data-lab-ration-type]")?.value || state?.rationType;
|
||||
return {
|
||||
lines: collectLines(),
|
||||
rationType,
|
||||
animalProfileId: root.querySelector("[data-lab-profile-select]")?.value || null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mount(el) {
|
||||
root = el;
|
||||
root.addEventListener("click", async (ev) => {
|
||||
const btn = ev.target.closest("[data-action]");
|
||||
if (!btn || !recipeId) return;
|
||||
const action = btn.getAttribute("data-action");
|
||||
try {
|
||||
if (action === "lab-add-line") {
|
||||
state.lines = [...(state?.lines || []), {
|
||||
componentId: "",
|
||||
ingredientName: "",
|
||||
dailyKg: 0,
|
||||
inRation: true,
|
||||
inCompound: false,
|
||||
}];
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (action === "lab-remove-line") {
|
||||
const idx = parseInt(btn.getAttribute("data-line-idx"), 10);
|
||||
if (!Number.isNaN(idx)) {
|
||||
state.lines = (state.lines || []).filter((_, i) => i !== idx);
|
||||
render();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "lab-recalculate") {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, {
|
||||
method: "POST",
|
||||
});
|
||||
await reload();
|
||||
}
|
||||
if (action === "lab-save") {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(collectPayload()),
|
||||
});
|
||||
await reload();
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер сохранён");
|
||||
}
|
||||
if (action === "lab-print-ration" || action === "lab-print-compound") {
|
||||
const mode = action === "lab-print-compound" ? "compound" : "ration";
|
||||
global.open?.(
|
||||
`/api/lab/rations/${encodeURIComponent(recipeId)}/print?mode=${mode}`,
|
||||
"_blank",
|
||||
"noopener"
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
|
||||
}
|
||||
});
|
||||
root.addEventListener("change", async (ev) => {
|
||||
if (ev.target.matches("[data-lab-recipe-select]")) {
|
||||
recipeId = ev.target.value;
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
if (ev.target.matches("[data-lab-ration-type]")) {
|
||||
const rt = ev.target.value;
|
||||
state = { ...state, rationType: rt };
|
||||
profiles = [];
|
||||
await ensureCatalogs(rt);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (ev.target.matches("[data-lab-profile-select]")) {
|
||||
const profile = profiles.find((p) => String(p.id) === String(ev.target.value));
|
||||
if (profile?.rationType) {
|
||||
state = { ...state, rationType: profile.rationType, animalProfileId: profile.id };
|
||||
const typeSel = root.querySelector("[data-lab-ration-type]");
|
||||
if (typeSel) typeSel.value = profile.rationType;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
root = null;
|
||||
state = null;
|
||||
},
|
||||
reload,
|
||||
setRecipeId(id) {
|
||||
recipeId = id;
|
||||
reload();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
global.WespLabMasterPanel = { createLabMasterPanel };
|
||||
})(window);
|
||||
@@ -0,0 +1,777 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const GFE_DYNAMIC_KEYS = ["usp", "nel"];
|
||||
|
||||
let catalog = [];
|
||||
let profiles = [];
|
||||
let selectedId = null;
|
||||
let draftNorms = {};
|
||||
let dynamicNorms = {};
|
||||
let normCoverage = null;
|
||||
let normSourceMap = {};
|
||||
let resolvedNormsPreview = {};
|
||||
let seedCatalog = [];
|
||||
let seedCatalogRation = null;
|
||||
let normsCoverageTimer = null;
|
||||
|
||||
async function api(path, opts) {
|
||||
const resp = await fetch(path, opts);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function notify() {
|
||||
return global.WespZootechNotify?.createAdapter?.();
|
||||
}
|
||||
|
||||
function readMass() {
|
||||
const raw = document.getElementById("labProfileMass")?.value?.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function readMilk() {
|
||||
const raw = document.getElementById("labProfileMilk")?.value?.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function readNumInput(id) {
|
||||
const raw = document.getElementById(id)?.value?.trim();
|
||||
if (raw === "") return null;
|
||||
const n = Number(raw);
|
||||
return Number.isNaN(n) ? null : n;
|
||||
}
|
||||
|
||||
function readNormsMethod() {
|
||||
return document.getElementById("labProfileNormsMethod")?.value || "wesp";
|
||||
}
|
||||
|
||||
function readNormsParams() {
|
||||
const method = readNormsMethod();
|
||||
if (method === "wesp") return {};
|
||||
const params = {
|
||||
milkFatPct: readNumInput("labProfileFat") ?? 4,
|
||||
lactationNo: readNumInput("labProfileLactation") ?? 2,
|
||||
};
|
||||
if (method === "racion_piter") {
|
||||
params.koncOeSv = readNumInput("labProfileKonc") ?? 10.3;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function syncNormsMethodUi() {
|
||||
const method = readNormsMethod();
|
||||
const racionBox = document.getElementById("labProfileRacionFields");
|
||||
const piterOnly = document.querySelectorAll(".lab-profile-piter-only");
|
||||
if (racionBox) racionBox.classList.toggle("d-none", method === "wesp");
|
||||
piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter"));
|
||||
syncSourceFilterOptions(method);
|
||||
renderNormsSummary();
|
||||
scheduleNormsCoverageRefresh();
|
||||
}
|
||||
|
||||
function syncSourceFilterOptions(method) {
|
||||
const sel = document.getElementById("labProfilesSourceFilter");
|
||||
if (!sel) return;
|
||||
sel.querySelectorAll("option[data-racion-only]").forEach((opt) => {
|
||||
opt.hidden = method === "wesp";
|
||||
opt.disabled = method === "wesp";
|
||||
});
|
||||
if (method === "wesp" && ["racion", "racion_only", "derived", "new_racion"].includes(sel.value)) {
|
||||
sel.value = "all";
|
||||
}
|
||||
}
|
||||
|
||||
function buildNormSourceMap(coverage, storedNorms) {
|
||||
const map = {};
|
||||
if (coverage) {
|
||||
(coverage.racion || []).forEach((k) => {
|
||||
map[k] = "racion";
|
||||
});
|
||||
(coverage.derived || []).forEach((k) => {
|
||||
map[k] = "derived";
|
||||
});
|
||||
(coverage.fallback || []).forEach((k) => {
|
||||
if (!map[k]) map[k] = "fallback";
|
||||
});
|
||||
(coverage.missing || []).forEach((k) => {
|
||||
if (!map[k]) map[k] = "missing";
|
||||
});
|
||||
}
|
||||
Object.keys(storedNorms || {}).forEach((k) => {
|
||||
if (!map[k]) map[k] = "stored";
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
function normSourceLabel(src) {
|
||||
const labels = {
|
||||
racion: "RACION",
|
||||
derived: "расч.",
|
||||
fallback: "WESP",
|
||||
stored: "БД",
|
||||
missing: "—",
|
||||
gfe: "GfE",
|
||||
};
|
||||
return labels[src] || "—";
|
||||
}
|
||||
|
||||
function normSourceBadge(src) {
|
||||
if (!src || src === "missing") return '<span class="norm-source-badge norm-source-badge--empty">—</span>';
|
||||
const cls = `norm-source-badge norm-source-badge--${src}`;
|
||||
return `<span class="${cls}" title="${escapeHtml(normSourceLabel(src))}">${escapeHtml(normSourceLabel(src))}</span>`;
|
||||
}
|
||||
|
||||
function isInDb(key) {
|
||||
const b = draftNorms[key] || {};
|
||||
return b.min != null || b.max != null;
|
||||
}
|
||||
|
||||
function matchesSourceFilter(key) {
|
||||
const filter = document.getElementById("labProfilesSourceFilter")?.value || "all";
|
||||
if (filter === "all") return true;
|
||||
const src = normSourceMap[key];
|
||||
if (filter === "in_db") return isInDb(key);
|
||||
if (filter === "seed") return src === "fallback" || src === "stored";
|
||||
if (filter === "racion") return src === "racion" || src === "derived";
|
||||
if (filter === "racion_only") return src === "racion";
|
||||
if (filter === "derived") return src === "derived";
|
||||
if (filter === "new_racion") return src === "racion" || src === "derived";
|
||||
return true;
|
||||
}
|
||||
|
||||
function countNormSources() {
|
||||
const counts = { inDb: 0, racion: 0, derived: 0, fallback: 0, stored: 0, missing: 0 };
|
||||
const seen = new Set();
|
||||
catalog.forEach((c) => seen.add(c.key));
|
||||
Object.keys(draftNorms).forEach((k) => seen.add(k));
|
||||
if (normCoverage) {
|
||||
(normCoverage.racion || []).forEach((k) => seen.add(k));
|
||||
(normCoverage.derived || []).forEach((k) => seen.add(k));
|
||||
(normCoverage.fallback || []).forEach((k) => seen.add(k));
|
||||
(normCoverage.missing || []).forEach((k) => seen.add(k));
|
||||
}
|
||||
seen.forEach((key) => {
|
||||
if (isInDb(key)) counts.inDb += 1;
|
||||
const src = normSourceMap[key];
|
||||
if (src === "racion") counts.racion += 1;
|
||||
else if (src === "derived") counts.derived += 1;
|
||||
else if (src === "fallback") counts.fallback += 1;
|
||||
else if (src === "stored") counts.stored += 1;
|
||||
else if (src === "missing") counts.missing += 1;
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
|
||||
function renderNormsSummary() {
|
||||
const panel = document.getElementById("labProfilesNormsSummary");
|
||||
const text = document.getElementById("labProfilesNormsSummaryText");
|
||||
const form = document.getElementById("labProfilesForm");
|
||||
if (!panel || !text || !form || form.hidden) {
|
||||
if (panel) panel.hidden = true;
|
||||
return;
|
||||
}
|
||||
const method = readNormsMethod();
|
||||
const counts = countNormSources();
|
||||
if (method === "wesp") {
|
||||
panel.hidden = false;
|
||||
text.textContent = `В БД ${counts.inDb} показ. · справочник/ручные ${counts.stored + counts.fallback}`;
|
||||
return;
|
||||
}
|
||||
if (!normCoverage) {
|
||||
panel.hidden = false;
|
||||
text.textContent = "Укажи массу и удой — разберём источники RACION";
|
||||
return;
|
||||
}
|
||||
panel.hidden = false;
|
||||
const racTotal = counts.racion + counts.derived;
|
||||
const withMin = normCoverage.withMin != null ? normCoverage.withMin : racTotal + counts.fallback;
|
||||
text.textContent =
|
||||
`В БД ${counts.inDb} · RACION ${racTotal} (NPitV ${counts.racion} + расч. ${counts.derived}) · ` +
|
||||
`WESP fallback ${counts.fallback} · с min ${withMin}/${normCoverage.total ?? "—"}`;
|
||||
}
|
||||
|
||||
function applyNormsCoveragePayload(payload) {
|
||||
normCoverage = payload.coverage || null;
|
||||
resolvedNormsPreview = payload.resolvedIndicators || {};
|
||||
if (payload.dynamicNorms) {
|
||||
dynamicNorms = { ...dynamicNorms, ...payload.dynamicNorms };
|
||||
}
|
||||
normSourceMap = buildNormSourceMap(normCoverage, draftNorms);
|
||||
renderNormsSummary();
|
||||
renderNormsTable();
|
||||
}
|
||||
|
||||
async function refreshNormsCoverage() {
|
||||
const method = readNormsMethod();
|
||||
const mass = readMass();
|
||||
const milk = readMilk();
|
||||
if (method === "wesp") {
|
||||
normCoverage = null;
|
||||
resolvedNormsPreview = {};
|
||||
normSourceMap = buildNormSourceMap(null, draftNorms);
|
||||
GFE_DYNAMIC_KEYS.forEach((key) => {
|
||||
if (dynamicNorms[key]?.min != null && !isInDb(key)) normSourceMap[key] = "gfe";
|
||||
});
|
||||
renderNormsSummary();
|
||||
renderNormsTable();
|
||||
return;
|
||||
}
|
||||
if (mass == null || milk == null) {
|
||||
normCoverage = null;
|
||||
normSourceMap = buildNormSourceMap(null, draftNorms);
|
||||
renderNormsSummary();
|
||||
renderNormsTable();
|
||||
return;
|
||||
}
|
||||
const q = new URLSearchParams({ method, mass_kg: String(mass), milk_yield_kg: String(milk) });
|
||||
if (selectedId) q.set("profile_id", selectedId);
|
||||
const np = readNormsParams();
|
||||
if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct));
|
||||
if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo));
|
||||
if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv));
|
||||
try {
|
||||
const preview = await api(`/api/lab/norms-preview?${q.toString()}`);
|
||||
applyNormsCoveragePayload(preview);
|
||||
} catch (err) {
|
||||
notify()?.error?.(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNormsCoverageRefresh() {
|
||||
if (normsCoverageTimer) clearTimeout(normsCoverageTimer);
|
||||
normsCoverageTimer = setTimeout(() => {
|
||||
refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }));
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function fillNormsParamsFromProfile(p) {
|
||||
const method = p.normsMethod || "wesp";
|
||||
const sel = document.getElementById("labProfileNormsMethod");
|
||||
if (sel) sel.value = method;
|
||||
const np = p.normsParams || {};
|
||||
const fat = document.getElementById("labProfileFat");
|
||||
const lact = document.getElementById("labProfileLactation");
|
||||
const konc = document.getElementById("labProfileKonc");
|
||||
if (fat) fat.value = np.milkFatPct != null ? np.milkFatPct : 4;
|
||||
if (lact) lact.value = np.lactationNo != null ? np.lactationNo : 2;
|
||||
if (konc) konc.value = np.koncOeSv != null ? np.koncOeSv : 10.3;
|
||||
syncNormsMethodUi();
|
||||
}
|
||||
|
||||
/** GfE 2001 — parity app/lab/calc/gfe_norms.py */
|
||||
function gfeUspMinG(massKg, milkKg) {
|
||||
if (!massKg || massKg <= 0) return null;
|
||||
return 0.09 * massKg ** 0.75 * 6.25 + Math.max(milkKg || 0, 0) * 85;
|
||||
}
|
||||
|
||||
function gfeNelMinMj(massKg, milkKg) {
|
||||
if (!massKg || massKg <= 0) return null;
|
||||
return 0.293 * massKg ** 0.75 + Math.max(milkKg || 0, 0) * 3.3;
|
||||
}
|
||||
|
||||
function computeDynamicNormsLocal() {
|
||||
const mass = readMass();
|
||||
const milk = readMilk();
|
||||
const out = {};
|
||||
const usp = gfeUspMinG(mass, milk);
|
||||
if (usp != null) {
|
||||
out.usp = { min: usp, formula: "0.09×масса^0.75×6.25 + удой×85" };
|
||||
}
|
||||
const nel = gfeNelMinMj(mass, milk);
|
||||
if (nel != null) {
|
||||
out.nel = { min: nel, formula: "0.293×масса^0.75 + удой×3.3" };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatGfe(v) {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(2);
|
||||
}
|
||||
|
||||
function refreshDynamicNorms() {
|
||||
dynamicNorms = computeDynamicNormsLocal();
|
||||
renderGfePanel();
|
||||
renderNormsTable();
|
||||
}
|
||||
|
||||
function renderGfePanel() {
|
||||
const panel = document.getElementById("labProfilesGfePanel");
|
||||
const vals = document.getElementById("labProfilesGfeValues");
|
||||
const form = document.getElementById("labProfilesForm");
|
||||
if (!panel || !form || form.hidden) {
|
||||
if (panel) panel.hidden = true;
|
||||
return;
|
||||
}
|
||||
const mass = readMass();
|
||||
if (!mass || mass <= 0) {
|
||||
panel.hidden = true;
|
||||
return;
|
||||
}
|
||||
panel.hidden = false;
|
||||
const parts = [];
|
||||
if (dynamicNorms.usp?.min != null) {
|
||||
parts.push(`min уСП ≈ ${formatGfe(dynamicNorms.usp.min)} г`);
|
||||
}
|
||||
if (dynamicNorms.nel?.min != null) {
|
||||
parts.push(`min ЧЭЛ ≈ ${formatGfe(dynamicNorms.nel.min)} МДж`);
|
||||
}
|
||||
if (vals) vals.textContent = parts.join(" · ") || "—";
|
||||
const enabled = parts.length > 0;
|
||||
["labProfilesGfeApplyUsp", "labProfilesGfeApplyNel", "labProfilesGfeApplyAll"].forEach((id) => {
|
||||
const btn = document.getElementById(id);
|
||||
if (btn) btn.disabled = !enabled;
|
||||
});
|
||||
}
|
||||
|
||||
function applyGfeMin(keys) {
|
||||
collectNormsFromTable();
|
||||
let applied = 0;
|
||||
keys.forEach((key) => {
|
||||
const v = dynamicNorms[key]?.min;
|
||||
if (v == null) return;
|
||||
const cur = draftNorms[key] || {};
|
||||
if (cur.min != null) return;
|
||||
draftNorms[key] = { ...cur, min: v };
|
||||
applied += 1;
|
||||
});
|
||||
renderNormsTable();
|
||||
if (applied) {
|
||||
notify()?.success?.("Подставлено по GfE — нажми «Запомни это»");
|
||||
} else {
|
||||
notify()?.error?.("Нижние границы уже заданы или нет массы");
|
||||
}
|
||||
}
|
||||
|
||||
function syncDeleteButton() {
|
||||
const btn = document.getElementById("labProfilesDelete");
|
||||
if (btn) btn.hidden = !selectedId;
|
||||
}
|
||||
|
||||
function readRationType() {
|
||||
return document.getElementById("labProfileType")?.value || "DAIRY";
|
||||
}
|
||||
|
||||
function syncSeedPanelVisibility() {
|
||||
const panel = document.getElementById("labProfilesSeedPanel");
|
||||
const form = document.getElementById("labProfilesForm");
|
||||
if (panel) panel.hidden = !form || form.hidden;
|
||||
}
|
||||
|
||||
function filteredSeedCatalog() {
|
||||
const q = (document.getElementById("labProfilesSeedSearch")?.value || "").trim().toLowerCase();
|
||||
if (!q) return seedCatalog;
|
||||
return seedCatalog.filter((e) => {
|
||||
const hay = `${e.externalNo} ${e.label} ${e.massKg ?? ""}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSeedSelect() {
|
||||
const sel = document.getElementById("labProfilesSeedSelect");
|
||||
const applyBtn = document.getElementById("labProfilesSeedApply");
|
||||
if (!sel) return;
|
||||
const prev = sel.value;
|
||||
const rows = filteredSeedCatalog();
|
||||
const opts = ['<option value="">— выбери строку —</option>'].concat(
|
||||
rows.map((e) => {
|
||||
const mass = e.massKg != null ? ` · ${e.massKg} кг` : "";
|
||||
const cnt = e.indicatorCount != null ? ` · ${e.indicatorCount} показ.` : "";
|
||||
return `<option value="${escapeHtml(String(e.externalNo))}">№${escapeHtml(String(e.externalNo))} — ${escapeHtml(e.label)}${escapeHtml(mass)}${escapeHtml(cnt)}</option>`;
|
||||
})
|
||||
);
|
||||
sel.innerHTML = opts.join("");
|
||||
if (prev && rows.some((e) => String(e.externalNo) === prev)) {
|
||||
sel.value = prev;
|
||||
}
|
||||
if (applyBtn) applyBtn.disabled = !sel.value;
|
||||
}
|
||||
|
||||
async function loadSeedCatalog(rationType) {
|
||||
const ration = rationType || readRationType();
|
||||
if (seedCatalogRation === ration && seedCatalog.length) {
|
||||
renderSeedSelect();
|
||||
return;
|
||||
}
|
||||
const data = await api(`/api/lab/seed-norms-catalog?ration_type=${encodeURIComponent(ration)}`);
|
||||
seedCatalog = data.entries || [];
|
||||
seedCatalogRation = ration;
|
||||
renderSeedSelect();
|
||||
}
|
||||
|
||||
async function applySeedNorms() {
|
||||
const sel = document.getElementById("labProfilesSeedSelect");
|
||||
const externalNo = sel?.value;
|
||||
if (!externalNo) {
|
||||
notify()?.error?.("Выбери строку справочника");
|
||||
return;
|
||||
}
|
||||
const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length;
|
||||
if (filled > 0) {
|
||||
const ok = global.confirm("Заменить текущие границы нормами из справочника?");
|
||||
if (!ok) return;
|
||||
}
|
||||
const ration = readRationType();
|
||||
const entry = await api(
|
||||
`/api/lab/seed-norms-catalog/${encodeURIComponent(externalNo)}?ration_type=${encodeURIComponent(ration)}`
|
||||
);
|
||||
draftNorms = { ...(entry.indicators || {}) };
|
||||
if (entry.massKg != null) {
|
||||
document.getElementById("labProfileMass").value = entry.massKg;
|
||||
}
|
||||
document.getElementById("labProfileExternal").value = entry.externalNo;
|
||||
const labelEl = document.getElementById("labProfileLabel");
|
||||
if (labelEl && !labelEl.value.trim()) {
|
||||
labelEl.value = entry.label || "";
|
||||
}
|
||||
refreshDynamicNorms();
|
||||
notify()?.success?.(`Загружено ${Object.keys(draftNorms).length} показателей — нажми «Запомни это»`);
|
||||
}
|
||||
|
||||
function filteredProfiles() {
|
||||
const type = document.getElementById("labProfilesType")?.value || "";
|
||||
const q = (document.getElementById("labProfilesSearch")?.value || "").trim().toLowerCase();
|
||||
return profiles.filter((p) => {
|
||||
if (type && p.rationType !== type) return false;
|
||||
if (!q) return true;
|
||||
const hay = `${p.profileKey} ${p.label}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById("labProfilesList");
|
||||
if (!el) return;
|
||||
const rows = filteredProfiles();
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<div class="lab-sandbox-empty">Стандартов нет — создай новый</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows
|
||||
.map(
|
||||
(p) =>
|
||||
`<button type="button" class="${p.id === selectedId ? "is-active" : ""}" data-id="${escapeHtml(p.id)}">` +
|
||||
`${escapeHtml(p.label)}` +
|
||||
`<span class="profile-key">${escapeHtml(p.profileKey)} · ${escapeHtml(p.rationType)}</span>` +
|
||||
`</button>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function indicatorRows() {
|
||||
const onlyCalc = document.getElementById("labProfilesOnlyCalc")?.checked;
|
||||
const onlyFilled = document.getElementById("labProfilesOnlyFilled")?.checked;
|
||||
const q = (document.getElementById("labProfilesNormSearch")?.value || "").trim().toLowerCase();
|
||||
const extraKeys = Object.keys(draftNorms).filter((k) => !catalog.some((c) => c.key === k));
|
||||
const rows = [
|
||||
...catalog.map((c) => ({ ...c })),
|
||||
...extraKeys.map((k) => ({ key: k, label: k, unit: "", inCalc: false })),
|
||||
];
|
||||
return rows.filter((row) => {
|
||||
if (onlyCalc && !row.inCalc) return false;
|
||||
const b = draftNorms[row.key] || {};
|
||||
const has = b.min != null || b.max != null;
|
||||
if (onlyFilled && !has) return false;
|
||||
if (!matchesSourceFilter(row.key)) return false;
|
||||
if (q) {
|
||||
const hay = `${row.key} ${row.label}`.toLowerCase();
|
||||
if (!hay.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderNormsTable() {
|
||||
const tbody = document.getElementById("labProfilesNormsBody");
|
||||
const countEl = document.getElementById("labProfilesNormCount");
|
||||
if (!tbody) return;
|
||||
const rows = indicatorRows();
|
||||
tbody.innerHTML = rows
|
||||
.map((row) => {
|
||||
const b = draftNorms[row.key] || {};
|
||||
const min = b.min != null ? b.min : "";
|
||||
const max = b.max != null ? b.max : "";
|
||||
const badge = row.inCalc ? '<span class="norm-calc-badge ms-1">в деле</span>' : "";
|
||||
const unit = row.unit ? ` <span class="text-muted">${escapeHtml(row.unit)}</span>` : "";
|
||||
const dyn = dynamicNorms[row.key];
|
||||
const gfeHint =
|
||||
dyn?.min != null && min === ""
|
||||
? `<small class="text-muted d-block">GfE ≈ ${formatGfe(dyn.min)}${dyn.formula ? ` (${escapeHtml(dyn.formula)})` : ""}</small>`
|
||||
: "";
|
||||
const gfeBadge = GFE_DYNAMIC_KEYS.includes(row.key)
|
||||
? '<span class="norm-calc-badge ms-1" title="min по GfE 2001, если нижняя пуста">GfE</span>'
|
||||
: "";
|
||||
let src = normSourceMap[row.key];
|
||||
if (!src && isInDb(row.key)) src = "stored";
|
||||
const dbBadge = isInDb(row.key) ? '<span class="norm-source-badge norm-source-badge--db" title="Сохранено в БД">БД</span>' : "";
|
||||
const srcBadge = normSourceBadge(src);
|
||||
const resolved = resolvedNormsPreview[row.key];
|
||||
const resolvedHint =
|
||||
resolved?.min != null && min !== "" && Number(min) !== Number(resolved.min)
|
||||
? `<small class="text-muted d-block">RACION ≈ ${formatGfe(resolved.min)}</small>`
|
||||
: resolved?.min != null && min === ""
|
||||
? `<small class="text-muted d-block">RACION ≈ ${formatGfe(resolved.min)} (не в БД)</small>`
|
||||
: "";
|
||||
return (
|
||||
`<tr data-key="${escapeHtml(row.key)}">` +
|
||||
`<td>${escapeHtml(row.label)}${badge}${gfeBadge}${unit}${gfeHint}${resolvedHint}</td>` +
|
||||
`<td class="lab-profiles-source-cell">${dbBadge}${srcBadge}</td>` +
|
||||
`<td><input type="number" class="form-control form-control-sm" data-bound="min" step="any" value="${min}"` +
|
||||
`${dyn?.min != null && min === "" ? ` placeholder="${formatGfe(dyn.min)}"` : ""}></td>` +
|
||||
`<td><input type="number" class="form-control form-control-sm" data-bound="max" step="any" value="${max}"></td>` +
|
||||
`</tr>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
if (countEl) {
|
||||
const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length;
|
||||
const counts = countNormSources();
|
||||
const method = readNormsMethod();
|
||||
const srcPart =
|
||||
method === "wesp"
|
||||
? ` · в БД ${counts.inDb}`
|
||||
: ` · БД ${counts.inDb} · RACION ${counts.racion + counts.derived} · WESP ${counts.fallback}`;
|
||||
countEl.textContent = `В таблице ${rows.length} · с границами ${filled}${srcPart}`;
|
||||
}
|
||||
renderNormsSummary();
|
||||
}
|
||||
|
||||
function collectNormsFromTable() {
|
||||
const out = { ...draftNorms };
|
||||
document.querySelectorAll("#labProfilesNormsBody tr[data-key]").forEach((tr) => {
|
||||
const key = tr.getAttribute("data-key");
|
||||
if (!key) return;
|
||||
const minIn = tr.querySelector('[data-bound="min"]');
|
||||
const maxIn = tr.querySelector('[data-bound="max"]');
|
||||
const minRaw = minIn?.value?.trim();
|
||||
const maxRaw = maxIn?.value?.trim();
|
||||
const min = minRaw === "" ? null : Number(minRaw);
|
||||
const max = maxRaw === "" ? null : Number(maxRaw);
|
||||
if (min == null && max == null) {
|
||||
delete out[key];
|
||||
} else {
|
||||
out[key] = {
|
||||
min: Number.isNaN(min) ? null : min,
|
||||
max: Number.isNaN(max) ? null : max,
|
||||
};
|
||||
}
|
||||
});
|
||||
draftNorms = out;
|
||||
}
|
||||
|
||||
async function selectProfile(id) {
|
||||
selectedId = id;
|
||||
syncDeleteButton();
|
||||
renderList();
|
||||
const empty = document.getElementById("labProfilesEmpty");
|
||||
const form = document.getElementById("labProfilesForm");
|
||||
if (!id) {
|
||||
if (empty) empty.hidden = false;
|
||||
if (form) form.hidden = true;
|
||||
syncSeedPanelVisibility();
|
||||
return;
|
||||
}
|
||||
const p = await api(`/api/lab/animal-profiles/${encodeURIComponent(id)}`);
|
||||
if (empty) empty.hidden = true;
|
||||
if (form) form.hidden = false;
|
||||
document.getElementById("labProfileKey").value = p.profileKey || "";
|
||||
document.getElementById("labProfileLabel").value = p.label || "";
|
||||
document.getElementById("labProfileType").value = p.rationType || "BEEF";
|
||||
document.getElementById("labProfileMass").value = p.massKg != null ? p.massKg : "";
|
||||
document.getElementById("labProfileMilk").value = p.milkYieldKg != null ? p.milkYieldKg : "";
|
||||
document.getElementById("labProfileExternal").value = p.externalNo != null ? p.externalNo : "";
|
||||
fillNormsParamsFromProfile(p);
|
||||
draftNorms = { ...(p.norms || p.normsData?.indicators || {}) };
|
||||
normCoverage = p.coverage || p.normsData?.coverage || null;
|
||||
resolvedNormsPreview = p.resolvedNorms || p.normsData?.resolvedIndicators || {};
|
||||
normSourceMap = buildNormSourceMap(normCoverage, draftNorms);
|
||||
refreshDynamicNorms();
|
||||
syncSourceFilterOptions(readNormsMethod());
|
||||
renderNormsSummary();
|
||||
scheduleNormsCoverageRefresh();
|
||||
syncSeedPanelVisibility();
|
||||
loadSeedCatalog(p.rationType || "DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
}
|
||||
|
||||
function newProfile() {
|
||||
selectedId = null;
|
||||
syncDeleteButton();
|
||||
renderList();
|
||||
document.getElementById("labProfilesEmpty").hidden = true;
|
||||
document.getElementById("labProfilesForm").hidden = false;
|
||||
document.getElementById("labProfileKey").value = "";
|
||||
document.getElementById("labProfileLabel").value = "";
|
||||
document.getElementById("labProfileType").value = "DAIRY";
|
||||
document.getElementById("labProfileMass").value = "";
|
||||
document.getElementById("labProfileMilk").value = "";
|
||||
document.getElementById("labProfileExternal").value = "";
|
||||
fillNormsParamsFromProfile({ normsMethod: "wesp", normsParams: {} });
|
||||
draftNorms = {};
|
||||
normCoverage = null;
|
||||
resolvedNormsPreview = {};
|
||||
normSourceMap = {};
|
||||
refreshDynamicNorms();
|
||||
syncSourceFilterOptions("wesp");
|
||||
renderNormsSummary();
|
||||
syncSeedPanelVisibility();
|
||||
loadSeedCatalog("DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
collectNormsFromTable();
|
||||
const key = document.getElementById("labProfileKey")?.value?.trim();
|
||||
const label = document.getElementById("labProfileLabel")?.value?.trim();
|
||||
const rationType = document.getElementById("labProfileType")?.value || "BEEF";
|
||||
const massRaw = document.getElementById("labProfileMass")?.value?.trim();
|
||||
const milkRaw = document.getElementById("labProfileMilk")?.value?.trim();
|
||||
const extRaw = document.getElementById("labProfileExternal")?.value?.trim();
|
||||
if (!key || !label) {
|
||||
notify()?.error?.("Укажи код и название — я предупреждал");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
profileKey: key,
|
||||
label,
|
||||
rationType,
|
||||
indicators: draftNorms,
|
||||
normsMethod: readNormsMethod(),
|
||||
normsParams: readNormsParams(),
|
||||
};
|
||||
if (massRaw !== "") payload.massKg = Number(massRaw);
|
||||
payload.milkYieldKg = milkRaw === "" ? null : Number(milkRaw);
|
||||
if (extRaw !== "") payload.externalNo = Number(extRaw);
|
||||
const method = selectedId ? "PUT" : "POST";
|
||||
const url = selectedId
|
||||
? `/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`
|
||||
: "/api/lab/animal-profiles";
|
||||
await api(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
notify()?.success?.("Стандарт сохранён. Ты чертовски прав");
|
||||
const typeFilter = document.getElementById("labProfilesType")?.value || "";
|
||||
const data = await api(
|
||||
`/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}`
|
||||
);
|
||||
profiles = data.profiles || [];
|
||||
if (!selectedId && profiles.length) {
|
||||
const hit = profiles.find((p) => p.profileKey === key);
|
||||
if (hit) selectedId = hit.id;
|
||||
}
|
||||
renderList();
|
||||
if (selectedId) await selectProfile(selectedId);
|
||||
}
|
||||
|
||||
async function deleteProfile() {
|
||||
if (!selectedId) return;
|
||||
const label = document.getElementById("labProfileLabel")?.value?.trim() || selectedId;
|
||||
if (!global.confirm(`Удалить «${label}»? Никаких полумер.`)) return;
|
||||
await api(`/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`, { method: "DELETE" });
|
||||
notify()?.success?.("Стандарт снесён. Say my name.");
|
||||
selectedId = null;
|
||||
syncDeleteButton();
|
||||
const typeFilter = document.getElementById("labProfilesType")?.value || "";
|
||||
const data = await api(
|
||||
`/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}`
|
||||
);
|
||||
profiles = data.profiles || [];
|
||||
document.getElementById("labProfilesEmpty").hidden = false;
|
||||
document.getElementById("labProfilesForm").hidden = true;
|
||||
renderList();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const [cat, prof] = await Promise.all([
|
||||
api("/api/lab/norm-indicators"),
|
||||
api("/api/lab/animal-profiles"),
|
||||
]);
|
||||
catalog = cat.indicators || [];
|
||||
profiles = prof.profiles || [];
|
||||
renderList();
|
||||
}
|
||||
|
||||
function bind() {
|
||||
document.getElementById("labProfilesList")?.addEventListener("click", (ev) => {
|
||||
const btn = ev.target.closest("button[data-id]");
|
||||
if (!btn) return;
|
||||
selectProfile(btn.getAttribute("data-id")).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesType")?.addEventListener("change", async () => {
|
||||
const type = document.getElementById("labProfilesType")?.value || "";
|
||||
const data = await api(
|
||||
`/api/lab/animal-profiles${type ? `?ration_type=${encodeURIComponent(type)}` : ""}`
|
||||
);
|
||||
profiles = data.profiles || [];
|
||||
renderList();
|
||||
});
|
||||
document.getElementById("labProfilesSearch")?.addEventListener("input", renderList);
|
||||
document.getElementById("labProfilesOnlyFilled")?.addEventListener("change", renderNormsTable);
|
||||
document.getElementById("labProfilesOnlyCalc")?.addEventListener("change", renderNormsTable);
|
||||
document.getElementById("labProfilesSourceFilter")?.addEventListener("change", renderNormsTable);
|
||||
document.getElementById("labProfilesNormSearch")?.addEventListener("input", renderNormsTable);
|
||||
document.getElementById("labProfilesNormsRefresh")?.addEventListener("click", () => {
|
||||
refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesNew")?.addEventListener("click", newProfile);
|
||||
document.getElementById("labProfilesSave")?.addEventListener("click", () => {
|
||||
saveProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesDelete")?.addEventListener("click", () => {
|
||||
deleteProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesNormsBody")?.addEventListener("change", (ev) => {
|
||||
if (ev.target.matches("input[data-bound]")) collectNormsFromTable();
|
||||
});
|
||||
document.getElementById("labProfileMass")?.addEventListener("input", () => {
|
||||
refreshDynamicNorms();
|
||||
scheduleNormsCoverageRefresh();
|
||||
});
|
||||
document.getElementById("labProfileMilk")?.addEventListener("input", () => {
|
||||
refreshDynamicNorms();
|
||||
scheduleNormsCoverageRefresh();
|
||||
});
|
||||
document.getElementById("labProfileNormsMethod")?.addEventListener("change", syncNormsMethodUi);
|
||||
["labProfileFat", "labProfileLactation", "labProfileKonc"].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener("input", scheduleNormsCoverageRefresh);
|
||||
});
|
||||
document.getElementById("labProfileType")?.addEventListener("change", () => {
|
||||
seedCatalogRation = null;
|
||||
loadSeedCatalog(readRationType()).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesSeedSearch")?.addEventListener("input", renderSeedSelect);
|
||||
document.getElementById("labProfilesSeedSelect")?.addEventListener("change", () => {
|
||||
const applyBtn = document.getElementById("labProfilesSeedApply");
|
||||
const sel = document.getElementById("labProfilesSeedSelect");
|
||||
if (applyBtn && sel) applyBtn.disabled = !sel.value;
|
||||
});
|
||||
document.getElementById("labProfilesSeedApply")?.addEventListener("click", () => {
|
||||
applySeedNorms().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
document.getElementById("labProfilesGfeApplyUsp")?.addEventListener("click", () => applyGfeMin(["usp"]));
|
||||
document.getElementById("labProfilesGfeApplyNel")?.addEventListener("click", () => applyGfeMin(["nel"]));
|
||||
document.getElementById("labProfilesGfeApplyAll")?.addEventListener("click", () =>
|
||||
applyGfeMin(GFE_DYNAMIC_KEYS)
|
||||
);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bind();
|
||||
init().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" }));
|
||||
});
|
||||
})(window);
|
||||
@@ -0,0 +1,299 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
let diffByComponent = new Map();
|
||||
let hooked = false;
|
||||
|
||||
function getActiveRecipeId() {
|
||||
const fromEdit = document.getElementById("recipeEdit")?.dataset?.recipeId;
|
||||
if (fromEdit) return fromEdit;
|
||||
return document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId || null;
|
||||
}
|
||||
|
||||
function ensureToolbarButtons() {
|
||||
const toolbar = document.querySelector("#recipeEdit .recipe-section-toolbar");
|
||||
if (!toolbar) return;
|
||||
if (!toolbar.querySelector('[data-action="sync-to-master"]')) {
|
||||
const syncBtn = document.createElement("button");
|
||||
syncBtn.type = "button";
|
||||
syncBtn.className = "btn btn-outline-secondary";
|
||||
syncBtn.setAttribute("data-action", "sync-to-master");
|
||||
syncBtn.innerHTML = '<i class="fas fa-arrow-up me-1"></i>В мастер';
|
||||
toolbar.insertBefore(syncBtn, toolbar.firstChild);
|
||||
}
|
||||
if (!toolbar.querySelector('[data-action="apply-from-master"]')) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn btn-outline-primary";
|
||||
btn.setAttribute("data-action", "apply-from-master");
|
||||
btn.hidden = true;
|
||||
btn.innerHTML = '<i class="fas fa-arrow-down me-1"></i>Из мастера';
|
||||
const addBtn = toolbar.querySelector('[data-action="add-ingredient"]');
|
||||
if (addBtn) toolbar.insertBefore(btn, addBtn);
|
||||
else toolbar.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureKHubButton() {
|
||||
const actions = document.querySelector("#recipeEdit .recipe-edit-header-actions .d-flex");
|
||||
if (!actions || actions.querySelector('[data-action="open-lab-k-hub"]')) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn btn-outline-secondary recipe-edit-header-icon-btn";
|
||||
btn.setAttribute("data-action", "open-lab-k-hub");
|
||||
btn.title = "Открыть зоотех-мастер в K-hub";
|
||||
btn.setAttribute("aria-label", "K-hub");
|
||||
btn.innerHTML = '<i class="fas fa-flask" aria-hidden="true"></i>';
|
||||
actions.insertBefore(btn, actions.firstChild);
|
||||
}
|
||||
|
||||
function ensureRationTypeField() {
|
||||
const grid = document.querySelector("#recipeEdit .recipe-form-grid");
|
||||
if (!grid || document.getElementById("rationType")) return;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-group";
|
||||
wrap.innerHTML =
|
||||
'<label for="rationType">Тип стада</label>' +
|
||||
'<select class="form-control" id="rationType">' +
|
||||
'<option value="">—</option><option value="DAIRY">Дойное</option><option value="BEEF">Мясное</option>' +
|
||||
"</select>";
|
||||
const tripWrap = document.getElementById("tripPercent")?.closest(".form-group");
|
||||
if (tripWrap?.parentElement === grid) {
|
||||
tripWrap.insertAdjacentElement("afterend", wrap);
|
||||
} else {
|
||||
grid.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDiffStyles() {
|
||||
if (document.getElementById("wesp-lab-diff-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wesp-lab-diff-styles";
|
||||
style.textContent =
|
||||
".lab-diff-badge{margin-left:6px;font-size:.7rem;vertical-align:middle;}" +
|
||||
".lab-diff-badge--warn{background:#fff3cd;color:#664d03;}" +
|
||||
".lab-diff-badge--miss{background:#f8d7da;color:#842029;}" +
|
||||
".lab-recipe-quality{margin-top:4px;font-size:.78rem;color:var(--bs-secondary-color,#6c757d);}" +
|
||||
".lab-recipe-quality__label{font-weight:600;margin-right:4px;}";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function diffBadgeHtml(reasons) {
|
||||
if (!reasons?.length) return "";
|
||||
const title = reasons.join(", ");
|
||||
const cls = reasons.includes("missing_in_master") || reasons.includes("missing_in_execution")
|
||||
? "lab-diff-badge--miss"
|
||||
: "lab-diff-badge--warn";
|
||||
return `<span class="badge lab-diff-badge ${cls}" title="${title}">мастер</span>`;
|
||||
}
|
||||
|
||||
function applyDiffBadges() {
|
||||
document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach((row) => {
|
||||
const select = row.querySelector(".ingredient-select");
|
||||
const cell = row.querySelector('.ingredient-detail-cell[data-label="Ингредиент"] .recipe-row-name-with-skip');
|
||||
if (!select || !cell) return;
|
||||
cell.querySelectorAll(".lab-diff-badge").forEach((el) => el.remove());
|
||||
const compId = select.value;
|
||||
if (!compId) return;
|
||||
const reasons = diffByComponent.get(String(compId));
|
||||
if (!reasons?.length) return;
|
||||
cell.insertAdjacentHTML("beforeend", diffBadgeHtml(reasons));
|
||||
});
|
||||
}
|
||||
|
||||
function ensureQualityBlock() {
|
||||
const headerText = document.querySelector("#recipeEdit .recipe-edit-header-text");
|
||||
if (!headerText || document.getElementById("labRecipeQuality")) return;
|
||||
const block = document.createElement("div");
|
||||
block.id = "labRecipeQuality";
|
||||
block.className = "lab-recipe-quality";
|
||||
block.hidden = true;
|
||||
block.innerHTML =
|
||||
'<span class="lab-recipe-quality__label">Зоотех:</span>' +
|
||||
'<span class="lab-recipe-quality__items" data-lab-quality-items></span>';
|
||||
headerText.appendChild(block);
|
||||
}
|
||||
|
||||
async function refreshQuality(recipeId) {
|
||||
ensureQualityBlock();
|
||||
const block = document.getElementById("labRecipeQuality");
|
||||
const items = block?.querySelector("[data-lab-quality-items]");
|
||||
if (!block || !items || !recipeId) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}`);
|
||||
if (!resp.ok) throw new Error("no ration");
|
||||
const data = await resp.json();
|
||||
const indicators = (data.rationResults?.indicators || []).slice(0, 4);
|
||||
if (!data.exists || !indicators.length) {
|
||||
block.hidden = true;
|
||||
return;
|
||||
}
|
||||
items.textContent = indicators
|
||||
.map((ind) => `${ind.label}: ${ind.content ?? "—"} ${ind.unit || ""}`.trim())
|
||||
.join(" · ");
|
||||
block.hidden = false;
|
||||
} catch (_) {
|
||||
block.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDiff(recipeId) {
|
||||
const btn = document.querySelector('[data-action="apply-from-master"]');
|
||||
if (!recipeId) return;
|
||||
await refreshQuality(recipeId);
|
||||
if (!btn) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/diff`);
|
||||
const data = await resp.json();
|
||||
diffByComponent = new Map();
|
||||
(data.lines || []).forEach((line) => {
|
||||
if (line.componentId) diffByComponent.set(String(line.componentId), line.reasons || []);
|
||||
});
|
||||
btn.hidden = !data.hasChanges;
|
||||
applyDiffBadges();
|
||||
} catch (_) {
|
||||
btn.hidden = true;
|
||||
diffByComponent = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function patchRecipeLoad() {
|
||||
if (hooked) return;
|
||||
const orig = global.loadRecipeDetails;
|
||||
if (typeof orig !== "function") return;
|
||||
global.loadRecipeDetails = async function (recipeId, options) {
|
||||
const recipeEdit = document.getElementById("recipeEdit");
|
||||
if (recipeEdit && recipeId) recipeEdit.dataset.recipeId = String(recipeId);
|
||||
await orig(recipeId, options);
|
||||
await refreshDiff(recipeId);
|
||||
global.WespLabRecipesPlanOverlay?.decorateRows?.();
|
||||
};
|
||||
hooked = true;
|
||||
}
|
||||
|
||||
function patchRecipeSave() {
|
||||
const orig = global.doSaveRecipe;
|
||||
if (typeof orig !== "function" || orig.__labPatched) return;
|
||||
global.doSaveRecipe = async function (closeAfterSave) {
|
||||
const rationEl = document.getElementById("rationType");
|
||||
const prev = global.__wespLabRationTypeForSave;
|
||||
if (rationEl) {
|
||||
global.__wespLabRationTypeForSave = rationEl.value || null;
|
||||
}
|
||||
try {
|
||||
return await orig(closeAfterSave);
|
||||
} finally {
|
||||
global.__wespLabRationTypeForSave = prev;
|
||||
}
|
||||
};
|
||||
const origFetch = global.fetch.bind(global);
|
||||
global.fetch = async function (input, init) {
|
||||
const url = typeof input === "string" ? input : input?.url;
|
||||
const method = (init?.method || "GET").toUpperCase();
|
||||
if (
|
||||
method === "PUT" &&
|
||||
url &&
|
||||
/\/api\/recipes\/[^/]+$/.test(url) &&
|
||||
init?.body &&
|
||||
global.__wespLabRationTypeForSave !== undefined
|
||||
) {
|
||||
try {
|
||||
const body = JSON.parse(init.body);
|
||||
body.ration_type = global.__wespLabRationTypeForSave || null;
|
||||
init = { ...init, body: JSON.stringify(body) };
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return origFetch(input, init);
|
||||
};
|
||||
global.doSaveRecipe.__labPatched = true;
|
||||
}
|
||||
|
||||
document.addEventListener("click", async (ev) => {
|
||||
const btn = ev.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute("data-action");
|
||||
const recipeId = getActiveRecipeId();
|
||||
|
||||
if (action === "open-lab-k-hub") {
|
||||
global.WespZootechNotificationCenter?.openDailyPlan?.(recipeId || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "sync-to-master") {
|
||||
if (!recipeId) return;
|
||||
const ok = await global.WespDialog?.confirm?.(
|
||||
"Сохранить текущий рецепт в зоотех-мастер?",
|
||||
{ title: "В мастер" }
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/lab/rations/${encodeURIComponent(recipeId)}/sync-from-execution`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.message || "Ошибка");
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер обновлён из рецепта");
|
||||
await refreshDiff(recipeId);
|
||||
} catch (e) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action !== "apply-from-master") return;
|
||||
if (!recipeId) return;
|
||||
const ok = await global.WespDialog?.confirm?.("Перенести ингредиенты из зоотех-мастера?", {
|
||||
title: "Из мастера",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.message || "Ошибка");
|
||||
if (typeof global.reloadRecipeEditor === "function") {
|
||||
await global.reloadRecipeEditor(recipeId);
|
||||
} else if (typeof global.loadRecipeDetails === "function") {
|
||||
await global.loadRecipeDetails(recipeId);
|
||||
}
|
||||
await refreshDiff(recipeId);
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер применён к рецепту");
|
||||
} catch (e) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось применить мастер" });
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", (ev) => {
|
||||
if (!ev.target?.matches?.(".ingredient-select")) return;
|
||||
applyDiffBadges();
|
||||
});
|
||||
|
||||
global.WespLabRecipesOverlay = {
|
||||
init() {
|
||||
ensureDiffStyles();
|
||||
ensureToolbarButtons();
|
||||
ensureKHubButton();
|
||||
ensureRationTypeField();
|
||||
patchRecipeLoad();
|
||||
patchRecipeSave();
|
||||
},
|
||||
refreshDiff,
|
||||
refreshQuality,
|
||||
applyDiffBadges,
|
||||
};
|
||||
|
||||
function boot() {
|
||||
global.WespLabRecipesOverlay.init();
|
||||
if (typeof global.loadRecipeDetails === "function") patchRecipeLoad();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})(window);
|
||||
@@ -0,0 +1,252 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
let observer = null;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function planDate() {
|
||||
return (
|
||||
global.WespDailyPlanPanel?.getSelectedPlanDate?.() ||
|
||||
global.WespDailyPlanSkipDuration?.todayIso?.() ||
|
||||
new Date().toISOString().slice(0, 10)
|
||||
);
|
||||
}
|
||||
|
||||
function getRecipeId() {
|
||||
return (
|
||||
document.getElementById("recipeEdit")?.dataset?.recipeId ||
|
||||
document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function notifySuccess(message) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.(message);
|
||||
}
|
||||
|
||||
function notifyError(message, fallback) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(message, { fallback: fallback || message });
|
||||
}
|
||||
|
||||
async function reloadEditor() {
|
||||
const recipeId = getRecipeId();
|
||||
if (!recipeId) return;
|
||||
if (typeof global.reloadRecipeEditor === "function") {
|
||||
await global.reloadRecipeEditor(recipeId);
|
||||
} else if (typeof global.loadRecipeDetailsWithPlanOverlay === "function") {
|
||||
await global.loadRecipeDetailsWithPlanOverlay();
|
||||
} else if (typeof global.loadRecipeDetails === "function") {
|
||||
await global.loadRecipeDetails(recipeId);
|
||||
}
|
||||
}
|
||||
|
||||
function planBtnHtml(action, title, icon, attrs = "") {
|
||||
return (
|
||||
`<button type="button" class="btn btn-outline-secondary btn-sm recipe-plan-action-btn" ` +
|
||||
`data-action="${action}" title="${escapeHtml(title)}" ${attrs}>` +
|
||||
`<i class="fas ${icon}"></i></button>`
|
||||
);
|
||||
}
|
||||
|
||||
function decorateRow(row) {
|
||||
const actions = row.querySelector(".recipe-table-row-actions");
|
||||
if (!actions || actions.querySelector(".recipe-plan-actions")) return;
|
||||
|
||||
const ingredientId = row.dataset.id;
|
||||
if (!ingredientId) return;
|
||||
|
||||
const skipped = row.classList.contains("ingredient-row--skipped");
|
||||
const replaced = row.classList.contains("ingredient-row--replaced");
|
||||
const select = row.querySelector(".ingredient-select");
|
||||
const componentId = select?.value || "";
|
||||
const label = select?.selectedOptions?.[0]?.textContent?.trim() || "Компонент";
|
||||
const recipeId = getRecipeId();
|
||||
if (!recipeId) return;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "btn-group recipe-plan-actions me-1";
|
||||
wrap.setAttribute("role", "group");
|
||||
|
||||
if (skipped) {
|
||||
wrap.innerHTML = planBtnHtml(
|
||||
"recipe-plan-unskip-ingredient",
|
||||
"Вернуть в план",
|
||||
"fa-undo",
|
||||
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`
|
||||
);
|
||||
} else if (replaced) {
|
||||
wrap.innerHTML = planBtnHtml(
|
||||
"recipe-plan-undo-replace-ingredient",
|
||||
"Отменить замену",
|
||||
"fa-undo",
|
||||
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`
|
||||
);
|
||||
} else {
|
||||
wrap.innerHTML =
|
||||
planBtnHtml(
|
||||
"recipe-plan-skip-ingredient",
|
||||
"Убрать из плана",
|
||||
"fa-ban",
|
||||
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}" data-label="${escapeHtml(label)}"`
|
||||
) +
|
||||
(componentId
|
||||
? planBtnHtml(
|
||||
"recipe-plan-replace-ingredient",
|
||||
"Заменить в плане",
|
||||
"fa-exchange-alt",
|
||||
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}" ` +
|
||||
`data-component-id="${escapeHtml(componentId)}" data-label="${escapeHtml(label)}"`
|
||||
)
|
||||
: "");
|
||||
}
|
||||
|
||||
const deleteBtn = actions.querySelector('[data-action="remove-ingredient"]');
|
||||
if (deleteBtn) actions.insertBefore(wrap, deleteBtn);
|
||||
else actions.appendChild(wrap);
|
||||
}
|
||||
|
||||
function decorateRows() {
|
||||
document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach(decorateRow);
|
||||
}
|
||||
|
||||
function ensureStyles() {
|
||||
if (document.getElementById("wesp-lab-plan-overlay-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wesp-lab-plan-overlay-styles";
|
||||
style.textContent =
|
||||
".recipe-plan-action-btn{padding:2px 6px;}" +
|
||||
".recipe-plan-actions .btn+.btn{margin-left:2px;}";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
async function skipIngredient(recipeId, ingredientId, label) {
|
||||
const skip = global.WespDailyPlanSkipDuration;
|
||||
if (!skip?.skipDurationBody) return;
|
||||
const body = await skip.skipDurationBody(planDate(), { recipeId, ingredientId }, "На какой срок убрать компонент?");
|
||||
if (!body) return;
|
||||
const resp = await fetch("/api/daily-plan/skips/ingredients", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось убрать компонент");
|
||||
}
|
||||
await reloadEditor();
|
||||
notifySuccess(`«${label}» убран из плана`);
|
||||
}
|
||||
|
||||
async function unskipIngredient(recipeId, ingredientId) {
|
||||
const params = new URLSearchParams({ recipe_id: recipeId, ingredient_id: ingredientId, date: planDate() });
|
||||
const resp = await fetch(`/api/daily-plan/skips/ingredients?${params}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось вернуть компонент");
|
||||
}
|
||||
await reloadEditor();
|
||||
notifySuccess("Компонент снова в плане");
|
||||
}
|
||||
|
||||
async function openReplace(recipeId, ingredientId, componentId, label) {
|
||||
const replaceModal = global.WespDailyPlanReplaceModal;
|
||||
if (!replaceModal?.open) {
|
||||
notifyError("Модалка замены недоступна");
|
||||
return;
|
||||
}
|
||||
await replaceModal.open({
|
||||
currentName: label,
|
||||
onPick: async (replacementComponentId, replacementName) => {
|
||||
const skip = global.WespDailyPlanSkipDuration;
|
||||
const body = await skip?.skipDurationBody?.(
|
||||
planDate(),
|
||||
{ recipeId, ingredientId, replacementComponentId },
|
||||
"На какой срок заменить компонент?"
|
||||
);
|
||||
if (!body) return;
|
||||
const resp = await fetch("/api/daily-plan/replacements/ingredients", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось заменить компонент");
|
||||
}
|
||||
await reloadEditor();
|
||||
notifySuccess(`Компонент заменён на «${replacementName}»`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function undoReplace(recipeId, ingredientId) {
|
||||
const params = new URLSearchParams({ recipe_id: recipeId, ingredient_id: ingredientId, date: planDate() });
|
||||
const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params}`, {
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.message || "Не удалось отменить замену");
|
||||
}
|
||||
await reloadEditor();
|
||||
notifySuccess("Замена отменена");
|
||||
}
|
||||
|
||||
document.addEventListener("click", async (ev) => {
|
||||
const btn = ev.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action;
|
||||
if (!action?.startsWith("recipe-plan-")) return;
|
||||
if (!document.getElementById("recipeEdit")?.contains(btn)) return;
|
||||
ev.preventDefault();
|
||||
|
||||
const recipeId = btn.dataset.recipeId;
|
||||
const ingredientId = btn.dataset.ingredientId;
|
||||
try {
|
||||
if (action === "recipe-plan-skip-ingredient") {
|
||||
await skipIngredient(recipeId, ingredientId, btn.dataset.label || "Компонент");
|
||||
} else if (action === "recipe-plan-unskip-ingredient") {
|
||||
await unskipIngredient(recipeId, ingredientId);
|
||||
} else if (action === "recipe-plan-replace-ingredient") {
|
||||
await openReplace(recipeId, ingredientId, btn.dataset.componentId, btn.dataset.label || "Компонент");
|
||||
} else if (action === "recipe-plan-undo-replace-ingredient") {
|
||||
await undoReplace(recipeId, ingredientId);
|
||||
}
|
||||
} catch (e) {
|
||||
notifyError(e.message, "Ошибка плана");
|
||||
}
|
||||
});
|
||||
|
||||
function watchIngredients() {
|
||||
const tbody = document.getElementById("ingredientsTableBody");
|
||||
if (!tbody || observer) return;
|
||||
observer = new MutationObserver(() => decorateRows());
|
||||
observer.observe(tbody, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
global.WespLabRecipesPlanOverlay = {
|
||||
init() {
|
||||
ensureStyles();
|
||||
watchIngredients();
|
||||
decorateRows();
|
||||
},
|
||||
decorateRows,
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => global.WespLabRecipesPlanOverlay.init());
|
||||
} else {
|
||||
global.WespLabRecipesPlanOverlay.init();
|
||||
}
|
||||
})(window);
|
||||
@@ -0,0 +1,525 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const INDICATOR_LABELS = {
|
||||
dry_matter: "Сухое вещество",
|
||||
dm_main: "СВ — основной корм",
|
||||
oe: "ОЭ — КРС",
|
||||
nel: "ЧЭЛ — КРС",
|
||||
nel_per_kg_dm: "ЧЭЛ/кг СВ",
|
||||
crude_protein: "Сырой протеин",
|
||||
rup_per_kg_dm: "Нер. СП / кг СВ",
|
||||
insoluble_protein: "Нерастворимый протеин",
|
||||
usp: "уСП",
|
||||
usp_pct_dm: "% уСП/кг СВ",
|
||||
ndf: "Сырая клетчатка",
|
||||
structural_fiber: "Структур. клетчатка",
|
||||
crude_fat: "Сырой жир",
|
||||
rnb: "RNB",
|
||||
calcium: "Ca",
|
||||
phosphorus: "P",
|
||||
magnesium: "Mg",
|
||||
sodium: "Na",
|
||||
dcab: "DCAB",
|
||||
sugar_starch: "Сахар и крахмал",
|
||||
insoluble_starch: "Нераств. крахмал",
|
||||
sugar: "Сахар",
|
||||
starch: "Крахмал",
|
||||
carotene: "Каротин",
|
||||
};
|
||||
|
||||
let state = null;
|
||||
let recipeId = null;
|
||||
let components = [];
|
||||
let profiles = [];
|
||||
let normCatalog = [];
|
||||
|
||||
async function loadNormCatalog() {
|
||||
if (normCatalog.length) return normCatalog;
|
||||
try {
|
||||
const data = await api("/api/lab/norm-indicators");
|
||||
normCatalog = data.indicators || [];
|
||||
} catch (_e) {
|
||||
normCatalog = [];
|
||||
}
|
||||
return normCatalog;
|
||||
}
|
||||
|
||||
function indicatorMeta(key) {
|
||||
const fromCat = normCatalog.find((item) => item.key === key);
|
||||
if (fromCat) return fromCat;
|
||||
return { key, label: INDICATOR_LABELS[key] || key, unit: "" };
|
||||
}
|
||||
|
||||
function mergeIndicatorsForDisplay(calcRows, norms) {
|
||||
const byKey = new Map();
|
||||
for (const ind of calcRows || []) {
|
||||
if (ind?.key) byKey.set(ind.key, { ...ind });
|
||||
}
|
||||
for (const key of Object.keys(norms || {})) {
|
||||
const bounds = norms[key] || {};
|
||||
if (bounds.min == null && bounds.max == null) continue;
|
||||
const meta = indicatorMeta(key);
|
||||
const existing = byKey.get(key);
|
||||
if (existing) {
|
||||
existing.min = bounds.min ?? existing.min;
|
||||
existing.max = bounds.max ?? existing.max;
|
||||
existing.label = existing.label || meta.label;
|
||||
existing.unit = existing.unit || meta.unit || "";
|
||||
} else {
|
||||
byKey.set(key, {
|
||||
key,
|
||||
label: meta.label,
|
||||
unit: meta.unit || "",
|
||||
content: null,
|
||||
min: bounds.min,
|
||||
max: bounds.max,
|
||||
diff: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
async function api(path, opts) {
|
||||
const resp = await fetch(path, opts);
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function labError(message) {
|
||||
global.WespLabHeisenbergPrime?.error?.(message, "sandbox");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatNum(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function formatKgInput(v) {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "";
|
||||
return String(Math.round(n * 100) / 100);
|
||||
}
|
||||
|
||||
function diffClass(diff) {
|
||||
if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na";
|
||||
const d = Number(diff);
|
||||
if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok";
|
||||
return "lab-sandbox-diff--bad";
|
||||
}
|
||||
|
||||
async function loadCatalogs(rationType) {
|
||||
const [compResp, profResp] = await Promise.all([
|
||||
components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()),
|
||||
api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`),
|
||||
]);
|
||||
if (Array.isArray(compResp)) components = compResp;
|
||||
profiles = profResp.profiles || [];
|
||||
}
|
||||
|
||||
async function loadRecipes() {
|
||||
try {
|
||||
const data = await api("/api/lab/recipes");
|
||||
return data.recipes || [];
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function componentOptions(selectedId) {
|
||||
const opts = ['<option value="">— реагент —</option>'];
|
||||
components.forEach((c) => {
|
||||
const sel = String(c.id) === String(selectedId) ? " selected" : "";
|
||||
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
|
||||
});
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function profileOptions(selectedId) {
|
||||
const opts = ['<option value="">— стандарт чистоты —</option>'];
|
||||
profiles.forEach((p) => {
|
||||
const sel = String(p.id) === String(selectedId) ? " selected" : "";
|
||||
const key = p.profileKey ? `[${p.profileKey}] ` : "";
|
||||
opts.push(
|
||||
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(key)}${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
|
||||
);
|
||||
});
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function hasNormBounds(ind) {
|
||||
return ind != null && (ind.min != null || ind.max != null);
|
||||
}
|
||||
|
||||
function formatNormBounds(bounds) {
|
||||
const b = bounds || {};
|
||||
const min = b.min != null ? formatNum(b.min) : null;
|
||||
const max = b.max != null ? formatNum(b.max) : null;
|
||||
if (min != null && max != null) return `${min}–${max}`;
|
||||
if (max != null) return `≤ ${max}`;
|
||||
if (min != null) return `≥ ${min}`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
function formatIndicatorNorm(ind) {
|
||||
if (!hasNormBounds(ind)) {
|
||||
return '<span class="lab-sandbox-norm--empty" title="Для этого стандарта нет границы">вне стандарта</span>';
|
||||
}
|
||||
return formatNormBounds(ind);
|
||||
}
|
||||
|
||||
function formatIndicatorDiff(ind) {
|
||||
if (!hasNormBounds(ind)) return "—";
|
||||
return formatNum(ind.diff);
|
||||
}
|
||||
|
||||
function renderNormsPanel() {
|
||||
const el = document.getElementById("labSandboxNorms");
|
||||
if (!el) return;
|
||||
const norms = state?.norms || {};
|
||||
const keys = Object.keys(norms);
|
||||
if (!keys.length) {
|
||||
el.innerHTML = '<div class="lab-sandbox-empty">Выбери стандарт чистоты</div>';
|
||||
return;
|
||||
}
|
||||
let note = "";
|
||||
el.innerHTML =
|
||||
note +
|
||||
`<dl class="lab-sandbox-norms">` +
|
||||
keys
|
||||
.map((key) => {
|
||||
const b = norms[key] || {};
|
||||
const label = INDICATOR_LABELS[key] || key;
|
||||
return `<dt>${escapeHtml(label)}</dt><dd>${formatNormBounds(b)}</dd>`;
|
||||
})
|
||||
.join("") +
|
||||
`</dl>`;
|
||||
}
|
||||
|
||||
function renderResultsPanel() {
|
||||
const el = document.getElementById("labSandboxResults");
|
||||
if (!el) return;
|
||||
const calcRows = state?.rationResults?.indicators || [];
|
||||
const totals = state?.rationResults?.totals || [];
|
||||
const norms = state?.norms || {};
|
||||
const indicators = mergeIndicatorsForDisplay(calcRows, norms);
|
||||
if (!indicators.length && !totals.length) {
|
||||
el.innerHTML = '<div class="lab-sandbox-empty">Нажми «Примени науку»</div>';
|
||||
return;
|
||||
}
|
||||
let html = "";
|
||||
if (!Object.keys(norms).length && calcRows.length) {
|
||||
html +=
|
||||
'<p class="small text-warning mb-2">Для полной матрицы показателей выберите «Стандарт чистоты» и снова нажмите «Примени науку».</p>';
|
||||
}
|
||||
if (totals.length) {
|
||||
html +=
|
||||
`<p class="small text-muted mb-1">Синий — значит чистый</p><ul class="small mb-2">` +
|
||||
totals.map((t) => `<li>${escapeHtml(t.label)}: ${formatNum(t.value)}</li>`).join("") +
|
||||
`</ul>`;
|
||||
}
|
||||
if (indicators.length) {
|
||||
const withNorm = indicators.filter((ind) => hasNormBounds(ind)).length;
|
||||
const rows = indicators.filter((ind) => hasNormBounds(ind) || ind.content != null);
|
||||
const sorted = rows.slice().sort((a, b) => {
|
||||
const an = hasNormBounds(a) ? 0 : 1;
|
||||
const bn = hasNormBounds(b) ? 0 : 1;
|
||||
if (an !== bn) return an - bn;
|
||||
return String(a.label).localeCompare(String(b.label), "ru");
|
||||
});
|
||||
html +=
|
||||
`<p class="small text-muted mb-1">Строк: ${sorted.length} (со стандартом: ${withNorm}). ≤/≥ — границы чистоты.</p>` +
|
||||
`<table><thead><tr><th>Показатель</th><th>Факт</th><th>Стандарт</th><th>Δ</th></tr></thead><tbody>` +
|
||||
sorted
|
||||
.map((ind) => {
|
||||
const dc = hasNormBounds(ind) ? diffClass(ind.diff) : "lab-sandbox-diff--na";
|
||||
return (
|
||||
`<tr>` +
|
||||
`<td>${escapeHtml(ind.label)}</td>` +
|
||||
`<td>${formatNum(ind.content)} ${escapeHtml(ind.unit || "")}</td>` +
|
||||
`<td>${formatIndicatorNorm(ind)}</td>` +
|
||||
`<td class="${dc}">${formatIndicatorDiff(ind)}</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
})
|
||||
.join("") +
|
||||
`</tbody></table>`;
|
||||
}
|
||||
if (state?.calculatedAt) {
|
||||
html += `<p class="small text-muted mt-2 mb-0">Готово: ${escapeHtml(state.calculatedAt)}</p>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderLines() {
|
||||
const tbody = document.getElementById("labSandboxLinesBody");
|
||||
if (!tbody) return;
|
||||
const lines = state?.lines || [];
|
||||
if (!lines.length) {
|
||||
tbody.innerHTML =
|
||||
`<tr><td colspan="6" class="lab-sandbox-empty">Партия пуста — добавь реагент или возьми из WESP</td></tr>`;
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = lines
|
||||
.map(
|
||||
(line, i) =>
|
||||
`<tr data-line-idx="${i}">` +
|
||||
`<td><select class="form-select form-select-sm" data-field="componentId">${componentOptions(line.componentId)}</select></td>` +
|
||||
`<td><input class="form-control form-control-sm" data-field="dailyKg" type="number" step="0.01" value="${formatKgInput(line.dailyKg)}"></td>` +
|
||||
`<td class="text-center"><input type="checkbox" data-field="inRation" ${line.inRation ? "checked" : ""}></td>` +
|
||||
`<td class="text-center"><input type="checkbox" data-field="inCompound" ${line.inCompound ? "checked" : ""}></td>` +
|
||||
`<td class="small text-muted">${line.pricePerKg != null ? formatNum(line.pricePerKg) : "—"}</td>` +
|
||||
`<td><button type="button" class="btn btn-sm btn-link text-danger p-0" data-action="remove-line" data-idx="${i}">×</button></td>` +
|
||||
`</tr>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderSetup() {
|
||||
const recipeSel = document.getElementById("labSandboxRecipe");
|
||||
const profileSel = document.getElementById("labSandboxProfile");
|
||||
const typeSel = document.getElementById("labSandboxRationType");
|
||||
if (profileSel) profileSel.innerHTML = profileOptions(state?.animalProfileId);
|
||||
if (typeSel && state?.rationType) typeSel.value = state.rationType;
|
||||
if (profileSel && state?.animalProfileId) profileSel.value = state.animalProfileId;
|
||||
if (recipeSel && recipeId) recipeSel.value = recipeId;
|
||||
const meta = document.getElementById("labSandboxMeta");
|
||||
if (meta && state) {
|
||||
meta.textContent = `${state.recipeName || ""} · ${state.headsPerTrip || 1} гол. · кг/сут на стадо · ${state.exists ? "формула есть" : "формула пуста — готовим?"}`;
|
||||
}
|
||||
}
|
||||
|
||||
function collectLines() {
|
||||
const tbody = document.getElementById("labSandboxLinesBody");
|
||||
if (!tbody || !state) return state.lines || [];
|
||||
return (state.lines || []).map((line, i) => {
|
||||
const row = tbody.querySelector(`tr[data-line-idx="${i}"]`);
|
||||
if (!row) return line;
|
||||
const componentId = row.querySelector('[data-field="componentId"]')?.value || null;
|
||||
const comp = components.find((c) => String(c.id) === String(componentId));
|
||||
return {
|
||||
...line,
|
||||
id: line.id,
|
||||
componentId,
|
||||
ingredientName: comp?.name || line.ingredientName,
|
||||
dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null,
|
||||
inRation: row.querySelector('[data-field="inRation"]')?.checked,
|
||||
inCompound: row.querySelector('[data-field="inCompound"]')?.checked,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function collectPayload() {
|
||||
return {
|
||||
lines: collectLines(),
|
||||
rationType: document.getElementById("labSandboxRationType")?.value || state?.rationType,
|
||||
animalProfileId: document.getElementById("labSandboxProfile")?.value || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function reloadRation() {
|
||||
if (!recipeId) return;
|
||||
await loadNormCatalog();
|
||||
state = await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`);
|
||||
if (state) {
|
||||
state.normsProfileKey = state.normsProfileKey || null;
|
||||
state.legacyNormsRemapped = Boolean(state.legacyNormsRemapped);
|
||||
}
|
||||
await loadCatalogs(state.rationType);
|
||||
if (state.legacyNormsRemapped && state.normsProfileKey) {
|
||||
const fp = profiles.find((p) => p.profileKey === state.normsProfileKey);
|
||||
if (fp) state.animalProfileId = fp.id;
|
||||
}
|
||||
renderSetup();
|
||||
renderLines();
|
||||
renderNormsPanel();
|
||||
renderResultsPanel();
|
||||
}
|
||||
|
||||
async function ensureMaster() {
|
||||
if (!recipeId) return;
|
||||
if (state?.exists) return;
|
||||
const ok = await global.WespDialog?.confirm?.("Взять состав из WESP и начать готовку?", {
|
||||
title: "Нам нужно готовить",
|
||||
});
|
||||
if (ok) {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" });
|
||||
} else {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, { method: "POST" });
|
||||
}
|
||||
await reloadRation();
|
||||
}
|
||||
|
||||
async function initRecipeSelect() {
|
||||
const sel = document.getElementById("labSandboxRecipe");
|
||||
if (!sel) return;
|
||||
const recipes = await loadRecipes();
|
||||
sel.innerHTML =
|
||||
'<option value="">— партия —</option>' +
|
||||
recipes.map((r) => `<option value="${escapeHtml(r.id)}">${escapeHtml(r.name)}</option>`).join("");
|
||||
const params = new URLSearchParams(global.location.search);
|
||||
const q = params.get("recipe");
|
||||
if (q) {
|
||||
sel.value = q;
|
||||
recipeId = q;
|
||||
await reloadRation();
|
||||
await ensureMaster();
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.getElementById("labSandboxRecipe")?.addEventListener("change", async (ev) => {
|
||||
recipeId = ev.target.value || null;
|
||||
if (!recipeId) {
|
||||
state = null;
|
||||
renderLines();
|
||||
renderResultsPanel();
|
||||
return;
|
||||
}
|
||||
await reloadRation();
|
||||
await ensureMaster();
|
||||
});
|
||||
|
||||
document.getElementById("labSandboxProfile")?.addEventListener("change", async (ev) => {
|
||||
const pid = ev.target.value;
|
||||
if (!pid) {
|
||||
if (state) state.norms = {};
|
||||
renderNormsPanel();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await api(`/api/lab/animal-profiles/${encodeURIComponent(pid)}`);
|
||||
if (state) {
|
||||
state.animalProfileId = pid;
|
||||
state.norms =
|
||||
profile.resolvedNorms ||
|
||||
profile.normsData?.resolvedIndicators ||
|
||||
profile.normsData?.indicators ||
|
||||
{};
|
||||
state.normsProfileKey = profile.normsProfileKey || profile.profileKey;
|
||||
state.legacyNormsRemapped = Boolean(profile.legacyNormsRemapped);
|
||||
if (profile.rationType) state.rationType = profile.rationType;
|
||||
}
|
||||
const typeSel = document.getElementById("labSandboxRationType");
|
||||
if (typeSel && profile.rationType) typeSel.value = profile.rationType;
|
||||
renderNormsPanel();
|
||||
} catch (e) {
|
||||
labError(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("labSandboxRationType")?.addEventListener("change", async (ev) => {
|
||||
await loadCatalogs(ev.target.value);
|
||||
renderSetup();
|
||||
});
|
||||
|
||||
document.getElementById("labSandboxLinesBody")?.addEventListener("click", (ev) => {
|
||||
const btn = ev.target.closest("[data-action='remove-line']");
|
||||
if (!btn || !state) return;
|
||||
const idx = parseInt(btn.getAttribute("data-idx"), 10);
|
||||
if (!Number.isNaN(idx)) {
|
||||
state.lines = (state.lines || []).filter((_, i) => i !== idx);
|
||||
renderLines();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-action='add-line']")?.addEventListener("click", () => {
|
||||
if (!state) state = { lines: [] };
|
||||
state.lines = [
|
||||
...(state.lines || []),
|
||||
{ componentId: "", ingredientName: "", dailyKg: 0, inRation: true, inCompound: false },
|
||||
];
|
||||
renderLines();
|
||||
});
|
||||
|
||||
document.querySelector("[data-action='save']")?.addEventListener("click", async () => {
|
||||
if (!recipeId) {
|
||||
labError("Сначала выбери партию — без этого не готовим");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(collectPayload()),
|
||||
});
|
||||
await reloadRation();
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Запомни это — формула сохранена");
|
||||
} catch (e) {
|
||||
labError(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-action='recalculate']")?.addEventListener("click", async () => {
|
||||
if (!recipeId) {
|
||||
labError("Сначала выбери партию — науку некуда применять");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(collectPayload()),
|
||||
});
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, { method: "POST" });
|
||||
await reloadRation();
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Применена наука — 99,1% чистоты");
|
||||
} catch (e) {
|
||||
labError(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-action='seed']")?.addEventListener("click", async () => {
|
||||
if (!recipeId) {
|
||||
labError("Сначала выбери партию — нечего брать с поля");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" });
|
||||
await reloadRation();
|
||||
} catch (e) {
|
||||
labError(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("[data-action='apply']")?.addEventListener("click", async () => {
|
||||
if (!recipeId) {
|
||||
labError("Сначала выбери партию — некуда отправлять");
|
||||
return;
|
||||
}
|
||||
const ok = await global.WespDialog?.confirm?.("Отправить кг/сут из лаборатории в WESP?");
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, { method: "POST" });
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Отправлено в WESP. Ты чертовски прав");
|
||||
} catch (e) {
|
||||
labError(e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
loadNormCatalog();
|
||||
initRecipeSelect();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(window);
|
||||
@@ -0,0 +1,284 @@
|
||||
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=6";
|
||||
import {
|
||||
markLoginTransition,
|
||||
storePreloadedApiPayload,
|
||||
PRELOAD_FEED_DISPENSERS_KEY,
|
||||
} from "/static/js/wesp-nav-preload.js";
|
||||
|
||||
const LS_LOGIN = "wesp_saved_login";
|
||||
const LS_REMEMBER = "wesp_remember_login";
|
||||
const THEME_STORAGE_KEY = "theme";
|
||||
const POST_LOGIN_REDIRECT_DELAY_MS = 300;
|
||||
/** Длительность анимации логотипа после успешного входа (на странице login). */
|
||||
const LOGIN_EXIT_ANIM_MS = 3500;
|
||||
|
||||
const RECIPES_WARM_URLS = [
|
||||
"/recipes",
|
||||
"/static/css/bootstrap.min.css",
|
||||
"/static/css/wesp-zootech-shell.css",
|
||||
"/static/css/wesp-zootech-layout.css",
|
||||
"/static/css/wesp-zootech-components.css",
|
||||
"/static/css/wesp-recipes-skeleton.css",
|
||||
"/static/css/wesp-logo-loader.css",
|
||||
"/static/css/notyf.min.css",
|
||||
"/static/css/font-awesome.min.css",
|
||||
"/static/js/wesp-theme-boot.js",
|
||||
"/static/js/wesp-logo-loader.js?v=6",
|
||||
"/static/js/wesp-page-enter.js",
|
||||
"/static/js/wesp-nav-logo-shimmer.js",
|
||||
"/static/js/wesp-zootech-nav.js",
|
||||
"/static/js/notyf.min.js",
|
||||
"/static/js/bootstrap.bundle.min.js",
|
||||
"/static/js/wesp-dialog.js",
|
||||
"/static/js/pages/recipes-page.js",
|
||||
"/static/js/pages/recipes-auth-settings.js",
|
||||
"/static/js/pages/recipes-data-controller.js",
|
||||
"/static/js/pages/recipes-operations-controller.js",
|
||||
"/static/js/pages/recipes-editor-controller.js",
|
||||
"/static/js/modules/app-state.js",
|
||||
];
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function preloadUrl(url) {
|
||||
return fetch(url, { credentials: "same-origin" }).catch(() => null);
|
||||
}
|
||||
|
||||
async function preloadRecipesAfterLogin() {
|
||||
markLoginTransition();
|
||||
|
||||
const apiTask = preloadUrl("/api/feed_dispensers").then(async (response) => {
|
||||
if (!response?.ok) return;
|
||||
const payload = await response.json();
|
||||
if (Array.isArray(payload)) {
|
||||
storePreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY, payload);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled([
|
||||
apiTask,
|
||||
...RECIPES_WARM_URLS.map((url) => preloadUrl(url)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function finishLoginTransition(target) {
|
||||
const preloadPromise = preloadRecipesAfterLogin();
|
||||
await playLoginExitAnimation();
|
||||
await Promise.all([preloadPromise, delay(POST_LOGIN_REDIRECT_DELAY_MS)]);
|
||||
}
|
||||
|
||||
function themeForLogoLoader() {
|
||||
try {
|
||||
const saved = (localStorage.getItem(THEME_STORAGE_KEY) || "").trim().toLowerCase();
|
||||
if (saved === "light" || saved === "organic" || saved === "dark") return saved;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
if (attr === "light" || attr === "organic" || attr === "dark") return attr;
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function isDarkTheme() {
|
||||
return themeForLogoLoader() === "dark";
|
||||
}
|
||||
|
||||
function safeNextPath(raw) {
|
||||
if (raw == null || typeof raw !== "string") return null;
|
||||
const s = raw.trim();
|
||||
if (!s || s.charAt(0) !== "/") return null;
|
||||
if (s.indexOf("//") === 0) return null;
|
||||
if (s.indexOf("://") !== -1) return null;
|
||||
if (s.indexOf("\0") !== -1 || s.indexOf("\r") !== -1 || s.indexOf("\n") !== -1) return null;
|
||||
const pathOnly = s.split("?", 1)[0];
|
||||
if (pathOnly.indexOf("@") !== -1) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
function postLoginRedirectUrl() {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const n = safeNextPath(params.get("next"));
|
||||
if (n) return n;
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
return "/recipes";
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const errorDiv = document.getElementById("errorMessage");
|
||||
if (!errorDiv) return;
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.style.display = "block";
|
||||
window.setTimeout(() => {
|
||||
errorDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function setLoading(loading) {
|
||||
const button = document.getElementById("authButton");
|
||||
const buttonText = document.getElementById("buttonText");
|
||||
const spinner = document.getElementById("loadingSpinner");
|
||||
const loginInput = document.getElementById("loginInput");
|
||||
const passwordInput = document.getElementById("passwordInput");
|
||||
const rememberInput = document.getElementById("rememberInput");
|
||||
|
||||
if (button) button.disabled = loading;
|
||||
if (loginInput) loginInput.disabled = loading;
|
||||
if (passwordInput) passwordInput.disabled = loading;
|
||||
if (rememberInput) rememberInput.disabled = loading;
|
||||
|
||||
if (buttonText && spinner) {
|
||||
buttonText.style.display = loading ? "none" : "block";
|
||||
spinner.style.display = loading ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
function applyRememberToStorage(login, remember) {
|
||||
if (remember) {
|
||||
try {
|
||||
localStorage.setItem(LS_LOGIN, login);
|
||||
localStorage.setItem(LS_REMEMBER, "1");
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
localStorage.removeItem(LS_LOGIN);
|
||||
localStorage.removeItem(LS_REMEMBER);
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadRememberFromStorage() {
|
||||
try {
|
||||
if (localStorage.getItem(LS_REMEMBER) === "1") {
|
||||
const saved = localStorage.getItem(LS_LOGIN);
|
||||
if (saved) {
|
||||
const loginInput = document.getElementById("loginInput");
|
||||
const rememberInput = document.getElementById("rememberInput");
|
||||
if (loginInput) loginInput.value = saved;
|
||||
if (rememberInput) rememberInput.checked = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
const r = await fetch("/api/auth/check", { credentials: "same-origin" });
|
||||
const data = await r.json();
|
||||
if (data.authenticated && data.remember_login) {
|
||||
const target = postLoginRedirectUrl();
|
||||
await finishLoginTransition(target);
|
||||
window.location.href = target;
|
||||
}
|
||||
} catch (e) {
|
||||
/* stay on login */
|
||||
}
|
||||
}
|
||||
|
||||
function playLoginExitAnimation() {
|
||||
return new Promise((resolve) => {
|
||||
const page = document.querySelector(".z-login-page");
|
||||
if (page) page.classList.add("z-login-page--hidden");
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "z-login-backdrop";
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "z-login-loader";
|
||||
const root = document.createElement("div");
|
||||
wrap.appendChild(root);
|
||||
document.body.appendChild(wrap);
|
||||
|
||||
mountWespLogoLoader(root, {
|
||||
loop: false,
|
||||
durationMs: LOGIN_EXIT_ANIM_MS,
|
||||
totalDurationMs: LOGIN_EXIT_ANIM_MS,
|
||||
embedded: true,
|
||||
transparentBackground: true,
|
||||
knockoutBackground: true,
|
||||
theme: themeForLogoLoader(),
|
||||
onComplete: () => resolve(),
|
||||
}).catch(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const login = document.getElementById("loginInput")?.value?.trim() || "";
|
||||
const password = document.getElementById("passwordInput")?.value || "";
|
||||
const remember = !!document.getElementById("rememberInput")?.checked;
|
||||
|
||||
if (!login) {
|
||||
showError("Введите логин");
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
showError("Введите пароль");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ login, password, remember }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
applyRememberToStorage(login, remember);
|
||||
const target = postLoginRedirectUrl();
|
||||
await finishLoginTransition(target);
|
||||
window.location.href = target;
|
||||
return;
|
||||
}
|
||||
|
||||
showError(data.message || "Неверный логин или пароль");
|
||||
const passwordInput = document.getElementById("passwordInput");
|
||||
if (passwordInput) {
|
||||
passwordInput.value = "";
|
||||
passwordInput.focus();
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
showError("Ошибка соединения. Попробуйте снова.");
|
||||
console.error("Authentication error:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function initLoginPage() {
|
||||
loadRememberFromStorage();
|
||||
checkSession();
|
||||
document.getElementById("loginInput")?.focus();
|
||||
|
||||
document.getElementById("loginForm")?.addEventListener("submit", authenticate);
|
||||
document.getElementById("loginInput")?.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") document.getElementById("passwordInput")?.focus();
|
||||
});
|
||||
document.getElementById("passwordInput")?.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") authenticate(e);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initLoginPage);
|
||||
} else {
|
||||
initLoginPage();
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { initThemePicker, initHighContrastToggle, initThemeToggle, applyTheme, getTheme } from "/static/js/wesp-theme.js";
|
||||
import {
|
||||
DEFAULT_PASSWORD_MIN_LEN,
|
||||
validateCredentialChange,
|
||||
} from "/static/js/wesp-user-credentials.js";
|
||||
|
||||
export function createRecipesAuthSettingsController({ notyf }) {
|
||||
let settingsModalClickHandler = null;
|
||||
let settingsEscapeHandler = null;
|
||||
let syncDispenserNames = [];
|
||||
let passwordMinLen = DEFAULT_PASSWORD_MIN_LEN;
|
||||
|
||||
function escapeHtmlSyncSettings(value) {
|
||||
if (value == null) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = String(value);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
const modal = document.getElementById("settingsModal");
|
||||
if (!modal) return;
|
||||
|
||||
modal.style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
|
||||
const form = document.getElementById("settingsForm");
|
||||
if (form) form.reset();
|
||||
|
||||
const credForm = document.getElementById("credentialsForm");
|
||||
if (credForm) credForm.style.display = "none";
|
||||
const userCard = document.querySelector(".settings-user-card--clickable");
|
||||
if (userCard) userCard.classList.remove("open");
|
||||
|
||||
const syncForm = document.getElementById("syncSettingsForm");
|
||||
if (syncForm) syncForm.style.display = "none";
|
||||
const syncCard = document.querySelector(".settings-sync-card--clickable");
|
||||
if (syncCard) syncCard.classList.remove("open");
|
||||
|
||||
if (settingsModalClickHandler) {
|
||||
modal.removeEventListener("click", settingsModalClickHandler);
|
||||
settingsModalClickHandler = null;
|
||||
}
|
||||
if (settingsEscapeHandler) {
|
||||
document.removeEventListener("keydown", settingsEscapeHandler);
|
||||
settingsEscapeHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettings(event) {
|
||||
if (event) event.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/get_current_credentials");
|
||||
const data = await response.json();
|
||||
if (data.status === "success") {
|
||||
const oldLogin = document.getElementById("oldLogin");
|
||||
const oldPassword = document.getElementById("oldPassword");
|
||||
if (oldLogin) oldLogin.value = data.login || "";
|
||||
if (oldPassword) oldPassword.value = "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки текущих учетных данных:", error);
|
||||
}
|
||||
|
||||
const modal = document.getElementById("settingsModal");
|
||||
if (!modal) return;
|
||||
modal.style.display = "block";
|
||||
document.body.style.overflow = "hidden";
|
||||
initThemePicker();
|
||||
initHighContrastToggle();
|
||||
|
||||
if (settingsModalClickHandler) {
|
||||
modal.removeEventListener("click", settingsModalClickHandler);
|
||||
}
|
||||
if (settingsEscapeHandler) {
|
||||
document.removeEventListener("keydown", settingsEscapeHandler);
|
||||
}
|
||||
|
||||
settingsModalClickHandler = (e) => {
|
||||
if (e.target === modal) closeSettings();
|
||||
};
|
||||
settingsEscapeHandler = (e) => {
|
||||
if (e.key === "Escape" && modal.style.display === "block") closeSettings();
|
||||
};
|
||||
|
||||
modal.addEventListener("click", settingsModalClickHandler);
|
||||
document.addEventListener("keydown", settingsEscapeHandler);
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/check");
|
||||
const data = await response.json();
|
||||
if (data.status === "success" && data.authenticated) {
|
||||
const userInfo = document.getElementById("userInfo");
|
||||
const userLogin = document.getElementById("userLogin");
|
||||
const mobileUserInfo = document.getElementById("mobileUserInfo");
|
||||
const canOpenAdmin = Boolean(data.is_superuser);
|
||||
window.WESP_CAN_LAB = Boolean(data.can_lab);
|
||||
window.WespLabAccess?.apply?.(window.WESP_CAN_LAB);
|
||||
const rules = data.user_rules || {};
|
||||
passwordMinLen = Number(rules.password_min_len) || DEFAULT_PASSWORD_MIN_LEN;
|
||||
if (userLogin) userLogin.textContent = data.user_login;
|
||||
if (userInfo) {
|
||||
userInfo.style.cursor = canOpenAdmin ? "pointer" : "";
|
||||
userInfo.title = canOpenAdmin ? "Открыть админ-панель" : "";
|
||||
userInfo.onclick = canOpenAdmin
|
||||
? () => {
|
||||
window.location.href = "/admin";
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (mobileUserInfo) {
|
||||
mobileUserInfo.innerHTML = `<i class="fas fa-user"></i> ${data.user_login}`;
|
||||
mobileUserInfo.style.cursor = canOpenAdmin ? "pointer" : "";
|
||||
mobileUserInfo.title = canOpenAdmin ? "Открыть админ-панель" : "";
|
||||
mobileUserInfo.onclick = canOpenAdmin
|
||||
? () => {
|
||||
window.location.href = "/admin";
|
||||
}
|
||||
: null;
|
||||
}
|
||||
} else {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Ошибка проверки авторизации:", error);
|
||||
window.location.href = "/login";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
const response = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === "success") {
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
notyf.error("Ошибка при выходе из системы");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при выходе:", error);
|
||||
notyf.error("Ошибка при выходе из системы");
|
||||
}
|
||||
}
|
||||
|
||||
function setupMobileMenu() {
|
||||
const hamburgerMenu = document.getElementById("hamburgerMenu");
|
||||
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
|
||||
const closeMenu = document.getElementById("closeMenu");
|
||||
const mobileUserInfo = document.getElementById("mobileUserInfo");
|
||||
if (!hamburgerMenu || !mobileMenuOverlay || !closeMenu) return;
|
||||
|
||||
hamburgerMenu.addEventListener("click", () => {
|
||||
mobileMenuOverlay.classList.add("active");
|
||||
hamburgerMenu.classList.add("active");
|
||||
document.body.style.overflow = "hidden";
|
||||
const userInfo = document.getElementById("userInfo");
|
||||
if (userInfo && mobileUserInfo) {
|
||||
mobileUserInfo.textContent = userInfo.textContent || "";
|
||||
}
|
||||
});
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
mobileMenuOverlay.classList.remove("active");
|
||||
hamburgerMenu.classList.remove("active");
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
|
||||
closeMenu.addEventListener("click", closeMobileMenu);
|
||||
mobileMenuOverlay.addEventListener("click", (e) => {
|
||||
if (e.target === mobileMenuOverlay) closeMobileMenu();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && mobileMenuOverlay.classList.contains("active")) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
});
|
||||
|
||||
const mobileMenuItems = document.querySelectorAll(".mobile-menu-item");
|
||||
mobileMenuItems.forEach((item) => {
|
||||
item.addEventListener("click", () => {
|
||||
setTimeout(closeMobileMenu, 300);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSyncClientsSettings() {
|
||||
const listEl = document.getElementById("syncClientsListSettings");
|
||||
const emptyEl = document.getElementById("syncClientsEmptySettings");
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = "";
|
||||
if (emptyEl) emptyEl.style.display = "none";
|
||||
|
||||
try {
|
||||
const [clientsRes, namesRes] = await Promise.all([
|
||||
fetch("/api/sync/clients"),
|
||||
fetch("/api/feed_dispensers/names"),
|
||||
]);
|
||||
const clients = clientsRes.ok ? await clientsRes.json() : [];
|
||||
syncDispenserNames = namesRes.ok ? await namesRes.json() : [];
|
||||
|
||||
if (!clients || clients.length === 0) {
|
||||
if (emptyEl) {
|
||||
emptyEl.style.display = "block";
|
||||
emptyEl.textContent = "Нет подключенных клиентов";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clients.forEach((client) => {
|
||||
const displayLabel = client.display_name || client.client_name || client.node_id;
|
||||
const item = document.createElement("div");
|
||||
item.className = "settings-sync-client-item";
|
||||
item.dataset.nodeId = client.node_id;
|
||||
item.dataset.displayName = displayLabel || "";
|
||||
item.innerHTML =
|
||||
`<span class="settings-sync-client-name">${escapeHtmlSyncSettings(displayLabel)}</span>` +
|
||||
'<div class="settings-sync-client-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-edit btn-outline-primary" data-action="sync-edit-client"><i class="fas fa-edit"></i> Изменить</button> ' +
|
||||
'<button type="button" class="btn btn-sm btn-delete btn-outline-danger" data-action="sync-delete-client"><i class="fas fa-trash"></i> Удалить</button>' +
|
||||
"</div>";
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (error) {
|
||||
if (emptyEl) {
|
||||
emptyEl.style.display = "block";
|
||||
emptyEl.textContent = "Не удалось загрузить список клиентов";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function editSyncClientSettings(btn) {
|
||||
const item = btn && btn.closest ? btn.closest(".settings-sync-client-item") : null;
|
||||
const nodeId = item ? item.dataset.nodeId : null;
|
||||
const currentName = item ? item.dataset.displayName || "" : "";
|
||||
if (!item || !nodeId) return;
|
||||
|
||||
let selectHtml = '<option value="">— не задано —</option>';
|
||||
syncDispenserNames.forEach((name) => {
|
||||
const escaped = escapeHtmlSyncSettings(name);
|
||||
selectHtml += `<option value="${escaped}">${escaped}</option>`;
|
||||
});
|
||||
|
||||
item.innerHTML =
|
||||
'<div class="settings-sync-edit-row w-100">' +
|
||||
'<label class="mb-0 me-2">Имя клиента:</label>' +
|
||||
`<select class="form-control form-control-sm d-inline-block" id="syncEditSelect_${nodeId}">${selectHtml}</select> ` +
|
||||
`<button type="button" class="btn btn-sm btn-primary" data-action="sync-save-client" data-node-id="${String(nodeId).replace(/"/g, """)}"><i class="fas fa-save"></i> Сохранить</button> ` +
|
||||
'<button type="button" class="btn btn-sm btn-secondary" data-action="sync-cancel-edit">Отмена</button>' +
|
||||
"</div>";
|
||||
|
||||
const sel = document.getElementById(`syncEditSelect_${nodeId}`);
|
||||
if (sel && currentName) sel.value = currentName;
|
||||
}
|
||||
|
||||
function saveSyncClientDisplayName(nodeId) {
|
||||
const sel = document.getElementById(`syncEditSelect_${nodeId}`);
|
||||
const displayName = sel ? (sel.value || "").trim() : "";
|
||||
fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}/display_name`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ display_name: displayName || null }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
notyf.error(data.error);
|
||||
return;
|
||||
}
|
||||
notyf.success(displayName ? "Имя сохранено" : "Имя сброшено");
|
||||
loadSyncClientsSettings();
|
||||
})
|
||||
.catch(() => notyf.error("Не удалось сохранить настройки клиента"));
|
||||
}
|
||||
|
||||
async function deleteSyncClientSettings(btn) {
|
||||
const item = btn && btn.closest ? btn.closest(".settings-sync-client-item") : null;
|
||||
const nodeId = item ? item.dataset.nodeId : null;
|
||||
if (!nodeId) return;
|
||||
const ok = globalThis.WespKioskDialog
|
||||
? await globalThis.WespKioskDialog.confirm("Удалить этого клиента из синхронизации?")
|
||||
: globalThis.confirm("Удалить этого клиента из синхронизации?");
|
||||
if (!ok) return;
|
||||
|
||||
fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}`, { method: "DELETE" })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
notyf.error(data.error);
|
||||
return;
|
||||
}
|
||||
notyf.success(data.message || "Клиент удалён");
|
||||
loadSyncClientsSettings();
|
||||
})
|
||||
.catch(() => notyf.error("Не удалось сохранить настройки клиента"));
|
||||
}
|
||||
|
||||
function toggleCredentialsForm() {
|
||||
const credForm = document.getElementById("credentialsForm");
|
||||
const userCard = document.querySelector(".settings-user-card--clickable");
|
||||
if (!credForm) return;
|
||||
|
||||
const isOpen = credForm.style.display !== "none";
|
||||
credForm.style.display = isOpen ? "none" : "block";
|
||||
if (userCard) userCard.classList.toggle("open", !isOpen);
|
||||
}
|
||||
|
||||
function toggleSyncForm() {
|
||||
const syncForm = document.getElementById("syncSettingsForm");
|
||||
const syncCard = document.querySelector(".settings-sync-card--clickable");
|
||||
if (!syncForm) return;
|
||||
|
||||
const isOpen = syncForm.style.display !== "none";
|
||||
syncForm.style.display = isOpen ? "none" : "block";
|
||||
if (syncCard) syncCard.classList.toggle("open", !isOpen);
|
||||
if (!isOpen) loadSyncClientsSettings();
|
||||
}
|
||||
|
||||
async function changeCredentials() {
|
||||
const oldLogin = (document.getElementById("oldLogin")?.value || "").trim();
|
||||
const oldPassword = document.getElementById("oldPassword")?.value || "";
|
||||
const newLogin = (document.getElementById("newLogin")?.value || "").trim();
|
||||
const newPassword = document.getElementById("newPassword")?.value || "";
|
||||
const confirmPassword = document.getElementById("confirmPassword")?.value || "";
|
||||
|
||||
const wantsCredChange = !!(newLogin || newPassword || confirmPassword.trim());
|
||||
if (!wantsCredChange) {
|
||||
notyf.success("Настройки сохранены");
|
||||
closeSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateCredentialChange({
|
||||
oldLogin,
|
||||
oldPassword,
|
||||
newLogin,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
passwordMinLen,
|
||||
});
|
||||
if (validationError) {
|
||||
notyf.error(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/change_credentials", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
old_login: oldLogin,
|
||||
old_password: oldPassword,
|
||||
new_login: newLogin,
|
||||
new_password: newPassword,
|
||||
confirm_password: confirmPassword,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.status === "success") {
|
||||
notyf.success(data.message);
|
||||
closeSettings();
|
||||
const userLogin = document.getElementById("userLogin");
|
||||
const mobileUserInfo = document.getElementById("mobileUserInfo");
|
||||
if (userLogin) userLogin.textContent = newLogin;
|
||||
if (mobileUserInfo) {
|
||||
mobileUserInfo.innerHTML = `<i class="fas fa-user"></i> ${newLogin}`;
|
||||
}
|
||||
} else {
|
||||
notyf.error(data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при изменении учетных данных:", error);
|
||||
notyf.error("Ошибка при изменении учетных данных");
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(getTheme());
|
||||
|
||||
return {
|
||||
checkAuth,
|
||||
logout,
|
||||
setupMobileMenu,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
toggleSyncForm,
|
||||
loadSyncClientsSettings,
|
||||
editSyncClientSettings,
|
||||
saveSyncClientDisplayName,
|
||||
deleteSyncClientSettings,
|
||||
toggleCredentialsForm,
|
||||
changeCredentials,
|
||||
initThemePicker,
|
||||
initHighContrastToggle,
|
||||
initThemeToggle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { LOGIN_TRANSITION_KEY, clearRecipesBootOverlay } from "/static/js/wesp-nav-preload.js";
|
||||
|
||||
function hasLoginTransition() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(LOGIN_TRANSITION_KEY);
|
||||
if (!raw) return false;
|
||||
const parsed = JSON.parse(raw);
|
||||
return Boolean(parsed?.storedAt);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** После входа — без полноэкранного логотипа, только page-enter на /recipes. */
|
||||
export function initRecipesBootOverlay() {
|
||||
if (!hasLoginTransition()) return;
|
||||
clearRecipesBootOverlay();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
export function createRecipesEditorController({
|
||||
notyf,
|
||||
getCurrentDispenserType,
|
||||
setCurrentDispenserType,
|
||||
getComponentsCache,
|
||||
ensureComponentsLoaded,
|
||||
setSelectedDispenser,
|
||||
setSelectedPeriod,
|
||||
setSelectedRecipe,
|
||||
resetDeletedEntities,
|
||||
setUnloadingLinkBroken,
|
||||
initializeUnloadingLinkButton,
|
||||
updateTripPercentFieldState,
|
||||
setDryMatterMode,
|
||||
loadIngredients,
|
||||
loadUnloadingGroups,
|
||||
showRecipeEdit,
|
||||
}) {
|
||||
function resetCreationForm() {
|
||||
const recipeForm = document.getElementById("recipeForm");
|
||||
if (recipeForm) recipeForm.reset();
|
||||
|
||||
const ingredientsTableBody = document.getElementById("ingredientsTableBody");
|
||||
if (ingredientsTableBody) ingredientsTableBody.innerHTML = "";
|
||||
|
||||
const unloadingGroupsTableBody = document.getElementById("unloadingGroupsTableBody");
|
||||
if (unloadingGroupsTableBody) unloadingGroupsTableBody.innerHTML = "";
|
||||
|
||||
updateTripPercentFieldState();
|
||||
resetDeletedEntities();
|
||||
|
||||
const tripPercent = document.getElementById("tripPercent");
|
||||
if (tripPercent) tripPercent.value = 100;
|
||||
|
||||
const totalWeightIngredients = document.getElementById("totalWeightIngredients");
|
||||
if (totalWeightIngredients) totalWeightIngredients.textContent = "0";
|
||||
|
||||
const totalTripWeight = document.getElementById("totalTripWeight");
|
||||
if (totalTripWeight) totalTripWeight.textContent = "0";
|
||||
|
||||
const totalDryMatterPerHead = document.getElementById("totalDryMatterPerHead");
|
||||
if (totalDryMatterPerHead) totalDryMatterPerHead.textContent = "0";
|
||||
|
||||
const totalWeightPerHead = document.getElementById("totalWeightPerHead");
|
||||
if (totalWeightPerHead) totalWeightPerHead.textContent = "0";
|
||||
}
|
||||
|
||||
async function loadComponentsForMill() {
|
||||
try {
|
||||
await ensureComponentsLoaded();
|
||||
const select = document.getElementById("targetComponentSelect");
|
||||
if (!select) return;
|
||||
|
||||
const components = Array.isArray(getComponentsCache()) ? getComponentsCache() : [];
|
||||
const currentValue = select.value;
|
||||
select.innerHTML = `
|
||||
<option value="">Выберите компонент...</option>
|
||||
${components.map((component) => `<option value="${component.id}">${component.name}</option>`).join("")}
|
||||
`;
|
||||
if (currentValue) {
|
||||
select.value = currentValue;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при загрузке компонентов для кормоцеха:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUnloadingBlocks() {
|
||||
const isMill = getCurrentDispenserType() === "mill";
|
||||
const unloadingGroupsCard = document.getElementById("unloadingGroupsCard");
|
||||
const componentSelectionCard = document.getElementById("componentSelectionCard");
|
||||
|
||||
if (unloadingGroupsCard) unloadingGroupsCard.style.display = "block";
|
||||
if (componentSelectionCard) {
|
||||
componentSelectionCard.style.display = isMill ? "block" : "none";
|
||||
}
|
||||
|
||||
if (isMill) {
|
||||
loadComponentsForMill();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecipeDetails(recipeId, options = {}) {
|
||||
try {
|
||||
const response = await fetch(`/api/recipes/${recipeId}`);
|
||||
if (!response.ok) throw new Error("Ошибка при загрузке данных рецепта");
|
||||
let recipe = await response.json();
|
||||
|
||||
if (!options.skipPlanOverlayFlags) {
|
||||
const planDate =
|
||||
options.planDate ||
|
||||
globalThis.WespDailyPlanPanel?.getSelectedPlanDate?.() ||
|
||||
new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const overlayResp = await fetch(
|
||||
`/api/recipes/${recipeId}?date=${encodeURIComponent(planDate)}`
|
||||
);
|
||||
if (overlayResp.ok) {
|
||||
const overlay = await overlayResp.json();
|
||||
const ingById = new Map(
|
||||
(overlay.ingredients || []).map((ing) => [String(ing.id), ing])
|
||||
);
|
||||
recipe = {
|
||||
...recipe,
|
||||
ingredients: (recipe.ingredients || []).map((ing) => {
|
||||
const overlayIng = ingById.get(String(ing.id));
|
||||
if (!overlayIng) return ing;
|
||||
return {
|
||||
...ing,
|
||||
skippedToday: Boolean(overlayIng.skippedToday),
|
||||
adjustedToday: Boolean(overlayIng.adjustedToday),
|
||||
replacedToday: Boolean(overlayIng.replacedToday),
|
||||
};
|
||||
}),
|
||||
};
|
||||
const groups = recipe.unloading_groups || recipe.unloadingGroups || [];
|
||||
const grpById = new Map(
|
||||
(overlay.unloadingGroups || overlay.unloading_groups || []).map((group) => [
|
||||
String(group.id),
|
||||
group,
|
||||
])
|
||||
);
|
||||
const mergedGroups = groups.map((group) => {
|
||||
const overlayGroup = grpById.get(String(group.id));
|
||||
if (!overlayGroup) return group;
|
||||
return { ...group, skippedToday: Boolean(overlayGroup.skippedToday) };
|
||||
});
|
||||
recipe.unloading_groups = mergedGroups;
|
||||
recipe.unloadingGroups = mergedGroups;
|
||||
}
|
||||
} catch (overlayError) {
|
||||
console.warn("Не удалось загрузить флаги плана для редактора рецепта:", overlayError);
|
||||
}
|
||||
}
|
||||
|
||||
resetDeletedEntities();
|
||||
|
||||
const recipeNameInput = document.getElementById("recipeName");
|
||||
if (recipeNameInput) recipeNameInput.value = recipe.name || "";
|
||||
const headsCountInput = document.getElementById("headsCount");
|
||||
if (headsCountInput) {
|
||||
const heads =
|
||||
recipe.heads_count ??
|
||||
recipe.headsPerTrip ??
|
||||
recipe.heads_per_trip ??
|
||||
"";
|
||||
headsCountInput.value = heads === "" || heads == null ? "" : String(heads);
|
||||
}
|
||||
const mixingTimeInput = document.getElementById("mixingTime");
|
||||
if (mixingTimeInput) {
|
||||
const mt = recipe.mixing_time ?? recipe.mixingTime;
|
||||
mixingTimeInput.value = mt === "" || mt == null ? "" : String(mt);
|
||||
}
|
||||
const tripPercentInput = document.getElementById("tripPercent");
|
||||
if (tripPercentInput) {
|
||||
const tp = recipe.trip_percent ?? recipe.tripPercent;
|
||||
tripPercentInput.value = tp === "" || tp == null ? 100 : String(tp);
|
||||
}
|
||||
|
||||
const ingredientsTableBody = document.getElementById("ingredientsTableBody");
|
||||
if (ingredientsTableBody) ingredientsTableBody.innerHTML = "";
|
||||
const unloadingGroupsTableBody = document.getElementById("unloadingGroupsTableBody");
|
||||
if (unloadingGroupsTableBody) unloadingGroupsTableBody.innerHTML = "";
|
||||
|
||||
setUnloadingLinkBroken(recipe.unloading_link_broken || false);
|
||||
initializeUnloadingLinkButton();
|
||||
updateTripPercentFieldState();
|
||||
|
||||
setDryMatterMode(!!recipe.dry_matter_locked);
|
||||
toggleUnloadingBlocks();
|
||||
updateTripPercentFieldState();
|
||||
|
||||
await loadIngredients(recipe.ingredients);
|
||||
const unloadingGroups = recipe.unloading_groups || recipe.unloadingGroups || [];
|
||||
await loadUnloadingGroups(unloadingGroups);
|
||||
|
||||
if (getCurrentDispenserType() === "mill") {
|
||||
const targetComponentSelect = document.getElementById("targetComponentSelect");
|
||||
if (targetComponentSelect) {
|
||||
targetComponentSelect.value = "";
|
||||
if (recipe.target_component_id) {
|
||||
targetComponentSelect.value = recipe.target_component_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при загрузке данных рецепта:", error);
|
||||
notyf.error("Ошибка при загрузке данных рецепта");
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewRecipeForMill(dispenserId) {
|
||||
try {
|
||||
setSelectedDispenser(dispenserId);
|
||||
setSelectedPeriod(null);
|
||||
setSelectedRecipe(null);
|
||||
setCurrentDispenserType("mill");
|
||||
|
||||
resetCreationForm();
|
||||
toggleUnloadingBlocks();
|
||||
showRecipeEdit(true);
|
||||
setDryMatterMode(false);
|
||||
} catch (error) {
|
||||
console.error("Ошибка в createNewRecipeForMill:", error);
|
||||
notyf.error("Ошибка при создании рецепта");
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewRecipe(periodId) {
|
||||
try {
|
||||
setSelectedPeriod(periodId);
|
||||
setSelectedRecipe(null);
|
||||
|
||||
resetCreationForm();
|
||||
toggleUnloadingBlocks();
|
||||
showRecipeEdit(true);
|
||||
setDryMatterMode(false);
|
||||
} catch (error) {
|
||||
console.error("Ошибка в createNewRecipe:", error);
|
||||
notyf.error("Ошибка при создании рейса");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toggleUnloadingBlocks,
|
||||
loadComponentsForMill,
|
||||
loadRecipeDetails,
|
||||
createNewRecipeForMill,
|
||||
createNewRecipe,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
export function createRecipesOperationsController({
|
||||
notyf,
|
||||
getSelectedRecipe,
|
||||
setSelectedRecipe,
|
||||
getSelectedPeriod,
|
||||
setSelectedPeriod,
|
||||
getSelectedDispenser,
|
||||
getCurrentDispenserType,
|
||||
showRecipeEdit,
|
||||
loadRecipes,
|
||||
loadPeriods,
|
||||
ensureComponentsLoaded,
|
||||
setUnloadingLinkBroken,
|
||||
initializeUnloadingLinkButton,
|
||||
updateTripPercentFieldState,
|
||||
setDryMatterMode,
|
||||
loadIngredients,
|
||||
loadUnloadingGroups,
|
||||
recalculateWeights,
|
||||
updateTotalValues,
|
||||
}) {
|
||||
let copiedRecipeData = null;
|
||||
let moveRecipeInFlight = false;
|
||||
|
||||
async function deleteRecipe(recipeId) {
|
||||
const periodId = getSelectedPeriod();
|
||||
const dispenserId = getSelectedDispenser();
|
||||
const isMill =
|
||||
typeof getCurrentDispenserType === "function" && getCurrentDispenserType() === "mill";
|
||||
/** В контексте периода кормораздатчика удаляем только связь с периодом — иначе DELETE /api/recipes глобально убирает рецепт со всех оборудований. */
|
||||
const unlinkFromPeriodOnly = Boolean(periodId && dispenserId && !isMill);
|
||||
|
||||
const msg = unlinkFromPeriodOnly
|
||||
? "Удалить этот рейс из выбранного периода? (Рецепт останется в базе, если используется ещё где-то.)"
|
||||
: "Вы уверены, что хотите удалить этот рецепт? Он будет удалён для всех привязок.";
|
||||
const ok = globalThis.WespKioskDialog
|
||||
? await globalThis.WespKioskDialog.confirm(msg)
|
||||
: globalThis.confirm(msg);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const url = unlinkFromPeriodOnly
|
||||
? `/api/feed_dispensers/${encodeURIComponent(dispenserId)}/periods/${encodeURIComponent(periodId)}/recipes/${encodeURIComponent(recipeId)}`
|
||||
: `/api/recipes/${encodeURIComponent(recipeId)}`;
|
||||
|
||||
const response = await fetch(url, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || "Ошибка при удалении рейса");
|
||||
}
|
||||
|
||||
const result = await response.json().catch(() => ({}));
|
||||
notyf.success(result.message || (unlinkFromPeriodOnly ? "Рейс удалён из периода" : "Рейс удален"));
|
||||
|
||||
if (String(getSelectedRecipe() ?? "") === String(recipeId)) {
|
||||
setSelectedRecipe(null);
|
||||
showRecipeEdit(false);
|
||||
}
|
||||
|
||||
if (getSelectedPeriod()) {
|
||||
await loadRecipes(getSelectedPeriod());
|
||||
}
|
||||
if (getSelectedDispenser()) {
|
||||
await loadPeriods(getSelectedDispenser());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при удалении рейса:", error);
|
||||
notyf.error(error.message, { fallback: "Ошибка при удалении рейса" });
|
||||
}
|
||||
}
|
||||
|
||||
async function moveRecipe(recipeId, fromIndex, toIndex) {
|
||||
const selectedPeriod = getSelectedPeriod();
|
||||
if (!selectedPeriod || fromIndex === toIndex) return;
|
||||
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.body.classList.contains("recipe-period-transfer-active")
|
||||
) {
|
||||
notyf.error(
|
||||
"Сначала завершите перенос в другой период (кнопка «Вставить здесь») или отмените его (Escape, отпустите вне списка)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (moveRecipeInFlight) return;
|
||||
|
||||
if (!applyOptimisticRecipeReorder(fromIndex, toIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
moveRecipeInFlight = true;
|
||||
try {
|
||||
const response = await fetch(`/api/recipes/${encodeURIComponent(recipeId)}/move`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from_index: fromIndex, to_index: toIndex, period_id: selectedPeriod }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
await loadRecipes(selectedPeriod, { skipRecipesSkeleton: true });
|
||||
throw new Error(errorData.message || "Ошибка при перемещении рецепта");
|
||||
}
|
||||
|
||||
const listEl = document.getElementById("recipesList");
|
||||
if (listEl) {
|
||||
reindexRecipeListItems(listEl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка при перемещении рейса:", error);
|
||||
notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" });
|
||||
} finally {
|
||||
moveRecipeInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reindexRecipeListItems(listEl) {
|
||||
const items = listEl.querySelectorAll(":scope > .list-item[data-recipe-id]");
|
||||
items.forEach((item, index) => {
|
||||
item.dataset.index = String(index);
|
||||
item.querySelectorAll("[data-index]").forEach((el) => {
|
||||
el.dataset.index = String(index);
|
||||
});
|
||||
const upBtn = item.querySelector('[data-action="move-recipe-up"]');
|
||||
const downBtn = item.querySelector('[data-action="move-recipe-down"]');
|
||||
if (upBtn) upBtn.disabled = index === 0;
|
||||
if (downBtn) downBtn.disabled = index === items.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
function applyOptimisticRecipeReorder(fromIndex, toIndex) {
|
||||
const listEl = document.getElementById("recipesList");
|
||||
if (!listEl) return false;
|
||||
const items = [...listEl.querySelectorAll(":scope > .list-item[data-recipe-id]")];
|
||||
if (
|
||||
fromIndex < 0 ||
|
||||
toIndex < 0 ||
|
||||
fromIndex >= items.length ||
|
||||
toIndex >= items.length ||
|
||||
fromIndex === toIndex
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const [moved] = items.splice(fromIndex, 1);
|
||||
items.splice(toIndex, 0, moved);
|
||||
items.forEach((item) => listEl.appendChild(item));
|
||||
reindexRecipeListItems(listEl);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function moveRecipeUp(recipeId, currentIndex) {
|
||||
if (currentIndex === 0) {
|
||||
notyf.error("Рейс уже находится в начале списка");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await moveRecipe(recipeId, currentIndex, currentIndex - 1);
|
||||
} catch (error) {
|
||||
console.error("Ошибка при перемещении рейса:", error);
|
||||
notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" });
|
||||
}
|
||||
}
|
||||
|
||||
async function moveRecipeDown(recipeId, currentIndex) {
|
||||
const totalRecipes = document.querySelectorAll("#recipesList .list-item").length;
|
||||
if (currentIndex >= totalRecipes - 1) {
|
||||
notyf.error("Рейс уже находится в конце списка");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await moveRecipe(recipeId, currentIndex, currentIndex + 1);
|
||||
} catch (error) {
|
||||
console.error("Ошибка при перемещении рейса:", error);
|
||||
notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" });
|
||||
}
|
||||
}
|
||||
|
||||
async function pasteRecipe(periodId) {
|
||||
if (!copiedRecipeData) {
|
||||
notyf.error("Буфер пуст. Сначала скопируйте рейс.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSelectedPeriod(periodId);
|
||||
setSelectedRecipe(null);
|
||||
|
||||
await ensureComponentsLoaded();
|
||||
|
||||
const recipeName = document.getElementById("recipeName");
|
||||
if (recipeName) {
|
||||
recipeName.value = `${copiedRecipeData.name} (копия)`;
|
||||
}
|
||||
|
||||
const headsCount = document.getElementById("headsCount");
|
||||
if (headsCount) {
|
||||
const h =
|
||||
copiedRecipeData.heads_count ??
|
||||
copiedRecipeData.headsPerTrip ??
|
||||
copiedRecipeData.heads_per_trip ??
|
||||
"";
|
||||
headsCount.value = h === "" || h == null ? "" : String(h);
|
||||
}
|
||||
|
||||
const mixingTime = document.getElementById("mixingTime");
|
||||
if (mixingTime) {
|
||||
const mt = copiedRecipeData.mixing_time ?? copiedRecipeData.mixingTime;
|
||||
mixingTime.value = mt === "" || mt == null ? "" : String(mt);
|
||||
}
|
||||
|
||||
const tripPercent = document.getElementById("tripPercent");
|
||||
if (tripPercent) {
|
||||
const tp = copiedRecipeData.trip_percent ?? copiedRecipeData.tripPercent;
|
||||
tripPercent.value = tp === "" || tp == null ? 100 : String(tp);
|
||||
}
|
||||
|
||||
const ingredientsTableBody = document.getElementById("ingredientsTableBody");
|
||||
if (ingredientsTableBody) {
|
||||
ingredientsTableBody.innerHTML = "";
|
||||
}
|
||||
|
||||
const unloadingGroupsTableBody = document.getElementById("unloadingGroupsTableBody");
|
||||
if (unloadingGroupsTableBody) {
|
||||
unloadingGroupsTableBody.innerHTML = "";
|
||||
}
|
||||
|
||||
setUnloadingLinkBroken(Boolean(copiedRecipeData.unloading_link_broken));
|
||||
initializeUnloadingLinkButton();
|
||||
updateTripPercentFieldState();
|
||||
|
||||
setDryMatterMode(false);
|
||||
|
||||
if (copiedRecipeData.ingredients && copiedRecipeData.ingredients.length > 0) {
|
||||
await loadIngredients(copiedRecipeData.ingredients);
|
||||
}
|
||||
|
||||
const copiedGroups =
|
||||
copiedRecipeData.unloading_groups?.length > 0
|
||||
? copiedRecipeData.unloading_groups
|
||||
: copiedRecipeData.unloadingGroups;
|
||||
if (copiedGroups && copiedGroups.length > 0) {
|
||||
await loadUnloadingGroups(copiedGroups);
|
||||
}
|
||||
|
||||
showRecipeEdit(true);
|
||||
|
||||
setTimeout(() => {
|
||||
Promise.resolve(recalculateWeights()).catch((err) => console.error("Ошибка при расчете:", err));
|
||||
}, 100);
|
||||
setTimeout(() => {
|
||||
Promise.resolve(updateTotalValues()).catch((err) => console.error("Ошибка при расчете:", err));
|
||||
}, 100);
|
||||
|
||||
notyf.success("Рейс вставлен из буфера");
|
||||
} catch (error) {
|
||||
console.error("Ошибка при вставке рейса:", error);
|
||||
notyf.error("Ошибка при вставке рейса");
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRecipe(recipeId) {
|
||||
if (!recipeId) {
|
||||
notyf.error("Выберите рейс для копирования");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/recipes/${recipeId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Ошибка при загрузке рейса");
|
||||
}
|
||||
|
||||
const recipe = await response.json();
|
||||
copiedRecipeData = {
|
||||
name: recipe.name,
|
||||
heads_count:
|
||||
recipe.heads_count ??
|
||||
recipe.headsPerTrip ??
|
||||
recipe.heads_per_trip ??
|
||||
0,
|
||||
mixing_time: recipe.mixing_time ?? recipe.mixingTime ?? 0,
|
||||
trip_percent: recipe.trip_percent ?? recipe.tripPercent ?? 100,
|
||||
ingredients: (recipe.ingredients || []).map((ing) => ({
|
||||
component_id: ing.component_id,
|
||||
weight_per_head: ing.weight_per_head,
|
||||
amount: ing.amount,
|
||||
dry_matter: ing.dry_matter,
|
||||
order: ing.order,
|
||||
})),
|
||||
unloading_link_broken: Boolean(
|
||||
recipe.unloading_link_broken ?? recipe.unloadingLinkBroken ?? false
|
||||
),
|
||||
unloading_groups: (recipe.unloading_groups || recipe.unloadingGroups || []).map((group) => ({
|
||||
name: group.name,
|
||||
distribution_type: group.distribution_type ?? group.distributionType ?? "percent",
|
||||
value: group.value,
|
||||
weight: group.weight,
|
||||
order: group.order,
|
||||
})),
|
||||
};
|
||||
|
||||
updatePasteButtons();
|
||||
notyf.success("Рейс скопирован");
|
||||
} catch (error) {
|
||||
console.error("Ошибка при копировании рейса:", error);
|
||||
notyf.error("Ошибка при копировании рейса");
|
||||
}
|
||||
}
|
||||
|
||||
function updatePasteButtons() {
|
||||
const pasteButtons = document.querySelectorAll(".paste-recipe-btn");
|
||||
const hasData = copiedRecipeData !== null;
|
||||
|
||||
pasteButtons.forEach((button) => {
|
||||
button.disabled = !hasData;
|
||||
if (hasData) {
|
||||
button.title = `Вставить рейс "${copiedRecipeData.name}"`;
|
||||
} else {
|
||||
button.title = "Вставить рейс из буфера";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
deleteRecipe,
|
||||
moveRecipe,
|
||||
moveRecipeUp,
|
||||
moveRecipeDown,
|
||||
pasteRecipe,
|
||||
copyRecipe,
|
||||
updatePasteButtons,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { RecipeEditor } from "/static/js/modules/recipes/recipe-editor.js";
|
||||
import { SyncPanel } from "/static/js/modules/recipes/sync-panel.js";
|
||||
import { DispenserSelector } from "/static/js/modules/recipes/dispenser-selector.js";
|
||||
import { initRecipeMobileSheetGestures } from "/static/js/modules/recipes/recipe-mobile-sheet-gestures.js";
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function initRecipesPage(handlers) {
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
const recipeEditorModule = new RecipeEditor({
|
||||
addIngredient: handlers.addIngredient,
|
||||
toggleUnloadingLink: handlers.toggleUnloadingLink,
|
||||
addUnloadingGroup: handlers.addUnloadingGroup,
|
||||
showRecipeEdit: handlers.showRecipeEdit,
|
||||
printAllRecipesInPeriod: handlers.printAllRecipesInPeriod,
|
||||
printRecipe: handlers.printRecipe,
|
||||
saveRecipe: handlers.saveRecipe,
|
||||
moveGroupUp: handlers.moveGroupUp,
|
||||
moveGroupDown: handlers.moveGroupDown,
|
||||
removeUnloadingGroup: handlers.removeUnloadingGroup,
|
||||
moveIngredientUp: handlers.moveIngredientUp,
|
||||
moveIngredientDown: handlers.moveIngredientDown,
|
||||
removeIngredient: handlers.removeIngredient,
|
||||
openIngredientMobileSheet: handlers.openIngredientMobileSheet,
|
||||
closeIngredientMobileSheet: handlers.closeIngredientMobileSheet,
|
||||
removeIngredientFromSheet: handlers.removeIngredientFromSheet,
|
||||
openUnloadingGroupMobileSheet: handlers.openUnloadingGroupMobileSheet,
|
||||
closeUnloadingGroupMobileSheet: handlers.closeUnloadingGroupMobileSheet,
|
||||
handleGroupTypeChange: handlers.handleGroupTypeChange,
|
||||
validateGroupValue: handlers.validateGroupValue,
|
||||
handleIngredientChange: handlers.handleIngredientChange,
|
||||
handleDryMatterPercentChange: handlers.handleDryMatterPercentChange,
|
||||
recalculateWeights: handlers.recalculateWeights,
|
||||
recalculateFromTotalWeight: handlers.recalculateFromTotalWeight,
|
||||
});
|
||||
|
||||
const syncPanelModule = new SyncPanel({
|
||||
openSettings: handlers.openSettings,
|
||||
closeSettings: handlers.closeSettings,
|
||||
toggleCredentialsForm: handlers.toggleCredentialsForm,
|
||||
toggleSyncForm: handlers.toggleSyncForm,
|
||||
changeCredentials: handlers.changeCredentials,
|
||||
editSyncClientSettings: handlers.editSyncClientSettings,
|
||||
deleteSyncClientSettings: handlers.deleteSyncClientSettings,
|
||||
saveSyncClientDisplayName: handlers.saveSyncClientDisplayName,
|
||||
loadSyncClientsSettings: handlers.loadSyncClientsSettings,
|
||||
});
|
||||
|
||||
const dispenserSelectorModule = new DispenserSelector({
|
||||
selectDispenser: handlers.selectDispenser,
|
||||
selectRecipe: handlers.selectRecipe,
|
||||
selectPeriod: handlers.selectPeriod,
|
||||
copyRecipe: handlers.copyRecipe,
|
||||
deleteRecipe: handlers.deleteRecipe,
|
||||
unskipRecipeToday: handlers.unskipRecipeToday,
|
||||
unskipIngredientPartsToday: handlers.unskipIngredientPartsToday,
|
||||
unskipGroupPartsToday: handlers.unskipGroupPartsToday,
|
||||
pasteRecipe: handlers.pasteRecipe,
|
||||
createNewRecipe: handlers.createNewRecipe,
|
||||
moveRecipeUp: handlers.moveRecipeUp,
|
||||
moveRecipeDown: handlers.moveRecipeDown,
|
||||
});
|
||||
|
||||
initRecipeMobileSheetGestures({
|
||||
closeIngredientMobileSheet: handlers.closeIngredientMobileSheet,
|
||||
closeUnloadingGroupMobileSheet: handlers.closeUnloadingGroupMobileSheet,
|
||||
closeRecipeHelpModal: handlers.closeRecipeHelpModal,
|
||||
});
|
||||
|
||||
const createRecipeBtn = document.getElementById("createRecipeBtn");
|
||||
if (createRecipeBtn) {
|
||||
createRecipeBtn.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const selectedDispenser = handlers.getSelectedDispenser();
|
||||
if (selectedDispenser) {
|
||||
handlers.createNewRecipeForMill(selectedDispenser);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const actionEl = event.target.closest("[data-action]");
|
||||
if (!actionEl) return;
|
||||
|
||||
const action = actionEl.dataset.action;
|
||||
if (await syncPanelModule.handleAction(action, event, actionEl)) return;
|
||||
if (await dispenserSelectorModule.handleAction(action, event, actionEl)) return;
|
||||
if (await recipeEditorModule.handleAction(action, event, actionEl)) return;
|
||||
|
||||
if (action === "open-recipe-help") {
|
||||
event.preventDefault();
|
||||
handlers.openRecipeHelp?.(actionEl.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "close-recipe-help") {
|
||||
event.preventDefault();
|
||||
handlers.closeRecipeHelpModal?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "mobile-dashboard-back") {
|
||||
event.preventDefault();
|
||||
handlers.mobileDashboardBack?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "logout") {
|
||||
event.preventDefault();
|
||||
handlers.logout();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", (event) => {
|
||||
const actionEl = event.target.closest("[data-action]");
|
||||
if (!actionEl) return;
|
||||
const action = actionEl.dataset.action;
|
||||
recipeEditorModule.handleChange(action, actionEl);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
const ingSheet = document.getElementById("ingredientMobileSheet");
|
||||
if (ingSheet && !ingSheet.hidden) {
|
||||
handlers.closeIngredientMobileSheet?.();
|
||||
return;
|
||||
}
|
||||
const grpSheet = document.getElementById("unloadingGroupMobileSheet");
|
||||
if (grpSheet && !grpSheet.hidden) {
|
||||
handlers.closeUnloadingGroupMobileSheet?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* Компактный Date Range Picker для /reports.
|
||||
* Сохраняет #date-from / #date-to для совместимости с остальным кодом.
|
||||
*/
|
||||
(function (global) {
|
||||
const MONTHS = [
|
||||
"янв",
|
||||
"фев",
|
||||
"мар",
|
||||
"апр",
|
||||
"май",
|
||||
"июн",
|
||||
"июл",
|
||||
"авг",
|
||||
"сен",
|
||||
"окт",
|
||||
"ноя",
|
||||
"дек",
|
||||
];
|
||||
|
||||
let draftFrom = null;
|
||||
let draftTo = null;
|
||||
let viewMonth = null;
|
||||
let activePreset = "day";
|
||||
let panelEl = null;
|
||||
let triggerEl = null;
|
||||
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function toIso(d) {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function parseIso(s) {
|
||||
if (!s) return null;
|
||||
const p = String(s).split("-");
|
||||
if (p.length !== 3) return null;
|
||||
const d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function startOfDay(d) {
|
||||
const x = new Date(d);
|
||||
x.setHours(0, 0, 0, 0);
|
||||
return x;
|
||||
}
|
||||
|
||||
function today() {
|
||||
return startOfDay(new Date());
|
||||
}
|
||||
|
||||
function sameDay(a, b) {
|
||||
return a && b && toIso(a) === toIso(b);
|
||||
}
|
||||
|
||||
function formatLabel(from, to) {
|
||||
if (!from || !to) return "Выберите даты";
|
||||
const t = today();
|
||||
const fmt = (d) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`;
|
||||
if (sameDay(from, to) && sameDay(from, t)) return "Сегодня";
|
||||
if (sameDay(from, to)) return fmt(from);
|
||||
return `${fmt(from)} — ${fmt(to)}`;
|
||||
}
|
||||
|
||||
function syncHiddenInputs(from, to) {
|
||||
const fromEl = document.getElementById("date-from");
|
||||
const toEl = document.getElementById("date-to");
|
||||
if (fromEl) fromEl.value = from ? toIso(from) : "";
|
||||
if (toEl) toEl.value = to ? toIso(to) : "";
|
||||
}
|
||||
|
||||
function readCommittedRange() {
|
||||
const fromEl = document.getElementById("date-from");
|
||||
const toEl = document.getElementById("date-to");
|
||||
return {
|
||||
from: parseIso(fromEl?.value),
|
||||
to: parseIso(toEl?.value),
|
||||
};
|
||||
}
|
||||
|
||||
function updateTriggerLabel() {
|
||||
const label = document.getElementById("reportsDateRangeLabel");
|
||||
if (!label) return;
|
||||
const { from, to } = readCommittedRange();
|
||||
label.textContent = formatLabel(from, to);
|
||||
}
|
||||
|
||||
function setPresetActive(preset) {
|
||||
activePreset = preset || "";
|
||||
document.querySelectorAll("[data-drp-preset]").forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.getAttribute("data-drp-preset") === activePreset);
|
||||
});
|
||||
}
|
||||
|
||||
function computePresetRange(preset) {
|
||||
const t = today();
|
||||
let from = t;
|
||||
let to = t;
|
||||
if (preset === "week") {
|
||||
from = new Date(t);
|
||||
from.setDate(t.getDate() - ((t.getDay() + 6) % 7));
|
||||
} else if (preset === "month") {
|
||||
from = new Date(t.getFullYear(), t.getMonth(), 1);
|
||||
}
|
||||
return { from: startOfDay(from), to: startOfDay(to) };
|
||||
}
|
||||
|
||||
function commitDraft() {
|
||||
if (draftFrom && !draftTo) draftTo = draftFrom;
|
||||
if (!draftFrom || !draftTo) return readCommittedRange();
|
||||
syncHiddenInputs(draftFrom, draftTo);
|
||||
setPresetActive("");
|
||||
updateTriggerLabel();
|
||||
return { from: draftFrom, to: draftTo };
|
||||
}
|
||||
|
||||
function applyRange(from, to, preset) {
|
||||
syncHiddenInputs(from, to);
|
||||
draftFrom = from;
|
||||
draftTo = to;
|
||||
setPresetActive(preset || "");
|
||||
updateTriggerLabel();
|
||||
}
|
||||
|
||||
function commitSelection() {
|
||||
commitDraft();
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function positionPanel() {
|
||||
if (!triggerEl || !panelEl || panelEl.hidden) return;
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const width = Math.max(rect.width, 304);
|
||||
let left = rect.left;
|
||||
if (left + width > window.innerWidth - 12) {
|
||||
left = Math.max(12, window.innerWidth - width - 12);
|
||||
}
|
||||
let top = rect.bottom + 6;
|
||||
const panelHeight = panelEl.offsetHeight || 360;
|
||||
if (top + panelHeight > window.innerHeight - 12) {
|
||||
top = Math.max(12, rect.top - panelHeight - 6);
|
||||
}
|
||||
panelEl.style.left = `${left}px`;
|
||||
panelEl.style.top = `${top}px`;
|
||||
panelEl.style.width = `${width}px`;
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
if (panelEl) panelEl.hidden = true;
|
||||
if (triggerEl) triggerEl.setAttribute("aria-expanded", "false");
|
||||
window.removeEventListener("resize", positionPanel);
|
||||
window.removeEventListener("scroll", positionPanel, true);
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
const committed = readCommittedRange();
|
||||
draftFrom = committed.from || today();
|
||||
draftTo = committed.to || draftFrom;
|
||||
viewMonth = new Date(draftFrom.getFullYear(), draftFrom.getMonth(), 1);
|
||||
renderCalendar();
|
||||
if (panelEl) panelEl.hidden = false;
|
||||
if (triggerEl) triggerEl.setAttribute("aria-expanded", "true");
|
||||
positionPanel();
|
||||
window.addEventListener("resize", positionPanel);
|
||||
window.addEventListener("scroll", positionPanel, true);
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
if (panelEl?.hidden) openPanel();
|
||||
else closePanel();
|
||||
}
|
||||
|
||||
function inRange(d, a, b) {
|
||||
if (!a || !b) return false;
|
||||
const t = d.getTime();
|
||||
const lo = Math.min(a.getTime(), b.getTime());
|
||||
const hi = Math.max(a.getTime(), b.getTime());
|
||||
return t >= lo && t <= hi;
|
||||
}
|
||||
|
||||
function onDayClick(d) {
|
||||
if (!draftFrom || (draftFrom && draftTo)) {
|
||||
draftFrom = d;
|
||||
draftTo = null;
|
||||
setPresetActive("");
|
||||
renderCalendar();
|
||||
return;
|
||||
}
|
||||
if (d < draftFrom) {
|
||||
draftTo = draftFrom;
|
||||
draftFrom = d;
|
||||
} else {
|
||||
draftTo = d;
|
||||
}
|
||||
setPresetActive("");
|
||||
commitSelection();
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const grid = document.getElementById("reportsDateRangeGrid");
|
||||
const title = document.getElementById("reportsDateRangeMonth");
|
||||
if (!grid || !viewMonth) return;
|
||||
|
||||
const y = viewMonth.getFullYear();
|
||||
const m = viewMonth.getMonth();
|
||||
if (title) title.textContent = `${MONTHS[m]} ${y}`;
|
||||
|
||||
const first = new Date(y, m, 1);
|
||||
const startOffset = (first.getDay() + 6) % 7;
|
||||
const daysInMonth = new Date(y, m + 1, 0).getDate();
|
||||
|
||||
let html = "";
|
||||
for (let i = 0; i < startOffset; i++) {
|
||||
html += '<span class="reports-drp__day reports-drp__day--empty"></span>';
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const d = new Date(y, m, day);
|
||||
const classes = ["reports-drp__day"];
|
||||
if (sameDay(d, today())) classes.push("reports-drp__day--today");
|
||||
if (draftFrom && sameDay(d, draftFrom)) classes.push("reports-drp__day--edge");
|
||||
if (draftTo && sameDay(d, draftTo)) classes.push("reports-drp__day--edge");
|
||||
if (inRange(d, draftFrom, draftTo)) classes.push("reports-drp__day--in-range");
|
||||
html += `<button type="button" class="${classes.join(" ")}" data-drp-day="${toIso(d)}">${day}</button>`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
|
||||
grid.querySelectorAll("[data-drp-day]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const d = parseIso(btn.getAttribute("data-drp-day"));
|
||||
if (d) onDayClick(d);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setPeriod(period) {
|
||||
const { from, to } = computePresetRange(period);
|
||||
applyRange(from, to, period);
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function init(options) {
|
||||
triggerEl = document.getElementById("reportsDateRangeTrigger");
|
||||
panelEl = document.getElementById("reportsDateRangePanel");
|
||||
if (!triggerEl || !panelEl) return;
|
||||
|
||||
if (panelEl.parentElement !== document.body) {
|
||||
document.body.appendChild(panelEl);
|
||||
}
|
||||
|
||||
panelEl.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
panelEl.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
document.querySelectorAll("[data-drp-preset]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
setPeriod(btn.getAttribute("data-drp-preset") || "day");
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("reportsDateRangePrev")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!viewMonth) viewMonth = today();
|
||||
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1);
|
||||
renderCalendar();
|
||||
positionPanel();
|
||||
});
|
||||
document.getElementById("reportsDateRangeNext")?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (!viewMonth) viewMonth = today();
|
||||
viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1);
|
||||
renderCalendar();
|
||||
positionPanel();
|
||||
});
|
||||
|
||||
triggerEl.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
togglePanel();
|
||||
});
|
||||
|
||||
document.addEventListener("click", () => {
|
||||
if (!panelEl.hidden) closePanel();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closePanel();
|
||||
});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const highlightReportId = urlParams.get("report");
|
||||
if (highlightReportId) {
|
||||
global.__wespHighlightReportId = highlightReportId;
|
||||
setPeriod("month");
|
||||
} else {
|
||||
setPeriod("day");
|
||||
}
|
||||
|
||||
if (options?.onReady) options.onReady();
|
||||
}
|
||||
|
||||
global.WespReportsDateRange = {
|
||||
init,
|
||||
setPeriod,
|
||||
commit: commitDraft,
|
||||
updateTriggerLabel,
|
||||
formatLabel,
|
||||
parseIso,
|
||||
toIso,
|
||||
};
|
||||
|
||||
global.setPeriod = setPeriod;
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Экспорт /reports — выбор раздела и формата (Excel / PDF).
|
||||
*/
|
||||
(function (global) {
|
||||
function appendFilterContext(params) {
|
||||
const farms = global.WespReportsFilters?.getSelectedFarmNames?.() || [];
|
||||
const dispensers = global.WespReportsFilters?.getSelectedDispenserNames?.() || [];
|
||||
const recipes = global.WespReportsFilters?.getSelectedRecipeNames?.() || [];
|
||||
params.set("filter_farms", farms.length ? farms.join(",") : "all");
|
||||
params.set("filter_dispensers", dispensers.length ? dispensers.join(",") : "all");
|
||||
params.set("filter_recipes", recipes.length ? recipes.join(",") : "all");
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildExportUrl(format, section) {
|
||||
const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams();
|
||||
appendFilterContext(params);
|
||||
params.set("format", format || "xlsx");
|
||||
params.set("section", section || "all");
|
||||
return `/api/analytics/export?${params.toString()}`;
|
||||
}
|
||||
|
||||
function ensureDates() {
|
||||
const { date_from, date_to } = global.WespReportsFilters?.getDateParams?.() || {};
|
||||
if (!date_from || !date_to) {
|
||||
global.WespZootechNotify?.error?.("Выберите период для экспорта");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function download(format, section) {
|
||||
if (!ensureDates()) return;
|
||||
window.location.href = buildExportUrl(format, section);
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
const menu = document.getElementById("reportsExportMenu");
|
||||
const btn = document.getElementById("reportsExportBtn");
|
||||
if (menu) menu.hidden = true;
|
||||
if (btn) btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
const menu = document.getElementById("reportsExportMenu");
|
||||
const btn = document.getElementById("reportsExportBtn");
|
||||
if (!menu || !btn) return;
|
||||
const open = menu.hidden;
|
||||
menu.hidden = !open;
|
||||
btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
|
||||
function init() {
|
||||
const btn = document.getElementById("reportsExportBtn");
|
||||
const menu = document.getElementById("reportsExportMenu");
|
||||
if (!btn || !menu || btn.dataset.bound) return;
|
||||
btn.dataset.bound = "1";
|
||||
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
toggleMenu();
|
||||
});
|
||||
|
||||
menu.querySelectorAll("[data-reports-export-format]").forEach((item) => {
|
||||
item.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
download(
|
||||
item.getAttribute("data-reports-export-format") || "xlsx",
|
||||
item.getAttribute("data-reports-export-section") || "all"
|
||||
);
|
||||
closeMenu();
|
||||
});
|
||||
});
|
||||
|
||||
menu.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
menu.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
document.addEventListener("click", () => closeMenu());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeMenu();
|
||||
});
|
||||
}
|
||||
|
||||
global.WespReportsExport = { init, buildExportUrl, download, appendFilterContext };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Кастомные выпадашки фильтров /reports (без нативного select macOS).
|
||||
* Скрытый <select> сохраняется для совместимости с существующим кодом.
|
||||
* data-multiselect — чекбоксы (ферма, кормораздатчик, рейс).
|
||||
*/
|
||||
(function (global) {
|
||||
const instances = new Map();
|
||||
|
||||
function closeAll(exceptMenu) {
|
||||
instances.forEach(({ menu }) => {
|
||||
if (menu && menu !== exceptMenu) menu.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
function getMultiSelected(wrap) {
|
||||
try {
|
||||
const raw = wrap?.dataset?.selectedValues || "[]";
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setMultiSelected(wrap, values) {
|
||||
if (!wrap) return;
|
||||
wrap.dataset.selectedValues = JSON.stringify(values || []);
|
||||
}
|
||||
|
||||
function countSelectableOptions(select) {
|
||||
return Array.from(select.options).filter((opt) => opt.value).length;
|
||||
}
|
||||
|
||||
const MULTI_COUNT_LABELS = {
|
||||
"farm-select": "Фермы",
|
||||
"dispenser-select": "Кормораздатчики",
|
||||
"recipe-select": "Рейсы",
|
||||
};
|
||||
|
||||
function formatMultiLabel(select, selected) {
|
||||
const allLabel =
|
||||
select.querySelector('option[value=""]')?.textContent?.trim() || "—";
|
||||
const total = countSelectableOptions(select);
|
||||
const count = selected.length;
|
||||
|
||||
if (!count || (total > 0 && count >= total)) return allLabel;
|
||||
if (count === 1) return selected[0];
|
||||
|
||||
const entity = MULTI_COUNT_LABELS[select.id];
|
||||
if (entity) return `${entity} (${count})`;
|
||||
return `Выбрано: ${count}`;
|
||||
}
|
||||
|
||||
function updateTriggerLabel(select, trigger, wrap) {
|
||||
const textEl = trigger.querySelector(".reports-filter-dropdown__label");
|
||||
let label;
|
||||
if (select.dataset.multiselect === "true" && wrap) {
|
||||
label = formatMultiLabel(select, getMultiSelected(wrap));
|
||||
} else {
|
||||
const opt = select.selectedOptions?.[0];
|
||||
label = opt?.textContent?.trim() || "—";
|
||||
}
|
||||
if (textEl) textEl.textContent = label;
|
||||
trigger.setAttribute(
|
||||
"aria-label",
|
||||
select.getAttribute("aria-label") || label
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchSelectChange(select) {
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
function createCheckboxRow(labelText, checked, onChange) {
|
||||
const row = document.createElement("label");
|
||||
row.className = "reports-filter-dropdown__check";
|
||||
|
||||
const boxWrap = document.createElement("span");
|
||||
boxWrap.className = "zt-checkbox";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.className = "zt-checkbox__input";
|
||||
input.checked = checked;
|
||||
|
||||
const box = document.createElement("span");
|
||||
box.className = "zt-checkbox__box";
|
||||
box.setAttribute("aria-hidden", "true");
|
||||
|
||||
boxWrap.appendChild(input);
|
||||
boxWrap.appendChild(box);
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "reports-filter-dropdown__check-label";
|
||||
text.textContent = labelText;
|
||||
|
||||
input.addEventListener("change", (e) => {
|
||||
e.stopPropagation();
|
||||
onChange(input);
|
||||
});
|
||||
|
||||
row.appendChild(boxWrap);
|
||||
row.appendChild(text);
|
||||
return { row, input };
|
||||
}
|
||||
|
||||
function buildSingleMenu(select, menu, trigger, wrap) {
|
||||
menu.innerHTML = "";
|
||||
Array.from(select.options).forEach((opt) => {
|
||||
const item = document.createElement("button");
|
||||
item.type = "button";
|
||||
item.className = "reports-filter-dropdown__item";
|
||||
item.setAttribute("role", "option");
|
||||
item.dataset.value = opt.value;
|
||||
item.textContent = opt.textContent;
|
||||
if (opt.value === select.value) {
|
||||
item.classList.add("reports-filter-dropdown__item--active");
|
||||
item.setAttribute("aria-selected", "true");
|
||||
}
|
||||
item.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
select.value = opt.value;
|
||||
updateTriggerLabel(select, trigger, wrap);
|
||||
buildSingleMenu(select, menu, trigger, wrap);
|
||||
menu.hidden = true;
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
dispatchSelectChange(select);
|
||||
});
|
||||
menu.appendChild(item);
|
||||
});
|
||||
updateTriggerLabel(select, trigger, wrap);
|
||||
}
|
||||
|
||||
function buildMultiMenu(select, menu, trigger, wrap) {
|
||||
menu.innerHTML = "";
|
||||
const selected = getMultiSelected(wrap);
|
||||
const allLabel =
|
||||
select.querySelector('option[value=""]')?.textContent?.trim() || "—";
|
||||
|
||||
const { row: allRow, input: allInput } = createCheckboxRow(
|
||||
allLabel,
|
||||
selected.length === 0,
|
||||
(input) => {
|
||||
if (input.checked) {
|
||||
setMultiSelected(wrap, []);
|
||||
select.value = "";
|
||||
}
|
||||
buildMultiMenu(select, menu, trigger, wrap);
|
||||
dispatchSelectChange(select);
|
||||
}
|
||||
);
|
||||
menu.appendChild(allRow);
|
||||
|
||||
Array.from(select.options).forEach((opt) => {
|
||||
if (!opt.value) return;
|
||||
const { row } = createCheckboxRow(opt.textContent, selected.includes(opt.value), (input) => {
|
||||
let next = getMultiSelected(wrap);
|
||||
if (input.checked) {
|
||||
if (!next.includes(opt.value)) next = next.concat(opt.value);
|
||||
} else {
|
||||
next = next.filter((v) => v !== opt.value);
|
||||
}
|
||||
setMultiSelected(wrap, next);
|
||||
select.value = "";
|
||||
buildMultiMenu(select, menu, trigger, wrap);
|
||||
dispatchSelectChange(select);
|
||||
});
|
||||
menu.appendChild(row);
|
||||
});
|
||||
|
||||
updateTriggerLabel(select, trigger, wrap);
|
||||
}
|
||||
|
||||
function buildMenu(select, menu, trigger, wrap) {
|
||||
if (select.dataset.multiselect === "true") {
|
||||
buildMultiMenu(select, menu, trigger, wrap);
|
||||
} else {
|
||||
buildSingleMenu(select, menu, trigger, wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function bindSelect(selectId) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select || select.dataset.dropdownBound === "1") {
|
||||
return instances.get(selectId);
|
||||
}
|
||||
select.dataset.dropdownBound = "1";
|
||||
|
||||
const wrap = select.closest(".reports-filter-select");
|
||||
if (!wrap) return null;
|
||||
|
||||
if (select.dataset.multiselect === "true" && !wrap.dataset.selectedValues) {
|
||||
wrap.dataset.selectedValues = "[]";
|
||||
}
|
||||
|
||||
wrap.querySelector(".reports-filter-select__chev")?.remove();
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className =
|
||||
"reports-filter-dropdown__trigger reports-filter-bar__control";
|
||||
trigger.setAttribute("aria-haspopup", "listbox");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.innerHTML =
|
||||
'<span class="reports-filter-dropdown__label"></span>' +
|
||||
'<i class="fas fa-chevron-down reports-filter-dropdown__chev" aria-hidden="true"></i>';
|
||||
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "reports-filter-dropdown__menu";
|
||||
menu.hidden = true;
|
||||
menu.setAttribute("role", "listbox");
|
||||
|
||||
select.classList.add("reports-filter-select__native");
|
||||
wrap.insertBefore(trigger, select);
|
||||
wrap.insertBefore(menu, select);
|
||||
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const willOpen = menu.hidden;
|
||||
closeAll(willOpen ? menu : null);
|
||||
menu.hidden = !willOpen;
|
||||
trigger.setAttribute("aria-expanded", willOpen ? "true" : "false");
|
||||
});
|
||||
|
||||
menu.addEventListener("mousedown", (e) => e.stopPropagation());
|
||||
menu.addEventListener("click", (e) => e.stopPropagation());
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
buildMenu(select, menu, trigger, wrap);
|
||||
});
|
||||
observer.observe(select, { childList: true, subtree: true, attributes: true });
|
||||
|
||||
buildMenu(select, menu, trigger, wrap);
|
||||
|
||||
const api = {
|
||||
select,
|
||||
wrap,
|
||||
trigger,
|
||||
menu,
|
||||
rebuild: () => buildMenu(select, menu, trigger, wrap),
|
||||
observer,
|
||||
getMultiSelected: () => getMultiSelected(wrap),
|
||||
setMultiSelected: (values) => {
|
||||
setMultiSelected(wrap, values);
|
||||
select.value = "";
|
||||
buildMenu(select, menu, trigger, wrap);
|
||||
},
|
||||
};
|
||||
instances.set(selectId, api);
|
||||
return api;
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindSelect("farm-select");
|
||||
bindSelect("dispenser-select");
|
||||
bindSelect("recipe-select");
|
||||
document.addEventListener("click", () => closeAll());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeAll();
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
instances.forEach((api) => api.rebuild?.());
|
||||
}
|
||||
|
||||
function getMultiSelectValues(selectId) {
|
||||
return instances.get(selectId)?.getMultiSelected?.() || [];
|
||||
}
|
||||
|
||||
function setMultiSelectValues(selectId, values) {
|
||||
instances.get(selectId)?.setMultiSelected?.(values);
|
||||
}
|
||||
|
||||
global.WespReportsFilterDropdown = {
|
||||
init,
|
||||
refreshAll,
|
||||
bindSelect,
|
||||
getMultiSelectValues,
|
||||
setMultiSelectValues,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Общие фильтры /reports — даты, ферма, кормораздатчик, рейс.
|
||||
*/
|
||||
(function (global) {
|
||||
function parseLocalDate(iso) {
|
||||
if (!iso) return null;
|
||||
const p = String(iso).split("-");
|
||||
if (p.length !== 3) return null;
|
||||
const d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function getDateParams() {
|
||||
global.WespReportsDateRange?.commit?.();
|
||||
const from = document.getElementById("date-from")?.value || "";
|
||||
const to = document.getElementById("date-to")?.value || "";
|
||||
return { date_from: from, date_to: to };
|
||||
}
|
||||
|
||||
function getMultiValues(selectId) {
|
||||
const fromDropdown =
|
||||
global.WespReportsFilterDropdown?.getMultiSelectValues?.(selectId);
|
||||
if (Array.isArray(fromDropdown)) return fromDropdown.filter(Boolean);
|
||||
const value = (document.getElementById(selectId)?.value || "").trim();
|
||||
return value ? [value] : [];
|
||||
}
|
||||
|
||||
function getSelectedFarmNames() {
|
||||
const wrap = document.getElementById("reportsFarmFilterWrap");
|
||||
if (wrap?.hidden) return [];
|
||||
return getMultiValues("farm-select");
|
||||
}
|
||||
|
||||
/** @deprecated используйте getSelectedFarmNames */
|
||||
function getSelectedFarm() {
|
||||
const names = getSelectedFarmNames();
|
||||
return names.length === 1 ? names[0] : "";
|
||||
}
|
||||
|
||||
function getSelectedDispenserNames() {
|
||||
return getMultiValues("dispenser-select");
|
||||
}
|
||||
|
||||
function getEffectiveDispenserNames() {
|
||||
const selected = getSelectedDispenserNames();
|
||||
if (selected.length) return selected;
|
||||
const farms = getSelectedFarmNames();
|
||||
if (!farms.length) return [];
|
||||
const names = new Set();
|
||||
farms.forEach((farm) => {
|
||||
(global.reportsDispensersByFarm?.[farm] || []).forEach((name) => names.add(name));
|
||||
});
|
||||
return Array.from(names);
|
||||
}
|
||||
|
||||
function getDispenserName() {
|
||||
const names = getSelectedDispenserNames();
|
||||
return names.length === 1 ? names[0] : "";
|
||||
}
|
||||
|
||||
function getDispenserIdsForNames(names) {
|
||||
const map = global.reportsDispenserIdByName || {};
|
||||
return (names || []).map((name) => map[name]).filter(Boolean);
|
||||
}
|
||||
|
||||
function getDispenserIds() {
|
||||
return getDispenserIdsForNames(getEffectiveDispenserNames());
|
||||
}
|
||||
|
||||
function getDispenserId() {
|
||||
const ids = getDispenserIds();
|
||||
return ids.length === 1 ? ids[0] : "";
|
||||
}
|
||||
|
||||
function getRecipeIdsForDispensers(names) {
|
||||
const map = global.reportsRecipesByDispenserName || {};
|
||||
const ids = new Set();
|
||||
(names || []).forEach((name) => {
|
||||
(map[name] || []).forEach((recipe) => {
|
||||
if (recipe?.id) ids.add(String(recipe.id));
|
||||
});
|
||||
});
|
||||
return Array.from(ids);
|
||||
}
|
||||
|
||||
function getSelectedRecipeNames() {
|
||||
return getMultiValues("recipe-select");
|
||||
}
|
||||
|
||||
function getRecipeName() {
|
||||
const names = getSelectedRecipeNames();
|
||||
return names.length === 1 ? names[0] : "";
|
||||
}
|
||||
|
||||
function getRecipeIds() {
|
||||
const names = getSelectedRecipeNames();
|
||||
if (!names.length) return [];
|
||||
const select = document.getElementById("recipe-select");
|
||||
if (!select) return [];
|
||||
const ids = [];
|
||||
names.forEach((name) => {
|
||||
const opt = Array.from(select.options).find((o) => o.value === name);
|
||||
if (opt?.dataset?.recipeId) ids.push(String(opt.dataset.recipeId));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getRecipeId() {
|
||||
const ids = getRecipeIds();
|
||||
return ids.length === 1 ? ids[0] : "";
|
||||
}
|
||||
|
||||
function buildAnalyticsParams() {
|
||||
const params = new URLSearchParams(getDateParams());
|
||||
const recipeIds = getRecipeIds();
|
||||
if (recipeIds.length === 1) {
|
||||
params.set("recipe_id", recipeIds[0]);
|
||||
return params;
|
||||
}
|
||||
if (recipeIds.length > 1) {
|
||||
params.set("recipe_ids", recipeIds.join(","));
|
||||
return params;
|
||||
}
|
||||
const dispenserNames = getEffectiveDispenserNames();
|
||||
if (dispenserNames.length) {
|
||||
const fromDispensers = getRecipeIdsForDispensers(dispenserNames);
|
||||
if (fromDispensers.length) params.set("recipe_ids", fromDispensers.join(","));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function buildAlertsParams() {
|
||||
const params = new URLSearchParams(getDateParams());
|
||||
const dispenserIds = getDispenserIds();
|
||||
if (dispenserIds.length === 1) {
|
||||
params.set("dispenser_id", dispenserIds[0]);
|
||||
} else if (dispenserIds.length > 1) {
|
||||
params.set("dispenser_id", dispenserIds.join(","));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function clearMultiSelect(selectId) {
|
||||
global.WespReportsFilterDropdown?.setMultiSelectValues?.(selectId, []);
|
||||
const select = document.getElementById(selectId);
|
||||
if (select) select.value = "";
|
||||
}
|
||||
|
||||
function clearDispenserSelection() {
|
||||
clearMultiSelect("dispenser-select");
|
||||
}
|
||||
|
||||
function clearFarmSelection() {
|
||||
clearMultiSelect("farm-select");
|
||||
}
|
||||
|
||||
function clearRecipeSelection() {
|
||||
clearMultiSelect("recipe-select");
|
||||
}
|
||||
|
||||
global.WespReportsFilters = {
|
||||
parseLocalDate,
|
||||
getDateParams,
|
||||
getSelectedFarmNames,
|
||||
getSelectedFarm,
|
||||
getSelectedDispenserNames,
|
||||
getEffectiveDispenserNames,
|
||||
getDispenserName,
|
||||
getDispenserId,
|
||||
getDispenserIds,
|
||||
getRecipeIdsForDispensers,
|
||||
getSelectedRecipeNames,
|
||||
getRecipeName,
|
||||
getRecipeId,
|
||||
getRecipeIds,
|
||||
buildAnalyticsParams,
|
||||
buildAlertsParams,
|
||||
clearDispenserSelection,
|
||||
clearFarmSelection,
|
||||
clearRecipeSelection,
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,883 @@
|
||||
import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js";
|
||||
import { createSetupOrchestrator } from "/static/js/setup-orchestrator.js";
|
||||
import { SetupTouchKeyboard } from "/static/js/setup-touch-keyboard.js";
|
||||
import {
|
||||
DEFAULT_PASSWORD_MIN_LEN,
|
||||
validateNewUserCredentials,
|
||||
} from "/static/js/wesp-user-credentials.js";
|
||||
|
||||
const API = {
|
||||
status: "/api/setup/status",
|
||||
deviceRole: "/api/setup/device-role",
|
||||
network: "/api/setup/network",
|
||||
sync: "/api/setup/sync",
|
||||
syncProgress: "/api/setup/sync/progress",
|
||||
users: "/api/setup/users",
|
||||
hardware: "/api/setup/hardware/check",
|
||||
kioskPrepare: "/api/setup/kiosk/prepare",
|
||||
kioskSetupAvailable: "/api/kiosk/setup-available",
|
||||
complete: "/api/setup/complete",
|
||||
kioskAccessLink: "/api/kiosk/access-link",
|
||||
kioskStatus: "/api/kiosk/status",
|
||||
};
|
||||
|
||||
const THEME_STORAGE_KEY = "theme";
|
||||
const STEP_LABELS = {
|
||||
theme: "Тема",
|
||||
welcome: "Начало",
|
||||
device: "Устройство",
|
||||
network: "Сеть",
|
||||
sync: "Сервер",
|
||||
users: "Пользователи",
|
||||
hardware: "Весы",
|
||||
kiosk: "Терминал",
|
||||
bootstrap: "Синхронизация",
|
||||
done: "Готово",
|
||||
};
|
||||
function passwordMinLen(statusData) {
|
||||
const n = Number(statusData?.user_rules?.password_min_len);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_PASSWORD_MIN_LEN;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const msg = globalThis.WespUserMessages?.messageFromResponseBody?.(
|
||||
data,
|
||||
"Не удалось выполнить запрос"
|
||||
) || "Не удалось выполнить запрос";
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function setupUserError(message, fallback) {
|
||||
return globalThis.WespUserMessages?.userFacingMessage?.(message, fallback) || fallback || "Не удалось выполнить операцию.";
|
||||
}
|
||||
|
||||
async function canIssueKioskAccessLink() {
|
||||
try {
|
||||
const data = await fetchJson(API.kioskSetupAvailable);
|
||||
return !!data.can_issue_access_link;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function field(label, input) {
|
||||
const wrap = el("div", "wesp-setup__field");
|
||||
wrap.appendChild(el("label", "wesp-setup__label", label));
|
||||
wrap.appendChild(input);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function fieldWithSuffix(label, input, suffixText) {
|
||||
const wrap = el("div", "wesp-setup__field");
|
||||
wrap.appendChild(el("label", "wesp-setup__label", label));
|
||||
const group = el("div", "wesp-setup__input-group");
|
||||
group.appendChild(input);
|
||||
group.appendChild(el("span", "wesp-setup__input-suffix", suffixText));
|
||||
wrap.appendChild(group);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function normalizeHostnameLabel(value) {
|
||||
return String(value || "").trim().toLowerCase().replace(/\.local$/i, "");
|
||||
}
|
||||
|
||||
function formatSyncServerDisplay(serverUrl) {
|
||||
const raw = String(serverUrl || "").trim().replace(/\/+$/, "");
|
||||
if (raw) return raw;
|
||||
return "http://komton_srv_1.local";
|
||||
}
|
||||
|
||||
function buildSyncServerUrl(raw) {
|
||||
const value = String(raw || "").trim().replace(/\/+$/, "");
|
||||
if (!value) return "";
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
if (/^\d+\.\d+\.\d+\.\d+(:\d+)?$/i.test(value)) return `http://${value}`;
|
||||
const host = normalizeHostnameLabel(value);
|
||||
return host ? `http://${host}.local` : "";
|
||||
}
|
||||
|
||||
function readSetupTheme() {
|
||||
try {
|
||||
const t = (localStorage.getItem(THEME_STORAGE_KEY) || "").trim().toLowerCase();
|
||||
return t === "light" ? "light" : "dark";
|
||||
} catch {
|
||||
return "dark";
|
||||
}
|
||||
}
|
||||
|
||||
function applySetupTheme(theme) {
|
||||
const next = theme === "dark" ? "dark" : "light";
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function stepIndicatorLabel(step) {
|
||||
return STEP_LABELS[step] || step || "";
|
||||
}
|
||||
|
||||
function textInput(id, placeholder, type = "text") {
|
||||
const input = document.createElement("input");
|
||||
input.className = "wesp-setup__input";
|
||||
input.id = id;
|
||||
input.type = type;
|
||||
input.placeholder = placeholder;
|
||||
return input;
|
||||
}
|
||||
|
||||
function postSetupLandingPath(statusData) {
|
||||
const role = statusData?.setup?.device_role;
|
||||
const scaleHw = Boolean(statusData?.hardware?.scale_hardware_platform);
|
||||
if (role === "client" && scaleHw) return "/scales";
|
||||
return "/login";
|
||||
}
|
||||
|
||||
export async function mountSetupWizard(root) {
|
||||
const orchestrator = createSetupOrchestrator();
|
||||
let statusData = null;
|
||||
let bootstrapTimer = 0;
|
||||
let activeTouchKeyboard = null;
|
||||
let introStarted = false;
|
||||
|
||||
const intro = el("div", "wesp-setup__intro");
|
||||
intro.setAttribute("aria-hidden", "false");
|
||||
root.appendChild(intro);
|
||||
|
||||
const panel = el("div", "wesp-setup__panel wesp-setup__panel--hidden");
|
||||
panel.setAttribute("role", "dialog");
|
||||
panel.setAttribute("aria-live", "polite");
|
||||
root.appendChild(panel);
|
||||
|
||||
const statusPromise = loadStatusEarly();
|
||||
|
||||
async function loadStatusEarly() {
|
||||
statusData = await fetchJson(API.status);
|
||||
if (statusData.setup?.setup_completed) {
|
||||
window.location.href = postSetupLandingPath(statusData);
|
||||
return statusData;
|
||||
}
|
||||
if (statusData.setup?.device_role === "client") {
|
||||
orchestrator.setDeviceRole("client");
|
||||
} else {
|
||||
orchestrator.setDeviceRole("server");
|
||||
}
|
||||
return statusData;
|
||||
}
|
||||
|
||||
async function playIntroAnimation() {
|
||||
if (introStarted) return;
|
||||
introStarted = true;
|
||||
await mountWespLogoLoader(intro, {
|
||||
loop: false,
|
||||
embedded: true,
|
||||
transparentBackground: true,
|
||||
knockoutBackground: true,
|
||||
darkTheme: readSetupTheme() === "dark",
|
||||
hideChrome: true,
|
||||
onComplete: () => revealWelcome(),
|
||||
});
|
||||
}
|
||||
|
||||
async function revealWelcome() {
|
||||
intro.classList.add("wesp-setup__intro--done");
|
||||
intro.setAttribute("aria-hidden", "true");
|
||||
await statusPromise;
|
||||
if (statusData?.setup?.setup_completed) return;
|
||||
panel.classList.remove("wesp-setup__panel--hidden");
|
||||
renderTheme();
|
||||
window.setTimeout(() => {
|
||||
if (intro.isConnected) intro.remove();
|
||||
}, 480);
|
||||
}
|
||||
|
||||
playIntroAnimation();
|
||||
|
||||
function setPanelBusy(busy, label = "Сохранение…") {
|
||||
let overlay = panel.querySelector(".wesp-setup__busy");
|
||||
if (busy) {
|
||||
if (!overlay) {
|
||||
overlay = el("div", "wesp-setup__busy");
|
||||
overlay.appendChild(el("span", "wesp-setup__spinner"));
|
||||
overlay.appendChild(el("span", "wesp-setup__busy-label", label));
|
||||
panel.appendChild(overlay);
|
||||
} else {
|
||||
overlay.querySelector(".wesp-setup__busy-label").textContent = label;
|
||||
overlay.hidden = false;
|
||||
}
|
||||
panel.classList.add("wesp-setup__panel--busy");
|
||||
panel.setAttribute("aria-busy", "true");
|
||||
return;
|
||||
}
|
||||
overlay?.remove();
|
||||
panel.classList.remove("wesp-setup__panel--busy");
|
||||
panel.removeAttribute("aria-busy");
|
||||
}
|
||||
|
||||
async function withStepLoading(label, fn) {
|
||||
setPanelBusy(true, label);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
setPanelBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderShell(title, lead) {
|
||||
activeTouchKeyboard?.destroy();
|
||||
activeTouchKeyboard = null;
|
||||
panel.replaceChildren();
|
||||
panel.classList.remove("wesp-setup__panel--busy");
|
||||
panel.removeAttribute("aria-busy");
|
||||
|
||||
const header = el("div", "wesp-setup__header");
|
||||
header.appendChild(
|
||||
el("div", "wesp-setup__step-indicator", `Шаг · ${stepIndicatorLabel(orchestrator.currentStep)}`),
|
||||
);
|
||||
header.appendChild(el("h1", "wesp-setup__title", title));
|
||||
if (lead) header.appendChild(el("p", "wesp-setup__lead", lead));
|
||||
panel.appendChild(header);
|
||||
|
||||
const content = el("div", "wesp-setup__content");
|
||||
panel.appendChild(content);
|
||||
return content;
|
||||
}
|
||||
|
||||
function populateActionRow(row, ...buttons) {
|
||||
row.replaceChildren();
|
||||
let primaryIndex = -1;
|
||||
buttons.forEach((b, i) => {
|
||||
if (b.classList.contains("wesp-setup__btn--primary")) primaryIndex = i;
|
||||
});
|
||||
buttons.forEach((b, i) => {
|
||||
if (!b.classList.contains("wesp-setup__btn--primary")) {
|
||||
b.classList.add("wesp-setup__btn--ghost");
|
||||
}
|
||||
if (i === primaryIndex && primaryIndex > 0) {
|
||||
row.appendChild(el("div", "wesp-setup__actions-fill"));
|
||||
}
|
||||
row.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function actions(...buttons) {
|
||||
const footer = el("div", "wesp-setup__footer");
|
||||
const row = el("div", "wesp-setup__actions");
|
||||
populateActionRow(row, ...buttons);
|
||||
footer.appendChild(row);
|
||||
panel.appendChild(footer);
|
||||
return row;
|
||||
}
|
||||
|
||||
function msg(text, kind, target) {
|
||||
const m = el("p", `wesp-setup__msg${kind ? ` wesp-setup__msg--${kind}` : ""}`, text || "");
|
||||
(target || panel.querySelector(".wesp-setup__content") || panel).appendChild(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
function btn(label, className, onClick) {
|
||||
const b = el("button", `wesp-setup__btn${className ? ` ${className}` : ""}`, label);
|
||||
b.type = "button";
|
||||
b.addEventListener("click", onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
statusData = await fetchJson(API.status);
|
||||
if (statusData.setup?.setup_completed) {
|
||||
window.location.href = postSetupLandingPath(statusData);
|
||||
return statusData;
|
||||
}
|
||||
if (statusData.setup?.device_role === "client") {
|
||||
orchestrator.setDeviceRole("client");
|
||||
} else {
|
||||
orchestrator.setDeviceRole("server");
|
||||
}
|
||||
return statusData;
|
||||
}
|
||||
|
||||
function shouldShowBootstrap() {
|
||||
return !!statusData?.setup?.sync_server_connected;
|
||||
}
|
||||
|
||||
function goAfterKiosk() {
|
||||
if (shouldShowBootstrap()) renderBootstrap();
|
||||
else goComplete();
|
||||
}
|
||||
|
||||
async function goComplete() {
|
||||
orchestrator.setStep("done");
|
||||
const data = await withStepLoading("Завершение…", () =>
|
||||
fetchJson(API.complete, { method: "POST" }),
|
||||
);
|
||||
const fallbackRedirect = postSetupLandingPath(statusData);
|
||||
const redirect = data.redirect || fallbackRedirect;
|
||||
const opensScales = redirect === "/scales";
|
||||
renderShell(
|
||||
"Готово",
|
||||
opensScales
|
||||
? "Система настроена. Сейчас откроется экран весов."
|
||||
: "Система настроена. Сейчас откроется страница входа.",
|
||||
);
|
||||
msg(data.message, "ok");
|
||||
actions(btn("Открыть", "wesp-setup__btn--primary", () => {
|
||||
window.location.href = redirect;
|
||||
}));
|
||||
window.setTimeout(() => {
|
||||
window.location.href = redirect;
|
||||
}, 2200);
|
||||
}
|
||||
|
||||
function renderTheme() {
|
||||
orchestrator.setStep("theme");
|
||||
const content = renderShell(
|
||||
"Оформление",
|
||||
"Выберите тему. Её можно сменить позже в меню терминала.",
|
||||
);
|
||||
let selected = readSetupTheme();
|
||||
applySetupTheme(selected);
|
||||
|
||||
const grid = el("div", "wesp-setup__role-grid wesp-setup__role-grid--cards wesp-setup__theme-grid");
|
||||
const options = [
|
||||
["light", "Светлая", "Светлый фон и контрастные элементы."],
|
||||
["dark", "Тёмная", "Удобнее при слабом освещении."],
|
||||
];
|
||||
const buttons = [];
|
||||
options.forEach(([value, title, hint]) => {
|
||||
const b = el("button", `wesp-setup__role-btn wesp-setup__role-card wesp-setup__theme-card wesp-setup__theme-card--${value}`, "");
|
||||
b.type = "button";
|
||||
b.innerHTML = `<strong>${title}</strong><span>${hint}</span>`;
|
||||
if (selected === value) b.classList.add("is-selected");
|
||||
b.addEventListener("click", () => {
|
||||
selected = value;
|
||||
applySetupTheme(selected);
|
||||
buttons.forEach((x) => x.classList.toggle("is-selected", x === b));
|
||||
});
|
||||
buttons.push(b);
|
||||
grid.appendChild(b);
|
||||
});
|
||||
content.appendChild(grid);
|
||||
|
||||
actions(btn("Далее", "wesp-setup__btn--primary", () => renderWelcome()));
|
||||
}
|
||||
|
||||
function renderWelcome() {
|
||||
orchestrator.setStep("welcome");
|
||||
renderShell(
|
||||
"Добро пожаловать",
|
||||
"Мастер поможет выбрать роль устройства и базовые параметры.",
|
||||
);
|
||||
const health = statusData?.health;
|
||||
if (health?.ok) {
|
||||
msg("Система готова к настройке.", "ok");
|
||||
} else {
|
||||
msg("Проверка health… при ошибках миграций обратитесь к установщику.", "error");
|
||||
}
|
||||
actions(btn("Начать", "wesp-setup__btn--primary", () => renderDevice()));
|
||||
}
|
||||
|
||||
function renderDevice() {
|
||||
orchestrator.setStep("device");
|
||||
const content = renderShell("Тип устройства", "Где будет работать это устройство?");
|
||||
const grid = el("div", "wesp-setup__role-grid wesp-setup__role-grid--cards");
|
||||
const roles = [
|
||||
["server", "Главный сервер"],
|
||||
["client", "Весовой терминал"],
|
||||
];
|
||||
let selected = orchestrator.deviceRole === "client" ? "client" : "server";
|
||||
const buttons = [];
|
||||
roles.forEach(([value, title]) => {
|
||||
const b = el("button", "wesp-setup__role-btn wesp-setup__role-card", "");
|
||||
b.type = "button";
|
||||
b.innerHTML = `<strong>${title}</strong>`;
|
||||
if (selected === value) b.classList.add("is-selected");
|
||||
b.addEventListener("click", () => {
|
||||
selected = value;
|
||||
buttons.forEach((x) => x.classList.toggle("is-selected", x === b));
|
||||
});
|
||||
buttons.push(b);
|
||||
grid.appendChild(b);
|
||||
});
|
||||
content.appendChild(grid);
|
||||
const statusEl = msg("");
|
||||
actions(
|
||||
btn("Назад", "", () => renderWelcome()),
|
||||
btn("Далее", "wesp-setup__btn--primary", async () => {
|
||||
try {
|
||||
const data = await withStepLoading("Сохранение…", () =>
|
||||
fetchJson(API.deviceRole, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ device_role: selected }),
|
||||
}),
|
||||
);
|
||||
if (data?.connection && statusData) {
|
||||
statusData.connection = data.connection;
|
||||
}
|
||||
orchestrator.setDeviceRole(selected);
|
||||
if (selected === "server") renderNetwork();
|
||||
else renderSync();
|
||||
} catch (e) {
|
||||
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function renderNetwork() {
|
||||
orchestrator.setStep("network");
|
||||
const content = renderShell(
|
||||
"Сеть",
|
||||
"Как этот сервер будет называться в вашей Wi‑Fi сети.",
|
||||
);
|
||||
const net = statusData?.network || {};
|
||||
const hostDefault = normalizeHostnameLabel(
|
||||
net.local_hostname || net.default_local_hostname || "komton_srv_1",
|
||||
);
|
||||
const hostLocked = !!net.local_hostname_locked_by_env;
|
||||
const hostInput = textInput("setupHost", hostDefault);
|
||||
hostInput.placeholder = "komton_srv_1";
|
||||
hostInput.disabled = hostLocked;
|
||||
hostInput.autocomplete = "off";
|
||||
hostInput.spellcheck = false;
|
||||
hostInput.addEventListener("input", () => {
|
||||
const cleaned = normalizeHostnameLabel(hostInput.value);
|
||||
if (hostInput.value !== cleaned) hostInput.value = cleaned;
|
||||
});
|
||||
|
||||
content.appendChild(fieldWithSuffix("Имя сервера", hostInput, ".local"));
|
||||
content.appendChild(
|
||||
el(
|
||||
"p",
|
||||
"wesp-setup__hint",
|
||||
"Латиница и цифры, без пробелов. Суффикс .local добавится автоматически.",
|
||||
),
|
||||
);
|
||||
if (net.detected?.lan_ip) {
|
||||
content.appendChild(el("p", "wesp-setup__hint", `IP в сети: ${net.detected.lan_ip}`));
|
||||
}
|
||||
const statusEl = msg("");
|
||||
actions(
|
||||
btn("Назад", "", () => renderDevice()),
|
||||
btn("Далее", "wesp-setup__btn--primary", async () => {
|
||||
try {
|
||||
const body = { local_hostname: normalizeHostnameLabel(hostInput.value) };
|
||||
if (!net.mdns_enabled_locked_by_env) {
|
||||
body.mdns_enabled = true;
|
||||
}
|
||||
await withStepLoading("Сохранение…", async () => {
|
||||
await fetchJson(API.network, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
await loadStatus();
|
||||
});
|
||||
renderUsers();
|
||||
} catch (e) {
|
||||
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
orchestrator.setStep("users");
|
||||
const content = renderShell(
|
||||
"Учётные записи",
|
||||
"Создайте учётную запись зоотехника для работы с рецептами.",
|
||||
);
|
||||
const zootechUsers = statusData?.zootech_users || [];
|
||||
if (zootechUsers.length) {
|
||||
content.appendChild(
|
||||
el(
|
||||
"p",
|
||||
"wesp-setup__info",
|
||||
`Уже есть: ${zootechUsers.join(", ")}. Тот же логин — обновит пароль, новый — добавит ещё одного зоотехника.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const zootechLogin = textInput("zootechLogin", zootechUsers[0] || "zootech");
|
||||
const zootechPass = textInput("zootechPass", "Пароль", "password");
|
||||
const zootechConfirm = textInput("zootechConfirm", "Повтор пароля", "password");
|
||||
content.appendChild(field("Логин", zootechLogin));
|
||||
content.appendChild(field("Пароль", zootechPass));
|
||||
content.appendChild(field("Подтверждение", zootechConfirm));
|
||||
|
||||
const statusEl = msg("");
|
||||
actions(
|
||||
btn("Назад", "", () => renderNetwork()),
|
||||
btn("Далее", "wesp-setup__btn--primary", async () => {
|
||||
const validationError = validateNewUserCredentials({
|
||||
login: zootechLogin.value,
|
||||
password: zootechPass.value,
|
||||
confirmPassword: zootechConfirm.value,
|
||||
passwordMinLen: passwordMinLen(statusData),
|
||||
label: "Зоотехник",
|
||||
});
|
||||
if (validationError) {
|
||||
statusEl.textContent = validationError;
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await withStepLoading("Проверка…", () =>
|
||||
fetchJson(API.users, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
zootech: {
|
||||
login: zootechLogin.value.trim(),
|
||||
password: zootechPass.value,
|
||||
confirm: zootechConfirm.value,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await loadStatus();
|
||||
statusEl.textContent = data.message || "Сохранено.";
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
|
||||
window.setTimeout(() => goComplete(), 350);
|
||||
} catch (e) {
|
||||
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function renderHardware() {
|
||||
orchestrator.setStep("hardware");
|
||||
const content = renderShell(
|
||||
"Весы",
|
||||
"Проверьте подключение весов или выполните калибровку.",
|
||||
);
|
||||
const platform = statusData?.hardware || {};
|
||||
if (platform.scale_hardware_platform) {
|
||||
content.appendChild(
|
||||
el(
|
||||
"p",
|
||||
"wesp-setup__hint",
|
||||
"Платформа ARM — можно подключить весы HX711 к Raspberry Pi.",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
content.appendChild(
|
||||
el(
|
||||
"p",
|
||||
"wesp-setup__info wesp-setup__info--warn",
|
||||
platform.message || "Весы не найдены",
|
||||
),
|
||||
);
|
||||
}
|
||||
const statusEl = msg("");
|
||||
actions(
|
||||
btn("Назад", "", () => renderSync()),
|
||||
btn("Проверить весы", "", async () => {
|
||||
if (!platform.scale_hardware_platform) {
|
||||
statusEl.textContent = platform.message || "Весы не найдены";
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await withStepLoading("Проверка весов…", () =>
|
||||
fetchJson(API.hardware, { method: "POST" }),
|
||||
);
|
||||
statusEl.textContent = data.ok
|
||||
? `OK · вес ${data.hardware?.current_weight_kg ?? "—"} кг`
|
||||
: "HX711 не отвечает — проверьте подключение или включите симуляцию в админке.";
|
||||
statusEl.className = `wesp-setup__msg ${data.ok ? "wesp-setup__msg--ok" : "wesp-setup__msg--error"}`;
|
||||
} catch (e) {
|
||||
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
}
|
||||
}),
|
||||
btn("Калибровка", "", () => {
|
||||
if (window.WespKioskCalibration?.openModal) {
|
||||
window.WespKioskCalibration.openModal({ zIndex: 10050, theme: readSetupTheme() });
|
||||
return;
|
||||
}
|
||||
window.open("/calibration?embed=1", "_blank", "noopener,noreferrer");
|
||||
}),
|
||||
btn("Далее", "wesp-setup__btn--primary", () => renderKiosk()),
|
||||
);
|
||||
}
|
||||
|
||||
async function renderKiosk() {
|
||||
orchestrator.setStep("kiosk");
|
||||
|
||||
const localSetup = await canIssueKioskAccessLink();
|
||||
if (!localSetup) {
|
||||
const content = renderShell(
|
||||
"Настройка терминала",
|
||||
"Этот шаг выполняется на самом терминале.",
|
||||
);
|
||||
content.appendChild(el("p", "wesp-setup__info", "Настройка производится на терминале."));
|
||||
actions(
|
||||
btn("Назад", "", () => renderHardware()),
|
||||
btn("Далее", "wesp-setup__btn--primary", () => goAfterKiosk()),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = renderShell(
|
||||
"Настройка терминала",
|
||||
"Привязка планшета тракториста по QR-коду.",
|
||||
);
|
||||
content.appendChild(
|
||||
el(
|
||||
"p",
|
||||
"wesp-setup__info",
|
||||
"Дополнительные планшеты можно привязать позже через меню ☰ → Привязать терминал.",
|
||||
),
|
||||
);
|
||||
const qrWrap = el("div", "wesp-setup__qr-wrap");
|
||||
const qr = el("img", "wesp-setup__qr");
|
||||
qr.alt = "QR Start URL";
|
||||
const urlEl = el("p", "wesp-setup__url");
|
||||
qrWrap.appendChild(qr);
|
||||
content.appendChild(qrWrap);
|
||||
content.appendChild(urlEl);
|
||||
const statusEl = msg("");
|
||||
|
||||
try {
|
||||
await withStepLoading("Подготовка QR…", async () => {
|
||||
await fetchJson(API.kioskPrepare, { method: "POST" });
|
||||
const data = await fetchJson(API.kioskAccessLink);
|
||||
qr.src = data.qr_image_url || "";
|
||||
urlEl.textContent = data.start_url || "";
|
||||
});
|
||||
statusEl.textContent = "Вставьте ссылку в Fully Kiosk → Start URL.";
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
|
||||
} catch (e) {
|
||||
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
}
|
||||
|
||||
actions(
|
||||
btn("Назад", "", () => renderHardware()),
|
||||
btn("Проверить статус", "", async () => {
|
||||
try {
|
||||
const st = await withStepLoading("Проверка…", () => fetchJson(API.kioskStatus));
|
||||
statusEl.textContent = st.paired ? "Терминал привязан." : "Ожидание привязки…";
|
||||
statusEl.className = `wesp-setup__msg ${st.paired ? "wesp-setup__msg--ok" : ""}`;
|
||||
} catch (e) {
|
||||
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
}
|
||||
}),
|
||||
btn("Пропустить", "", () => goAfterKiosk()),
|
||||
btn("Далее", "wesp-setup__btn--primary", () => goAfterKiosk()),
|
||||
);
|
||||
}
|
||||
|
||||
function renderSync() {
|
||||
orchestrator.setStep("sync");
|
||||
const content = renderShell(
|
||||
"Подключение к серверу",
|
||||
"Укажите адрес главного сервера и имя этого терминала.",
|
||||
);
|
||||
const conn = statusData?.connection || {};
|
||||
const serverLocked = !!conn.server_url_locked_by_env;
|
||||
const serverInput = textInput(
|
||||
"syncServer",
|
||||
serverLocked ? conn.server_url || conn.env_server_url || "" : formatSyncServerDisplay(conn.server_url),
|
||||
);
|
||||
serverInput.placeholder = "komton_srv_1.local";
|
||||
serverInput.disabled = serverLocked;
|
||||
content.appendChild(field("Адрес сервера", serverInput));
|
||||
if (serverLocked) {
|
||||
content.appendChild(
|
||||
el(
|
||||
"p",
|
||||
"wesp-setup__hint",
|
||||
"Адрес задан в настройках системы (WESP_SYNC_SERVER_URL).",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
content.appendChild(
|
||||
el(
|
||||
"p",
|
||||
"wesp-setup__hint",
|
||||
"Имя сервера (komton_srv_1.local) или IP, например http://192.168.0.10.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const nameDefault =
|
||||
conn.client_name ||
|
||||
normalizeHostnameLabel(statusData?.network?.local_hostname || "") ||
|
||||
statusData?.network?.detected?.os_hostname ||
|
||||
"vesy_1";
|
||||
const nameInput = textInput("syncName", nameDefault);
|
||||
nameInput.placeholder = "vesy_1";
|
||||
content.appendChild(field("Название терминала", nameInput));
|
||||
|
||||
const statusEl = msg("");
|
||||
let actionsRow = null;
|
||||
|
||||
async function submitSync(offline) {
|
||||
const serverUrl = buildSyncServerUrl(serverInput.value);
|
||||
if (!serverUrl && !serverLocked) {
|
||||
statusEl.textContent = "Укажите адрес сервера.";
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
client_name: nameInput.value.trim(),
|
||||
offline: !!offline,
|
||||
};
|
||||
if (!serverLocked) {
|
||||
body.server_url = serverUrl;
|
||||
}
|
||||
await withStepLoading(offline ? "Сохранение…" : "Подключение…", () =>
|
||||
fetchJson(API.sync, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
await loadStatus();
|
||||
renderHardware();
|
||||
}
|
||||
|
||||
function showSyncFailureChoices(errorMessage) {
|
||||
statusEl.textContent = setupUserError(errorMessage, "Не удалось подключиться к серверу.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
if (!actionsRow) return;
|
||||
populateActionRow(
|
||||
actionsRow,
|
||||
btn("Исправить адрес", "", () => {
|
||||
serverInput.focus();
|
||||
statusEl.textContent = "";
|
||||
statusEl.className = "wesp-setup__msg";
|
||||
populateActionRow(
|
||||
actionsRow,
|
||||
btn("Назад", "", () => renderDevice()),
|
||||
btn("Далее", "wesp-setup__btn--primary", onNext),
|
||||
);
|
||||
}),
|
||||
btn("Повторить", "wesp-setup__btn--primary", onNext),
|
||||
btn("Продолжить без сервера", "", async () => {
|
||||
try {
|
||||
await submitSync(true);
|
||||
} catch (e) {
|
||||
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function onNext() {
|
||||
try {
|
||||
await submitSync(false);
|
||||
} catch (e) {
|
||||
showSyncFailureChoices(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
actionsRow = actions(
|
||||
btn("Назад", "", () => renderDevice()),
|
||||
btn("Далее", "wesp-setup__btn--primary", onNext),
|
||||
);
|
||||
|
||||
activeTouchKeyboard = new SetupTouchKeyboard(panel);
|
||||
if (!serverInput.disabled) {
|
||||
activeTouchKeyboard.attach(serverInput, { layout: "url", maxLength: 256 });
|
||||
}
|
||||
activeTouchKeyboard.attach(nameInput, { layout: "hostname", maxLength: 200 });
|
||||
nameInput.addEventListener("input", () => {
|
||||
const cleaned = normalizeHostnameLabel(nameInput.value);
|
||||
if (nameInput.value !== cleaned) nameInput.value = cleaned;
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
if (!serverInput.disabled) serverInput.focus();
|
||||
else nameInput.focus();
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function renderBootstrap() {
|
||||
if (!shouldShowBootstrap()) {
|
||||
goComplete();
|
||||
return;
|
||||
}
|
||||
orchestrator.setStep("bootstrap");
|
||||
const content = renderShell(
|
||||
"Первая синхронизация",
|
||||
"Дождитесь загрузки данных с сервера.",
|
||||
);
|
||||
const bar = el("div", "wesp-setup__progress-bar");
|
||||
const fill = el("div", "wesp-setup__progress-fill");
|
||||
bar.appendChild(fill);
|
||||
content.appendChild(bar);
|
||||
const statusEl = msg("Ожидание синхронизации…");
|
||||
|
||||
function stopPoll() {
|
||||
if (bootstrapTimer) window.clearInterval(bootstrapTimer);
|
||||
bootstrapTimer = 0;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const data = await fetchJson(API.syncProgress);
|
||||
const p = data.initial_sync_progress || {};
|
||||
const percent = p.percent != null ? Number(p.percent) : data.first_bootstrap_done ? 100 : 10;
|
||||
fill.style.width = `${Math.min(100, percent)}%`;
|
||||
orchestrator.setBootstrapProgress(percent / 100);
|
||||
if (data.first_bootstrap_done) {
|
||||
stopPoll();
|
||||
statusEl.textContent = "Синхронизация завершена.";
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--ok";
|
||||
} else if (p.active) {
|
||||
statusEl.textContent = `Синхронизация… ${Math.round(percent)}%`;
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = setupUserError(e.message, "Не удалось выполнить шаг настройки.");
|
||||
statusEl.className = "wesp-setup__msg wesp-setup__msg--error";
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
bootstrapTimer = window.setInterval(poll, 2000);
|
||||
|
||||
actions(
|
||||
btn("Назад", "", () => {
|
||||
stopPoll();
|
||||
renderKiosk();
|
||||
}),
|
||||
btn("Далее", "wesp-setup__btn--primary", () => {
|
||||
stopPoll();
|
||||
goComplete();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await statusPromise;
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const root = document.getElementById("wespSetupRoot");
|
||||
if (root) mountSetupWizard(root);
|
||||
});
|
||||
} else {
|
||||
const root = document.getElementById("wespSetupRoot");
|
||||
if (root) mountSetupWizard(root);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Прогноз запаса на /feed_consumption.
|
||||
*/
|
||||
(function (global) {
|
||||
const COPY = () => global.WespAnalyticsCopy || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
let forecastByComponentId = {};
|
||||
|
||||
async function fetchForecast() {
|
||||
try {
|
||||
const resp = await fetch("/api/analytics/stock-forecast");
|
||||
if (!resp.ok) return null;
|
||||
return await resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBanner(data) {
|
||||
const el = document.getElementById("stockForecastBanner");
|
||||
if (!el) return;
|
||||
const text = (data && data.alertBanner) || "";
|
||||
if (!text) {
|
||||
el.hidden = true;
|
||||
el.textContent = "";
|
||||
return;
|
||||
}
|
||||
el.hidden = false;
|
||||
el.innerHTML =
|
||||
`<strong>${escapeHtml(COPY().stock?.bannerTitle || "Обратите внимание:")}</strong> ` +
|
||||
escapeHtml(text);
|
||||
}
|
||||
|
||||
function applyToCards() {
|
||||
document.querySelectorAll(".component-card[data-forecast-key], .component-card[data-component-id]").forEach((card) => {
|
||||
const key = card.getAttribute("data-forecast-key") || card.getAttribute("data-component-id");
|
||||
const fc = forecastByComponentId[key];
|
||||
if (!fc) return;
|
||||
const adjEl = card.querySelector("[data-stock-days-adjusted]");
|
||||
const explEl = card.querySelector("[data-stock-explanation]");
|
||||
const label = fc.daysLeftAdjustedLabel || fc.days_left_adjusted_label;
|
||||
if (adjEl && label) {
|
||||
adjEl.textContent = label;
|
||||
const daysVal = fc.daysLeftAdjusted != null ? fc.daysLeftAdjusted : fc.days_left_adjusted;
|
||||
adjEl.closest(".info-item")?.classList.toggle(
|
||||
"info-item--warn",
|
||||
daysVal != null && daysVal <= 2
|
||||
);
|
||||
}
|
||||
if (explEl && fc.explanation) {
|
||||
explEl.textContent = fc.explanation;
|
||||
explEl.hidden = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const data = await fetchForecast();
|
||||
forecastByComponentId = {};
|
||||
(data?.items || []).forEach((item) => {
|
||||
const key = item.forecast_key || item.component_id || item.name_key;
|
||||
if (key) forecastByComponentId[key] = item;
|
||||
});
|
||||
renderBanner(data);
|
||||
applyToCards();
|
||||
return data;
|
||||
}
|
||||
|
||||
function getForecast(componentId) {
|
||||
return forecastByComponentId[componentId] || null;
|
||||
}
|
||||
|
||||
global.WespStockForecast = { refresh, getForecast, applyToCards };
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
Reference in New Issue
Block a user