Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
@@ -0,0 +1,367 @@
/**
* Редактор серверной SQLite: дерево bind → таблицы, сетка с сохранением через /api/admin/db/*.
*/
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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, "&amp;").replace(/</g, "&lt;")
);
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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="Удалить">&times;</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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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}">&times;</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);
+284
View File
@@ -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,95 @@
/**
* K-hub «Мультисервер»: manual conflict resolution (orchestrator vs farm hub).
*/
(function (global) {
const API = "/api/v1/sync/conflicts";
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function renderList(root, items, enterpriseId) {
if (!items.length) {
root.innerHTML = '<p class="text-muted small">Нет конфликтов синхронизации.</p>';
return;
}
root.innerHTML =
'<table class="table table-sm zt-multiserver-table"><thead><tr>' +
"<th>Таблица</th><th>Запись</th><th>Ферма</th><th></th></tr></thead><tbody>" +
items
.map(
(c) =>
`<tr data-conflict-id="${escapeHtml(c.id)}">` +
`<td>${escapeHtml(c.table_name)}</td>` +
`<td>${escapeHtml(c.record_id)}</td>` +
`<td>${escapeHtml(c.farm_hub_id || "—")}</td>` +
`<td><button type="button" class="btn btn-sm btn-outline-primary" data-action="open-conflict">Открыть</button></td>` +
"</tr>",
)
.join("") +
"</tbody></table>";
root.querySelectorAll("[data-action=open-conflict]").forEach((btn) => {
btn.addEventListener("click", () => {
const row = btn.closest("tr");
const id = row && row.getAttribute("data-conflict-id");
if (id) openDetail(root, id, enterpriseId);
});
});
}
function openDetail(root, conflictId, enterpriseId) {
fetch(`${API}/${conflictId}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((detail) => {
root.innerHTML =
`<div class="zt-multiserver-detail">` +
`<h4>${escapeHtml(detail.table_name)} · ${escapeHtml(detail.record_id)}</h4>` +
`<div class="row"><div class="col-md-6"><h5>Оркестр</h5><pre class="zt-multiserver-pre">${escapeHtml(JSON.stringify(detail.orchestrator_snapshot, null, 2))}</pre></div>` +
`<div class="col-md-6"><h5>Сервер</h5><pre class="zt-multiserver-pre">${escapeHtml(JSON.stringify(detail.hub_snapshot, null, 2))}</pre></div></div>` +
`<div class="zt-multiserver-actions">` +
`<button type="button" class="btn btn-primary btn-sm" data-resolution="keep_orchestrator">Принять с оркестра</button> ` +
`<button type="button" class="btn btn-secondary btn-sm" data-resolution="keep_hub">Принять с сервера</button> ` +
`<button type="button" class="btn btn-link btn-sm" data-action="back-list">Назад</button>` +
`</div></div>`;
root.querySelector("[data-action=back-list]")?.addEventListener("click", () => load(root, enterpriseId));
root.querySelectorAll("[data-resolution]").forEach((btn) => {
btn.addEventListener("click", () => {
const resolution = btn.getAttribute("data-resolution");
fetch(`${API}/${conflictId}/resolve?enterprise_id=${encodeURIComponent(enterpriseId)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resolution }),
}).then(() => load(root, enterpriseId));
});
});
});
}
function load(root, enterpriseId) {
root.innerHTML = '<p class="text-muted small">Загрузка конфликтов…</p>';
fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((items) => renderList(root, items || [], enterpriseId))
.catch(() => {
root.innerHTML = '<p class="text-danger small">Не удалось загрузить конфликты.</p>';
});
}
global.WespMultiserverPanel = {
mount(rootEl, enterpriseId) {
if (!rootEl) return;
load(rootEl, enterpriseId || "");
},
pendingCount(enterpriseId) {
return fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`)
.then((r) => r.json())
.then((items) => (Array.isArray(items) ? items.length : 0))
.catch(() => 0);
},
};
})(window);
@@ -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, "&quot;")}"><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("month");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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);