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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,26 @@
export class AppState {
constructor(initialState = {}) {
this._state = { ...initialState };
this._listeners = new Set();
}
get(key) {
return this._state[key];
}
getAll() {
return { ...this._state };
}
set(patch) {
this._state = { ...this._state, ...patch };
for (const listener of this._listeners) {
listener(this.getAll());
}
}
subscribe(listener) {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
}
@@ -0,0 +1,27 @@
export class CrossTabSync {
constructor(channelName) {
this._channel = null;
if (typeof BroadcastChannel !== "undefined") {
this._channel = new BroadcastChannel(channelName);
}
}
post(type, payload = {}) {
if (!this._channel) return;
this._channel.postMessage({ type, ...payload });
}
onMessage(handler) {
if (!this._channel) return () => {};
const wrapped = (event) => handler(event.data || {});
this._channel.addEventListener("message", wrapped);
return () => this._channel.removeEventListener("message", wrapped);
}
close() {
if (this._channel) {
this._channel.close();
this._channel = null;
}
}
}
@@ -0,0 +1,185 @@
/**
* Модалка выбора компонента-замены (похожие + поиск), как в плане на день.
*/
(function (global) {
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function formatKg(value) {
const n = Number(value);
if (!Number.isFinite(n)) return "—";
const rounded = Math.round(n * 100) / 100;
return String(rounded).replace(/\.?0+$/, "");
}
function renderComponentPickList(items, emptyText) {
if (!items.length) {
return `<p class="zt-k-hub-plan-replace__empty">${escapeHtml(emptyText)}</p>`;
}
return items
.map(
(item) =>
`<button type="button" class="zt-k-hub-plan-replace__pick" data-pick-component-id="${escapeHtml(item.id)}" ` +
`data-pick-component-name="${escapeHtml(item.name)}">` +
`<span class="zt-k-hub-plan-replace__pick-name">${escapeHtml(item.name)}</span>` +
`<span class="zt-k-hub-plan-replace__pick-meta">` +
`${escapeHtml(item.type || "—")} · СВ ${formatKg(item.dryMatter)}%</span></button>`
)
.join("");
}
async function fetchComponentAlternatives(componentId, query) {
const params = new URLSearchParams({
component_id: componentId,
q: query || "",
limit: "100",
});
const resp = await fetch(`/api/daily-plan/component-alternatives?${params.toString()}`, {
headers: { Accept: "application/json" },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.message || "Не удалось загрузить компоненты");
}
return resp.json();
}
function dismissOverlays(host, keepSelectors) {
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
if (keepSelectors.some((sel) => el.matches(sel))) return;
el.remove();
});
}
/**
* @param {object} options
* @param {string} options.componentId
* @param {string} options.label
* @param {HTMLElement} [options.overlayHost]
* @param {string[]} [options.keepOverlays] — селекторы overlay, которые не закрывать
* @param {string} [options.subtitle]
* @param {(replacementComponentId: string, replacementName: string) => void|Promise<void>} options.onPick
* @param {(message: string) => void} [options.onError]
*/
async function openReplaceComponentModal(options) {
const {
componentId,
label,
overlayHost = document.body,
keepOverlays = [],
subtitle = "",
onPick,
onError = () => {},
} = options || {};
if (!componentId || !onPick) return;
const overlay = document.createElement("div");
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-replace";
const subtitleHtml = subtitle
? `<p class="zt-k-hub-plan-replace__lead">${escapeHtml(subtitle)}</p>`
: "";
overlay.innerHTML =
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-replace__dialog" role="dialog" aria-modal="true">' +
`<h3 class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-replace__title">Заменить «${escapeHtml(label)}»</h3>` +
subtitleHtml +
'<label class="zt-k-hub-plan-replace__search-label">Поиск по всем компонентам</label>' +
'<input type="search" class="form-control zt-k-hub-plan-replace__search" data-replace-search placeholder="Начните вводить название…" autocomplete="off">' +
'<div class="zt-k-hub-plan-replace__section">' +
'<p class="zt-k-hub-plan-replace__section-title">Похожие по параметрам</p>' +
'<div data-replace-similar class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
'<div class="zt-k-hub-plan-replace__section">' +
'<p class="zt-k-hub-plan-replace__section-title">Все компоненты</p>' +
'<div data-replace-results class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-replace__actions">' +
'<button type="button" class="btn btn-secondary" data-replace-cancel>Отмена</button></div></div>';
dismissOverlays(overlayHost, keepOverlays);
overlay.setAttribute("data-daily-plan-overlay", "1");
overlayHost.appendChild(overlay);
const similarEl = overlay.querySelector("[data-replace-similar]");
const resultsEl = overlay.querySelector("[data-replace-results]");
const searchEl = overlay.querySelector("[data-replace-search]");
let catalog = null;
const close = () => {
if (overlay.isConnected) overlay.remove();
};
function filterCatalog(query) {
const q = query.trim().toLowerCase();
if (!q) {
return {
similar: catalog?.similar || [],
items: catalog?.results || [],
q: "",
};
}
const items = (catalog?.results || []).filter((item) =>
String(item.name || "").toLowerCase().includes(q)
);
const similar = items.filter((item) => item.similar);
return { similar, items, q };
}
function paintLists(query = "") {
if (!overlay.isConnected) return;
const { similar, items, q } = filterCatalog(query);
if (similarEl) {
similarEl.innerHTML = renderComponentPickList(
similar,
q ? "Нет похожих по запросу" : "Нет похожих компонентов"
);
}
if (resultsEl) {
resultsEl.innerHTML = renderComponentPickList(
items,
q ? "Ничего не найдено" : "Нет доступных компонентов"
);
}
}
async function loadCatalog() {
try {
catalog = await fetchComponentAlternatives(componentId, "");
paintLists(searchEl?.value.trim() || "");
} catch (err) {
if (!overlay.isConnected) return;
onError(err.message || "Не удалось загрузить компоненты");
}
}
overlay.querySelector("[data-replace-cancel]")?.addEventListener("click", close);
overlay.addEventListener("click", (event) => {
if (event.target === overlay) close();
});
searchEl?.addEventListener("input", () => paintLists(searchEl.value.trim()));
overlay.addEventListener("click", async (event) => {
const pick = event.target.closest("[data-pick-component-id]");
if (!pick) return;
event.preventDefault();
const replacementComponentId = pick.dataset.pickComponentId;
const replacementName = pick.dataset.pickComponentName || "Компонент";
if (!replacementComponentId) return;
close();
try {
await onPick(replacementComponentId, replacementName);
} catch (err) {
onError(err.message || "Не удалось заменить компонент");
}
});
await loadCatalog();
searchEl?.focus();
}
global.WespDailyPlanReplaceModal = {
open: openReplaceComponentModal,
fetchComponentAlternatives,
};
})(window);
@@ -0,0 +1,124 @@
/**
* Диалог «На какой срок…» для skip/replace/adjustment плана на день.
*/
(function (global) {
function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function weekEndIso(planDate) {
const parts = String(planDate || "").split("-");
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
const jsDay = d.getDay();
const pyWeekday = jsDay === 0 ? 6 : jsDay - 1;
d.setDate(d.getDate() + (6 - pyWeekday));
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 todayIso() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function dismissOverlays(host, keepSelectors) {
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
if (keepSelectors.some((sel) => el.matches(sel))) return;
el.remove();
});
}
/**
* @param {object} options
* @param {string} options.planDate YYYY-MM-DD
* @param {string} [options.title]
* @param {HTMLElement} [options.overlayHost] куда монтировать overlay
* @param {string[]} [options.keepOverlays] селекторы overlay, которые не закрывать
* @param {function} [options.onError]
* @param {function} [options.beforeAppend] (overlay) => void — dismiss other overlays
*/
function promptSkipDuration(options) {
const planDate = options.planDate || todayIso();
const title = options.title || "На какой срок исключить?";
const onError = options.onError || function () {};
const host = options.overlayHost || document.body;
const keepOverlays = options.keepOverlays || [];
return new Promise((resolve) => {
const weekEnd = weekEndIso(planDate);
const overlay = document.createElement("div");
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-skip-duration";
overlay.innerHTML =
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-skip-duration__dialog" role="dialog" aria-modal="true">' +
`<p class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-skip-duration__title">${escapeHtml(title)}</p>` +
'<div class="zt-k-hub-plan-skip-duration__options">' +
'<label class="zt-k-hub-plan-skip-duration__option">' +
'<input type="radio" name="skip-duration" value="today" checked> Только сегодня</label>' +
'<label class="zt-k-hub-plan-skip-duration__option">' +
`<input type="radio" name="skip-duration" value="week"> До конца недели (до ${escapeHtml(weekEnd)})</label>` +
'<label class="zt-k-hub-plan-skip-duration__option zt-k-hub-plan-skip-duration__option--date">' +
'<input type="radio" name="skip-duration" value="date"> До даты ' +
`<input type="date" class="form-control" data-skip-until-date min="${escapeHtml(planDate)}" value="${escapeHtml(planDate)}">` +
"</label></div>" +
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-skip-duration__actions">' +
'<button type="button" class="btn btn-secondary" data-skip-duration-cancel>Отмена</button>' +
'<button type="button" class="btn btn-primary" data-skip-duration-confirm>Применить</button>' +
"</div></div>";
if (typeof options.beforeAppend === "function") {
options.beforeAppend(overlay);
} else if (keepOverlays.length) {
dismissOverlays(host, keepOverlays);
}
overlay.setAttribute("data-daily-plan-overlay", "1");
host.appendChild(overlay);
const dateInput = overlay.querySelector("[data-skip-until-date]");
const finish = (value) => {
if (overlay.isConnected) overlay.remove();
resolve(value);
};
overlay.addEventListener("click", (event) => {
if (event.target === overlay) finish(null);
});
overlay.querySelector("[data-skip-duration-cancel]")?.addEventListener("click", () => finish(null));
overlay.querySelector("[data-skip-duration-confirm]")?.addEventListener("click", () => {
const selected = overlay.querySelector('input[name="skip-duration"]:checked');
const mode = selected?.value || "today";
if (mode === "week") {
finish({ duration: "week" });
return;
}
if (mode === "date") {
const untilDate = dateInput?.value || planDate;
if (untilDate < planDate) {
onError("Дата окончания не может быть раньше начала");
return;
}
finish({ duration: "date", untilDate });
return;
}
finish({ duration: "today" });
});
});
}
function skipDurationBody(planDate, extra, title, options) {
return promptSkipDuration({ planDate, title, ...options }).then((choice) => {
if (!choice) return null;
return { date: planDate, ...choice, ...(extra || {}) };
});
}
global.WespDailyPlanSkipDuration = {
weekEndIso,
todayIso,
promptSkipDuration,
skipDurationBody,
escapeHtml,
};
})(window);
@@ -0,0 +1,209 @@
const KIOSK_SKELETON_MIN_MS = 300;
const KIOSK_SKELETON_EXIT_MS = 160;
const GRID_CARD_INNER = `
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-title"></div>
<div class="kiosk-skeleton-info">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--kiosk-meta"></div>
</div>`;
const GROUP_ITEM_INNER = `
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--meta"></div>`;
export function buildGridSkeletonCards(rowCount = 4) {
return Array.from({ length: rowCount }, () =>
`<div class="dispenser-card kiosk-skeleton-card" aria-hidden="true">${GRID_CARD_INNER}</div>`
).join("");
}
export function buildDispenserGridSkeletonHtml(rowCount = 4) {
return buildGridSkeletonCards(rowCount);
}
export function buildPeriodRecipeGridSkeletonHtml(rowCount = 4) {
return buildGridSkeletonCards(rowCount);
}
export function buildUnloadingGroupsSkeletonHtml(rowCount = 3) {
const items = Array.from({ length: rowCount }, () =>
`<div class="kiosk-skeleton-group-item">${GROUP_ITEM_INNER}</div>`
).join("");
return `<div class="kiosk-skeleton-groups" data-skeleton-variant="groups" aria-busy="true" aria-label="Загрузка групп выгрузки">${items}</div>`;
}
export function buildDuplicatePanelSkeletonHtml() {
return `<div class="kiosk-skeleton-panel" data-skeleton-variant="duplicate" aria-busy="true" aria-label="Загрузка данных">
<div class="kiosk-skeleton-recipe-header">
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--title"></div>
<div class="dashboard-skeleton-bar dashboard-skeleton-bar--subtitle"></div>
</div>
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
</div>`;
}
export function buildRecipeIngredientsSkeletonHtml(rowCount = 6) {
const rows = Array.from({ length: rowCount }, () =>
`<div class="kiosk-skeleton-table-row">
<div class="dashboard-skeleton-bar"></div>
<div class="dashboard-skeleton-bar"></div>
</div>`
).join("");
return `<div class="kiosk-skeleton-table" data-skeleton-variant="ingredients" aria-busy="true" aria-label="Загрузка ингредиентов">${rows}</div>`;
}
function prefersReducedMotion() {
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
}
function isGridSkeletonHost(container) {
if (!container) return false;
return (
container.getAttribute("aria-busy") === "true" &&
container.querySelector(":scope > .kiosk-skeleton-card") != null
);
}
function findSkeletonRoot(container) {
if (!container) return null;
if (isGridSkeletonHost(container)) return container;
return container.querySelector(
":scope > .kiosk-skeleton-groups, :scope > .kiosk-skeleton-table, :scope > .kiosk-skeleton-panel"
);
}
export function mountKioskSkeleton(container, html, label) {
if (!container) return performance.now();
container.innerHTML = html;
container.classList.remove("kiosk-skeleton-host--exit");
if (container.querySelector(":scope > .kiosk-skeleton-card")) {
container.setAttribute("aria-busy", "true");
if (label) container.setAttribute("aria-label", label);
}
return performance.now();
}
export async function awaitKioskSkeletonMin(startedAt, minMs = KIOSK_SKELETON_MIN_MS) {
const elapsed = performance.now() - startedAt;
const rest = minMs - elapsed;
if (rest > 0) {
await new Promise((resolve) => setTimeout(resolve, rest));
}
}
async function fadeOutSkeleton(skel) {
if (!skel || prefersReducedMotion()) return;
const exitClass = isGridSkeletonHost(skel)
? "kiosk-skeleton-host--exit"
: skel.classList.contains("kiosk-skeleton-groups")
? "kiosk-skeleton-groups--exit"
: skel.classList.contains("kiosk-skeleton-table")
? "kiosk-skeleton-table--exit"
: "kiosk-skeleton-panel--exit";
skel.classList.add(exitClass);
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
skel.removeEventListener("transitionend", onEnd);
resolve();
};
const onEnd = (event) => {
if (event.target === skel && event.propertyName === "opacity") finish();
};
skel.addEventListener("transitionend", onEnd);
globalThis.setTimeout(finish, KIOSK_SKELETON_EXIT_MS + 40);
});
}
function clearSkeletonHostAttrs(container) {
if (!container) return;
container.classList.remove("kiosk-skeleton-host--exit");
container.removeAttribute("aria-busy");
container.removeAttribute("aria-label");
}
export async function replaceKioskContent(container, html, options = {}) {
if (!container) return;
const { minMs = KIOSK_SKELETON_MIN_MS, startedAt = performance.now() } = options;
const skel = findSkeletonRoot(container);
if (skel) {
await awaitKioskSkeletonMin(startedAt, minMs);
await fadeOutSkeleton(skel);
} else {
await awaitKioskSkeletonMin(startedAt, minMs);
}
container.innerHTML = html;
clearSkeletonHostAttrs(container);
}
export function clearStaleKioskSkeleton(container) {
if (!container) return;
if (findSkeletonRoot(container)) {
container.innerHTML = "";
clearSkeletonHostAttrs(container);
}
}
export function mountDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
const html = buildDuplicatePanelSkeletonHtml();
const startedAt = performance.now();
if (recipeInfoEl) {
recipeInfoEl.style.display = "block";
recipeInfoEl.innerHTML = html;
}
if (weightSectionEl) {
weightSectionEl.classList.add("kiosk-skeleton-host");
const host = weightSectionEl.querySelector(".weight-content");
if (host) {
host.dataset.kioskSkeletonBackup = host.innerHTML;
host.innerHTML = `<div class="kiosk-skeleton-panel" aria-busy="true" aria-hidden="true">
<div class="dashboard-skeleton-bar kiosk-skeleton-component-name"></div>
<div class="dashboard-skeleton-bar kiosk-skeleton-weight-hero"></div>
</div>`;
}
}
return startedAt;
}
export function clearDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) {
if (recipeInfoEl) {
const skel = recipeInfoEl.querySelector(".kiosk-skeleton-panel");
if (skel) {
recipeInfoEl.style.display = "none";
recipeInfoEl.innerHTML = `
<h3>Рецепт: <span id="recipeName"></span></h3>
<p>Компонент <span id="currentIndex">0</span> из <span id="totalComponents">0</span></p>`;
}
}
if (weightSectionEl) {
weightSectionEl.classList.remove("kiosk-skeleton-host");
const host = weightSectionEl.querySelector(".weight-content");
if (host?.dataset.kioskSkeletonBackup) {
host.innerHTML = host.dataset.kioskSkeletonBackup;
delete host.dataset.kioskSkeletonBackup;
}
}
}
const api = {
buildGridSkeletonCards,
buildDispenserGridSkeletonHtml,
buildPeriodRecipeGridSkeletonHtml,
buildUnloadingGroupsSkeletonHtml,
buildDuplicatePanelSkeletonHtml,
buildRecipeIngredientsSkeletonHtml,
mountKioskSkeleton,
replaceKioskContent,
awaitKioskSkeletonMin,
clearStaleKioskSkeleton,
mountDuplicatePanelSkeleton,
clearDuplicatePanelSkeleton,
};
if (typeof globalThis !== "undefined") {
globalThis.WespKioskSkeleton = api;
}
@@ -0,0 +1,78 @@
export class DispenserSelector {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
const recipeId = actionEl.dataset.recipeId;
const periodId = actionEl.dataset.periodId;
const dispenserId = actionEl.dataset.dispenserId;
const index = Number(actionEl.dataset.index);
switch (action) {
case "select-dispenser":
if (dispenserId) await this.handlers.selectDispenser(dispenserId);
return true;
case "select-recipe":
if (recipeId) await this.handlers.selectRecipe(recipeId);
return true;
case "select-period":
if (periodId) await this.handlers.selectPeriod(periodId);
return true;
case "copy-recipe":
event.stopPropagation();
if (recipeId) await this.handlers.copyRecipe(recipeId);
return true;
case "delete-recipe":
event.stopPropagation();
if (recipeId) await this.handlers.deleteRecipe(recipeId);
return true;
case "paste-recipe":
event.stopPropagation();
if (periodId) await this.handlers.pasteRecipe(periodId);
return true;
case "create-recipe":
event.stopPropagation();
if (periodId) await this.handlers.createNewRecipe(periodId);
return true;
case "move-recipe-up":
event.stopPropagation();
if (recipeId && !Number.isNaN(index)) {
await this.handlers.moveRecipeUp(recipeId, index);
}
return true;
case "move-recipe-down":
event.stopPropagation();
if (recipeId && !Number.isNaN(index)) {
await this.handlers.moveRecipeDown(recipeId, index);
}
return true;
case "unskip-recipe-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipRecipeToday === "function") {
await this.handlers.unskipRecipeToday(recipeId);
}
return true;
case "unskip-ingredient-parts-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipIngredientPartsToday === "function") {
await this.handlers.unskipIngredientPartsToday(recipeId);
}
return true;
case "unskip-group-parts-today":
event.stopPropagation();
event.preventDefault();
if (recipeId && typeof this.handlers.unskipGroupPartsToday === "function") {
await this.handlers.unskipGroupPartsToday(recipeId);
}
return true;
case "recipe-drag-handle":
event.stopPropagation();
return true;
default:
return false;
}
}
}
@@ -0,0 +1,95 @@
export class RecipeEditor {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
switch (action) {
case "add-ingredient":
this.handlers.addIngredient();
return true;
case "toggle-unloading-link":
this.handlers.toggleUnloadingLink();
return true;
case "add-unloading-group":
this.handlers.addUnloadingGroup();
return true;
case "show-recipe-edit-off":
this.handlers.showRecipeEdit(false);
return true;
case "print-all-recipes":
this.handlers.printAllRecipesInPeriod();
return true;
case "print-recipe":
this.handlers.printRecipe();
return true;
case "save-and-exit":
await this.handlers.saveRecipe(null, true);
return true;
case "move-group-up":
this.handlers.moveGroupUp(actionEl);
return true;
case "move-group-down":
this.handlers.moveGroupDown(actionEl);
return true;
case "remove-unloading-group":
this.handlers.removeUnloadingGroup(actionEl);
return true;
case "move-ingredient-up":
this.handlers.moveIngredientUp(actionEl);
return true;
case "move-ingredient-down":
this.handlers.moveIngredientDown(actionEl);
return true;
case "remove-ingredient":
this.handlers.removeIngredient(actionEl);
return true;
case "open-ingredient-sheet": {
const row = actionEl.closest(".ingredient-row");
this.handlers.openIngredientMobileSheet(row);
return true;
}
case "close-ingredient-sheet":
this.handlers.closeIngredientMobileSheet();
return true;
case "remove-ingredient-sheet":
this.handlers.removeIngredientFromSheet();
return true;
case "open-unloading-group-sheet": {
const row = actionEl.closest(".unloading-group-row");
this.handlers.openUnloadingGroupMobileSheet(row);
return true;
}
case "close-unloading-group-sheet":
this.handlers.closeUnloadingGroupMobileSheet();
return true;
default:
return false;
}
}
handleChange(action, actionEl) {
switch (action) {
case "group-type-change":
this.handlers.handleGroupTypeChange(actionEl);
return true;
case "group-value-change":
this.handlers.validateGroupValue(actionEl);
return true;
case "ingredient-change":
this.handlers.handleIngredientChange(actionEl);
return true;
case "dry-matter-percent-change":
this.handlers.handleDryMatterPercentChange(actionEl);
return true;
case "recalculate-weights":
this.handlers.recalculateWeights();
return true;
case "recalculate-total-weight":
this.handlers.recalculateFromTotalWeight(actionEl);
return true;
default:
return false;
}
}
}
@@ -0,0 +1,875 @@
/**
* Drag-and-drop reorder for period recipe cards (#recipesList).
* Drag starts from .recipe-drag-handle; uses the same API as arrow buttons.
* Desktop: HTML5 DnD. Mobile: pointer reorder with card clone + insertion line.
*/
import { cloneTemplateInto } from "./recipe-skeleton.js";
const MOBILE_REORDER_MQ = "(max-width: 768px)";
const POINTER_ACTIVATION_PX = { mobile: 10, desktop: 10 };
const AUTO_SCROLL_EDGE_PX = 72;
const AUTO_SCROLL_MAX_STEP = 28;
const REORDER_TRACKING_BODY_CLASS = "recipe-list-reorder-tracking";
function isMobileReorderUi() {
return globalThis.matchMedia?.(MOBILE_REORDER_MQ)?.matches ?? false;
}
function activationThresholdPx() {
return isMobileReorderUi() ? POINTER_ACTIVATION_PX.mobile : POINTER_ACTIVATION_PX.desktop;
}
function transferBlocksReorder() {
const listRoot = document.getElementById("recipesList");
if (
listRoot?.classList.contains("recipe-list-reorder-active") ||
document.body.classList.contains("recipe-list-reorder-active")
) {
return false;
}
return (
document.body.classList.contains("recipe-mobile-transfer-armed") ||
document.body.classList.contains("recipe-period-transfer-active")
);
}
function removeRecipeDragGhost() {
const existing = document.getElementById("recipe-card-drag-ghost-root");
if (existing?.parentNode) {
existing.parentNode.removeChild(existing);
}
}
function removeInsertIndicator(listRoot) {
listRoot?.querySelector(":scope > .recipe-card-insert-indicator")?.remove();
}
function mountRecipeCardSkeletonInner(container) {
if (!cloneTemplateInto(container, "recipe-card-skeleton-inner")) {
container.innerHTML =
'<div class="dashboard-skeleton-card-inner"><div class="dashboard-skeleton-bar"></div></div>';
}
}
function getRecipeListItems(listRoot) {
return [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")];
}
function resolveRawInsertIndexAtY(listRoot, clientY) {
const items = getRecipeListItems(listRoot);
for (let i = 0; i < items.length; i++) {
const rect = items[i].getBoundingClientRect();
if (clientY < rect.top + rect.height / 2) {
return i;
}
}
return items.length;
}
function resolveInsertIndexAtY(listRoot, clientY, draggingItem) {
const items = getRecipeListItems(listRoot);
if (!draggingItem) {
return Math.max(0, Math.min(resolveRawInsertIndexAtY(listRoot, clientY), items.length));
}
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0) return 0;
let toIndex = items.length;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item === draggingItem) continue;
const rect = item.getBoundingClientRect();
const inCard = clientY >= rect.top && clientY <= rect.bottom;
if (inCard && fromIndex > i) {
toIndex = i;
break;
}
if (inCard && fromIndex < i) {
toIndex = clientY < rect.top + rect.height / 2 ? i : i + 1;
break;
}
if (clientY < rect.top + rect.height / 2) {
toIndex = i;
break;
}
toIndex = i + 1;
}
if (fromIndex < toIndex) {
toIndex -= 1;
}
return Math.max(0, Math.min(toIndex, Math.max(0, items.length - 1)));
}
function resolveReorderDropIndex(listRoot, dragEvent, draggingItem) {
const items = getRecipeListItems(listRoot);
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0) return null;
const toIndex = resolveInsertIndexAtY(listRoot, dragEvent.clientY, draggingItem);
if (fromIndex === toIndex) return null;
return { fromIndex, toIndex };
}
function resolveTrackedReorderDropIndex(listRoot, draggingItem, dropToIndex) {
const items = getRecipeListItems(listRoot);
const fromIndex = items.indexOf(draggingItem);
if (fromIndex < 0 || dropToIndex == null) return null;
const toIndex = Math.max(0, Math.min(dropToIndex, Math.max(0, items.length - 1)));
if (fromIndex === toIndex) return null;
return { fromIndex, toIndex };
}
function mountRecipeCardDragGhost(item, dragEvent) {
removeRecipeDragGhost();
const ghost = document.createElement("div");
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "recipe-card-drag-ghost";
ghost.setAttribute("aria-hidden", "true");
mountRecipeCardSkeletonInner(ghost);
const w = item.offsetWidth;
ghost.style.cssText = [
"position:fixed",
"left:-10000px",
"top:0",
"width:" + Math.round(Math.min(Math.max(w, 200), 640)) + "px",
"pointer-events:none",
].join(";");
document.body.appendChild(ghost);
void ghost.offsetWidth;
const rect = item.getBoundingClientRect();
const ox = Math.max(0, Math.round(dragEvent.clientX - rect.left));
const oy = Math.max(0, Math.round(dragEvent.clientY - rect.top));
try {
dragEvent.dataTransfer.setDragImage(ghost, ox, oy);
} catch (_) {
/* старые движки без кастомного изображения */
}
}
function sanitizeDragGhostClone(node) {
node.querySelectorAll("[data-action]").forEach((el) => el.removeAttribute("data-action"));
node.querySelectorAll("button").forEach((btn) => {
btn.disabled = true;
btn.tabIndex = -1;
});
node.classList.remove("active", "recipe-card-drop-target");
}
function mountPointerFollowGhost(item, clientX, clientY, grabOffsetX, grabOffsetY) {
removeRecipeDragGhost();
if (isMobileReorderUi()) {
const ghost = item.cloneNode(true);
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "list-item recipe-card-drag-ghost recipe-card-drag-ghost--card-clone";
ghost.setAttribute("aria-hidden", "true");
sanitizeDragGhostClone(ghost);
const width = Math.round(Math.min(Math.max(item.offsetWidth, 200), window.innerWidth - 24));
ghost.style.cssText = [
"position:fixed",
"left:0",
"top:0",
"width:" + width + "px",
"margin:0",
"transition:none",
"transform:none",
"z-index:1300",
"pointer-events:none",
].join(";");
document.body.appendChild(ghost);
positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX, grabOffsetY);
return ghost;
}
const ghost = document.createElement("div");
ghost.id = "recipe-card-drag-ghost-root";
ghost.className = "recipe-card-drag-ghost recipe-card-drag-ghost--follow";
ghost.setAttribute("aria-hidden", "true");
mountRecipeCardSkeletonInner(ghost);
ghost.style.width = `${Math.round(Math.min(Math.max(item.offsetWidth, 200), 640))}px`;
document.body.appendChild(ghost);
positionPointerFollowGhost(ghost, clientX, clientY);
return ghost;
}
function positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX = null, grabOffsetY = null) {
if (!ghost) return;
if (grabOffsetX != null && grabOffsetY != null) {
ghost.style.left = `${clientX - grabOffsetX}px`;
ghost.style.top = `${clientY - grabOffsetY}px`;
ghost.style.transform = "none";
return;
}
ghost.style.left = `${clientX}px`;
ghost.style.top = `${clientY}px`;
ghost.style.transform = "translate(-50%, -50%)";
}
function slotPickActive() {
return Boolean(document.querySelector(".recipe-transfer-slot"));
}
function clearDropTargets(listRoot) {
listRoot.querySelectorAll(".list-item.recipe-card-drop-target").forEach((el) => {
el.classList.remove("recipe-card-drop-target");
});
removeInsertIndicator(listRoot);
}
function updateInsertIndicator(listRoot, draggingItem, clientY) {
if (!isMobileReorderUi()) return;
const items = getRecipeListItems(listRoot);
if (!items.length) return;
let line = listRoot.querySelector(":scope > .recipe-card-insert-indicator");
if (!line) {
line = document.createElement("div");
line.className = "recipe-card-insert-indicator";
line.setAttribute("aria-hidden", "true");
listRoot.appendChild(line);
}
const insertIndex = resolveRawInsertIndexAtY(listRoot, clientY);
let yInList;
if (insertIndex >= items.length) {
const last = items[items.length - 1];
yInList = last.offsetTop + last.offsetHeight + 6;
} else {
yInList = items[insertIndex].offsetTop - 4;
}
line.style.top = `${yInList}px`;
line.hidden = false;
}
function highlightDropTarget(listRoot, draggingItem, clientY) {
clearDropTargets(listRoot);
if (isMobileReorderUi()) {
updateInsertIndicator(listRoot, draggingItem, clientY);
return;
}
const items = getRecipeListItems(listRoot);
for (const item of items) {
if (item === draggingItem) continue;
const rect = item.getBoundingClientRect();
if (clientY >= rect.top && clientY <= rect.bottom) {
item.classList.add("recipe-card-drop-target");
break;
}
}
}
function configureMobileDragHandles(listRoot) {
if (!isMobileReorderUi()) return;
listRoot.querySelectorAll(".recipe-drag-handle").forEach((handle) => {
handle.setAttribute("draggable", "false");
});
}
function listCanScroll(listRoot) {
return listRoot.scrollHeight > listRoot.clientHeight + 2;
}
function getPageScrollElement() {
return document.scrollingElement || document.documentElement;
}
function pageCanScrollUp() {
return getPageScrollElement().scrollTop > 0;
}
function pageCanScrollDown() {
const scrollEl = getPageScrollElement();
return scrollEl.scrollTop + window.innerHeight < scrollEl.scrollHeight - 2;
}
function ensureMobileEdgeScrollHints() {
if (!isMobileReorderUi() || document.getElementById("recipe-reorder-edge-hints")) return;
const wrap = document.createElement("div");
wrap.id = "recipe-reorder-edge-hints";
wrap.className = "recipe-reorder-edge-hints";
wrap.setAttribute("aria-hidden", "true");
wrap.innerHTML =
'<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--top">' +
'<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-up" aria-hidden="true"></i><span>К краю — вверх</span></span>' +
"</div>" +
'<div class="recipe-reorder-edge-hint recipe-reorder-edge-hint--bottom">' +
'<span class="recipe-list-scroll-zone-label"><i class="fas fa-chevron-down" aria-hidden="true"></i><span>К краю — вниз</span></span>' +
"</div>";
document.body.appendChild(wrap);
}
function updateMobileEdgeScrollHints(clientY) {
if (!isMobileReorderUi()) return;
const wrap = document.getElementById("recipe-reorder-edge-hints");
if (!wrap) return;
const vh = window.innerHeight;
wrap.querySelector(".recipe-reorder-edge-hint--top")?.classList.toggle(
"is-active",
clientY < AUTO_SCROLL_EDGE_PX && pageCanScrollUp()
);
wrap.querySelector(".recipe-reorder-edge-hint--bottom")?.classList.toggle(
"is-active",
clientY > vh - AUTO_SCROLL_EDGE_PX && pageCanScrollDown()
);
}
function removeReorderUiChrome() {
document.body.classList.remove(REORDER_TRACKING_BODY_CLASS);
document.getElementById("recipe-reorder-edge-hints")?.remove();
document.getElementById("recipe-reorder-floating-hint")?.remove();
}
function showRecipeReorderHint() {
if (!isMobileReorderUi() || document.getElementById("recipe-reorder-floating-hint")) return;
const hint = document.createElement("div");
hint.id = "recipe-reorder-floating-hint";
hint.className = "recipe-reorder-hint recipe-reorder-hint--floating";
hint.setAttribute("role", "status");
hint.textContent = "Перетащите вверх или вниз. К краю экрана — прокрутка списка.";
document.body.appendChild(hint);
requestAnimationFrame(() => hint.classList.add("is-visible"));
globalThis.setTimeout(() => {
hint.classList.remove("is-visible");
globalThis.setTimeout(() => hint.remove(), 320);
}, 3200);
}
function autoScrollWindowAtEdge(clientY) {
const vh = window.innerHeight;
const scrollEl = getPageScrollElement();
let delta = 0;
if (clientY < AUTO_SCROLL_EDGE_PX) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(clientY, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = -Math.max(8, speed);
} else if (clientY > vh - AUTO_SCROLL_EDGE_PX) {
const distBottom = vh - clientY;
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = Math.max(8, speed);
}
if (delta === 0) return false;
const prev = scrollEl.scrollTop;
scrollEl.scrollTop += delta;
return scrollEl.scrollTop !== prev;
}
function autoScrollRecipeList(listRoot, clientY) {
if (!listCanScroll(listRoot)) return false;
const rect = listRoot.getBoundingClientRect();
const distTop = clientY - rect.top;
const distBottom = rect.bottom - clientY;
let delta = 0;
if (distTop < AUTO_SCROLL_EDGE_PX || clientY < rect.top) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distTop, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = -Math.max(8, speed);
} else if (distBottom < AUTO_SCROLL_EDGE_PX || clientY > rect.bottom) {
const speed = Math.min(
AUTO_SCROLL_MAX_STEP,
Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP)
);
delta = Math.max(8, speed);
}
if (delta === 0) return false;
listRoot.scrollTop += delta;
return true;
}
function autoScrollDuringReorder(listRoot, clientY) {
if (isMobileReorderUi()) {
return autoScrollWindowAtEdge(clientY);
}
return autoScrollRecipeList(listRoot, clientY);
}
function findTouchById(touchList, touchId) {
for (let i = 0; i < touchList.length; i++) {
if (touchList[i].identifier === touchId) {
return touchList[i];
}
}
return null;
}
function attachMobileTouchReorder(listRoot, { moveRecipe, notyf }) {
let touchDrag = null;
let dropPending = false;
let autoScrollRaf = null;
let touchMoveListener = null;
let touchEndListener = null;
const detachTouchListeners = () => {
if (touchMoveListener) {
document.removeEventListener("touchmove", touchMoveListener, true);
touchMoveListener = null;
}
if (touchEndListener) {
document.removeEventListener("touchend", touchEndListener, true);
document.removeEventListener("touchcancel", touchEndListener, true);
touchEndListener = null;
}
};
const stopAutoScroll = () => {
if (autoScrollRaf != null) {
cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
};
const startAutoScroll = () => {
stopAutoScroll();
const tick = () => {
if (!touchDrag?.active) {
stopAutoScroll();
return;
}
const y = touchDrag.lastY;
autoScrollDuringReorder(listRoot, y);
updateMobileEdgeScrollHints(y);
highlightDropTarget(listRoot, touchDrag.item, y);
touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, touchDrag.item);
autoScrollRaf = requestAnimationFrame(tick);
};
autoScrollRaf = requestAnimationFrame(tick);
};
const finishTouchDrag = () => {
detachTouchListeners();
stopAutoScroll();
if (!touchDrag) return;
touchDrag.item.classList.remove("recipe-card-dragging");
listRoot.classList.remove("recipe-list-reorder-active");
removeReorderUiChrome();
touchDrag = null;
clearDropTargets(listRoot);
removeRecipeDragGhost();
};
const activateTouchDrag = (touch) => {
if (!touchDrag || touchDrag.active) return;
touchDrag.active = true;
touchDrag.item.classList.add("recipe-card-dragging");
listRoot.classList.add("recipe-list-reorder-active");
document.body.classList.add(REORDER_TRACKING_BODY_CLASS);
listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true }));
ensureMobileEdgeScrollHints();
showRecipeReorderHint();
touchDrag.ghost = mountPointerFollowGhost(
touchDrag.item,
touch.clientX,
touch.clientY,
touchDrag.grabOffsetX,
touchDrag.grabOffsetY
);
startAutoScroll();
globalThis.navigator?.vibrate?.(8);
};
const handleTouchMove = (e) => {
if (!touchDrag) return;
const touch = findTouchById(e.touches, touchDrag.touchId);
if (!touch) return;
e.preventDefault();
if (transferBlocksReorder()) {
finishTouchDrag();
return;
}
touchDrag.lastY = touch.clientY;
const dx = Math.abs(touch.clientX - touchDrag.startX);
const dy = Math.abs(touch.clientY - touchDrag.startY);
if (!touchDrag.active && dx + dy < activationThresholdPx()) return;
if (!touchDrag.active) {
activateTouchDrag(touch);
}
positionPointerFollowGhost(
touchDrag.ghost,
touch.clientX,
touch.clientY,
touchDrag.grabOffsetX,
touchDrag.grabOffsetY
);
updateMobileEdgeScrollHints(touch.clientY);
highlightDropTarget(listRoot, touchDrag.item, touch.clientY);
touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, touch.clientY, touchDrag.item);
};
const handleTouchEnd = (e) => {
if (!touchDrag) return;
const touch = findTouchById(e.changedTouches, touchDrag.touchId);
if (!touch) return;
const { item, active, dropToIndex } = touchDrag;
finishTouchDrag();
if (!active || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
const indices =
resolveReorderDropIndex(listRoot, touch, item) ||
resolveTrackedReorderDropIndex(listRoot, item, dropToIndex);
if (!indices) return;
void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex);
};
listRoot.addEventListener("recipe-transfer-armed", () => {
if (touchDrag?.active) {
finishTouchDrag();
} else if (touchDrag) {
detachTouchListeners();
removeReorderUiChrome();
touchDrag = null;
}
});
if (typeof MutationObserver !== "undefined") {
const handleObserver = new MutationObserver(() => configureMobileDragHandles(listRoot));
handleObserver.observe(listRoot, { childList: true });
configureMobileDragHandles(listRoot);
}
listRoot.addEventListener(
"touchstart",
(e) => {
if (e.touches.length !== 1) return;
if (transferBlocksReorder()) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
const touch = e.touches[0];
const handleRect = handle.getBoundingClientRect();
touchDrag = {
item,
touchId: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
lastY: touch.clientY,
grabOffsetX: touch.clientX - handleRect.left,
grabOffsetY: touch.clientY - handleRect.top,
active: false,
ghost: null,
dropToIndex: null,
};
e.stopPropagation();
touchMoveListener = handleTouchMove;
touchEndListener = handleTouchEnd;
document.addEventListener("touchmove", touchMoveListener, { capture: true, passive: false });
document.addEventListener("touchend", touchEndListener, { capture: true });
document.addEventListener("touchcancel", touchEndListener, { capture: true });
},
{ capture: true, passive: true }
);
}
function attachPointerReorder(listRoot, { moveRecipe, notyf }) {
if (isMobileReorderUi()) {
attachMobileTouchReorder(listRoot, { moveRecipe, notyf });
return;
}
let pointerDrag = null;
let dropPending = false;
let autoScrollRaf = null;
let documentDragMove = null;
let documentDragEnd = null;
const detachDocumentDragListeners = () => {
if (documentDragMove) {
document.removeEventListener("pointermove", documentDragMove, true);
documentDragMove = null;
}
if (documentDragEnd) {
document.removeEventListener("pointerup", documentDragEnd, true);
document.removeEventListener("pointercancel", documentDragEnd, true);
documentDragEnd = null;
}
};
const stopAutoScroll = () => {
if (autoScrollRaf != null) {
cancelAnimationFrame(autoScrollRaf);
autoScrollRaf = null;
}
};
const startAutoScroll = () => {
stopAutoScroll();
const tick = () => {
if (!pointerDrag?.active) {
stopAutoScroll();
return;
}
const y = pointerDrag.lastY;
autoScrollDuringReorder(listRoot, y);
highlightDropTarget(listRoot, pointerDrag.item, y);
pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, pointerDrag.item);
autoScrollRaf = requestAnimationFrame(tick);
};
autoScrollRaf = requestAnimationFrame(tick);
};
const finishPointerDrag = () => {
detachDocumentDragListeners();
stopAutoScroll();
if (!pointerDrag) return;
pointerDrag.item.classList.remove("recipe-card-dragging");
listRoot.classList.remove("recipe-list-reorder-active");
removeReorderUiChrome();
pointerDrag = null;
clearDropTargets(listRoot);
removeRecipeDragGhost();
};
const isOverTransferHoverTarget = (clientX, clientY) => {
const el = document.elementFromPoint(clientX, clientY);
return !!(
el?.closest("#periodsList .list-item[data-period-id]") ||
el?.closest("#dispensersList .list-item[data-dispenser-id]")
);
};
const handlePointerMove = (e) => {
if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
if (transferBlocksReorder()) {
finishPointerDrag();
return;
}
if (!pointerDrag.active && isOverTransferHoverTarget(e.clientX, e.clientY)) {
pointerDrag.lastY = e.clientY;
return;
}
e.preventDefault();
pointerDrag.lastY = e.clientY;
const dx = Math.abs(e.clientX - pointerDrag.startX);
const dy = Math.abs(e.clientY - pointerDrag.startY);
if (!pointerDrag.active && dx + dy < activationThresholdPx()) return;
if (!pointerDrag.active) {
pointerDrag.active = true;
pointerDrag.item.classList.add("recipe-card-dragging");
listRoot.classList.add("recipe-list-reorder-active");
listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true }));
pointerDrag.ghost = mountPointerFollowGhost(
pointerDrag.item,
e.clientX,
e.clientY,
pointerDrag.grabOffsetX,
pointerDrag.grabOffsetY
);
startAutoScroll();
}
positionPointerFollowGhost(
pointerDrag.ghost,
e.clientX,
e.clientY,
pointerDrag.grabOffsetX,
pointerDrag.grabOffsetY
);
updateMobileEdgeScrollHints(e.clientY);
highlightDropTarget(listRoot, pointerDrag.item, e.clientY);
pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, e.clientY, pointerDrag.item);
};
const onPointerEnd = (e) => {
if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return;
const { item, active, dropToIndex } = pointerDrag;
finishPointerDrag();
if (!active || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
const indices =
resolveReorderDropIndex(listRoot, e, item) ||
resolveTrackedReorderDropIndex(listRoot, item, dropToIndex);
if (!indices) return;
void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex);
};
const attachDocumentDragListeners = () => {
if (documentDragMove) return;
documentDragMove = handlePointerMove;
documentDragEnd = onPointerEnd;
document.addEventListener("pointermove", documentDragMove, { capture: true, passive: false });
document.addEventListener("pointerup", documentDragEnd, { capture: true });
document.addEventListener("pointercancel", documentDragEnd, { capture: true });
};
const cancelPointerDrag = () => {
if (!pointerDrag) return;
if (pointerDrag.active) return;
finishPointerDrag();
};
listRoot.addEventListener("recipe-list-drag-cancel", cancelPointerDrag);
listRoot.addEventListener("recipe-transfer-armed", () => {
if (pointerDrag && !pointerDrag.active) {
detachDocumentDragListeners();
pointerDrag = null;
} else if (pointerDrag?.active) {
finishPointerDrag();
}
});
listRoot.addEventListener(
"pointerdown",
(e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
if (transferBlocksReorder()) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
e.stopPropagation();
const handleRect = handle.getBoundingClientRect();
pointerDrag = {
item,
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
lastY: e.clientY,
grabOffsetX: e.clientX - handleRect.left,
grabOffsetY: e.clientY - handleRect.top,
active: false,
ghost: null,
dropToIndex: null,
};
attachDocumentDragListeners();
},
{ capture: true, passive: false }
);
}
export function attachRecipeListDragDrop(listRoot, { moveRecipe, notyf }) {
if (!listRoot || typeof moveRecipe !== "function") return;
let draggingItem = null;
let dropPending = false;
listRoot.addEventListener("dragstart", (e) => {
if (isMobileReorderUi()) {
e.preventDefault();
return;
}
if (transferBlocksReorder()) {
e.preventDefault();
return;
}
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !listRoot.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || item.dataset.index === undefined) return;
e.stopPropagation();
draggingItem = item;
item.classList.add("recipe-card-dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", item.dataset.recipeId || "");
mountRecipeCardDragGhost(item, e);
});
listRoot.addEventListener("dragend", () => {
removeRecipeDragGhost();
if (draggingItem) {
draggingItem.classList.remove("recipe-card-dragging");
}
draggingItem = null;
dropPending = false;
clearDropTargets(listRoot);
});
listRoot.addEventListener("dragover", (e) => {
if (!draggingItem) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
const target = e.target.closest(".list-item[data-recipe-id]");
clearDropTargets(listRoot);
if (target && target !== draggingItem) {
target.classList.add("recipe-card-drop-target");
}
});
listRoot.addEventListener("dragleave", (e) => {
if (!listRoot.contains(e.relatedTarget)) {
clearDropTargets(listRoot);
}
});
listRoot.addEventListener("drop", async (e) => {
if (!draggingItem || dropPending) return;
if (transferBlocksReorder() && slotPickActive()) return;
e.preventDefault();
e.stopPropagation();
clearDropTargets(listRoot);
const indices = resolveReorderDropIndex(listRoot, e, draggingItem);
if (!indices) return;
void moveRecipe(draggingItem.dataset.recipeId, indices.fromIndex, indices.toIndex);
});
const cancelHtml5Drag = () => {
if (listRoot.classList.contains("recipe-list-reorder-active")) return;
removeRecipeDragGhost();
if (draggingItem) {
draggingItem.classList.remove("recipe-card-dragging");
draggingItem = null;
}
dropPending = false;
clearDropTargets(listRoot);
};
listRoot.addEventListener("recipe-list-drag-cancel", cancelHtml5Drag);
if (!listRoot.dataset.recipeDragCancelBound) {
listRoot.dataset.recipeDragCancelBound = "1";
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") return;
cancelHtml5Drag();
listRoot.dispatchEvent(
new CustomEvent("recipe-list-drag-cancel", { bubbles: true })
);
},
true
);
}
attachPointerReorder(listRoot, { moveRecipe, notyf });
}
@@ -0,0 +1,166 @@
const MOBILE_SHEET_MQ = "(max-width: 768px)";
function getSheetParts(root) {
if (!root) return null;
const panel = root.querySelector(
".recipe-mobile-edit-sheet-panel, .ingredient-mobile-sheet-panel, .recipe-help-modal-panel"
);
const body = root.querySelector(
".recipe-mobile-edit-sheet-body, .ingredient-mobile-sheet-body, .recipe-help-modal-body"
);
const backdrop = root.querySelector(".recipe-mobile-edit-sheet-backdrop");
return panel ? { root, panel, body, backdrop } : null;
}
export function resetRecipeMobileSheetPresentation(root) {
const parts = getSheetParts(root);
if (!parts) return;
const { panel, backdrop } = parts;
panel.style.transform = "";
panel.style.transition = "";
panel.classList.remove("recipe-mobile-edit-sheet--dragging");
if (backdrop) {
backdrop.style.opacity = "";
backdrop.style.transition = "";
}
}
function canStartSheetDrag(target, body) {
if (target.closest(".recipe-mobile-edit-sheet-done")) return false;
if (target.closest("button, input, select, textarea, a, label")) {
return !!target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-title");
}
if (target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-header")) {
return true;
}
if (body?.contains(target) && body.scrollTop <= 0) {
return true;
}
return false;
}
function bindSheetGestures(root, onClose, mobileQuery) {
const parts = getSheetParts(root);
if (!parts || typeof onClose !== "function") return;
const { panel, body, backdrop } = parts;
let dragState = null;
function clearDragState() {
dragState = null;
panel.classList.remove("recipe-mobile-edit-sheet--dragging");
}
function applyDrag(deltaY) {
const offset = Math.max(0, deltaY);
panel.style.transition = "none";
panel.style.transform = `translateY(${offset}px)`;
if (backdrop) {
backdrop.style.transition = "none";
backdrop.style.opacity = String(Math.max(0, 1 - offset / 280));
}
}
function snapBack() {
panel.style.transition = "transform 0.24s ease";
panel.style.transform = "";
if (backdrop) {
backdrop.style.transition = "opacity 0.24s ease";
backdrop.style.opacity = "";
}
window.setTimeout(() => {
panel.style.transition = "";
if (backdrop) backdrop.style.transition = "";
clearDragState();
}, 240);
}
function dismissWithAnimation() {
panel.style.transition = "transform 0.22s ease";
panel.style.transform = "translateY(100%)";
if (backdrop) {
backdrop.style.transition = "opacity 0.22s ease";
backdrop.style.opacity = "0";
}
window.setTimeout(() => {
resetRecipeMobileSheetPresentation(root);
onClose();
}, 220);
}
function onPointerDown(event) {
if (!mobileQuery.matches || root.hidden) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
if (!canStartSheetDrag(event.target, body)) return;
dragState = {
pointerId: event.pointerId,
startY: event.clientY,
fromScrollableBody: !!(body && body.contains(event.target) && !event.target.closest(".recipe-mobile-edit-sheet-header, .recipe-mobile-edit-sheet-grab")),
};
panel.classList.add("recipe-mobile-edit-sheet--dragging");
panel.setPointerCapture(event.pointerId);
}
function onPointerMove(event) {
if (!dragState || event.pointerId !== dragState.pointerId) return;
const deltaY = event.clientY - dragState.startY;
if (dragState.fromScrollableBody) {
if (deltaY <= 0 || (body && body.scrollTop > 0)) {
clearDragState();
resetRecipeMobileSheetPresentation(root);
return;
}
if (deltaY > 8) {
dragState.fromScrollableBody = false;
} else {
return;
}
}
if (deltaY > 0) {
event.preventDefault();
applyDrag(deltaY);
}
}
function onPointerEnd(event) {
if (!dragState || event.pointerId !== dragState.pointerId) return;
const deltaY = event.clientY - dragState.startY;
const threshold = Math.min(96, panel.offsetHeight * 0.2);
clearDragState();
if (deltaY > threshold) {
dismissWithAnimation();
return;
}
snapBack();
}
panel.addEventListener("pointerdown", onPointerDown);
panel.addEventListener("pointermove", onPointerMove, { passive: false });
panel.addEventListener("pointerup", onPointerEnd);
panel.addEventListener("pointercancel", onPointerEnd);
}
export function initRecipeMobileSheetGestures(handlers = {}) {
const mobileQuery = window.matchMedia(MOBILE_SHEET_MQ);
const sheets = [
{
root: document.getElementById("ingredientMobileSheet"),
onClose: handlers.closeIngredientMobileSheet,
},
{
root: document.getElementById("unloadingGroupMobileSheet"),
onClose: handlers.closeUnloadingGroupMobileSheet,
},
{
root: document.getElementById("recipeHelpModal"),
onClose: handlers.closeRecipeHelpModal,
},
];
sheets.forEach(({ root, onClose }) => bindSheetGestures(root, onClose, mobileQuery));
}
@@ -0,0 +1,655 @@
/**
* Перенос рейса в другой период/оборудование: drag с ручки, удержание 1–2 с над целью, выбор позиции.
* Отмена: отпустить не над целью / Escape; другой период или оборудование — снова удержать над нужной карточкой или заново перетащить с ручки.
*/
const LONG_HOVER_MS = 1400;
const MOBILE_TRANSFER_MQ = "(max-width: 768px)";
const MOBILE_TRANSFER_LONG_PRESS_MS = 750;
const MOBILE_TRANSFER_MOVE_CANCEL_PX = 14;
const TRANSFER_MIME = "application/x-wesp-recipe-transfer";
export function attachRecipePeriodTransfer({
getCurrentDispenserType,
getSelectedDispenser,
getSelectedPeriod,
selectDispenser,
selectPeriod,
loadRecipes,
loadPeriods,
notyf,
}) {
const recipesList = document.getElementById("recipesList");
let session = null;
let slotPick = false;
let transferInFlight = false;
let longHoverState = { kind: null, id: null, since: 0, fired: false };
let rafHoverId = null;
let listMutationTimer = null;
function clearLongHover() {
longHoverState = { kind: null, id: null, since: 0, fired: false };
if (rafHoverId != null) {
cancelAnimationFrame(rafHoverId);
rafHoverId = null;
}
}
function isTransferAllowed() {
return (
typeof getCurrentDispenserType === "function" &&
getCurrentDispenserType() === "dispenser" &&
getSelectedPeriod()
);
}
function clearEligibleHighlights() {
document.querySelectorAll(".recipe-transfer-eligible").forEach((el) => {
el.classList.remove("recipe-transfer-eligible");
});
}
function applyEligibleHighlights() {
clearEligibleHighlights();
if (!session || slotPick) return;
document.querySelectorAll("#dispensersList .list-item[data-dispenser-id]").forEach((el) => {
if (el.dataset.dispenserType === "mill") return;
el.classList.add("recipe-transfer-eligible");
});
document.querySelectorAll("#periodsList .list-item[data-period-id]").forEach((el) => {
const pid = el.dataset.periodId;
if (
String(pid) === String(session.fromPeriodId) &&
String(getSelectedDispenser()) === String(session.fromDispenserId)
) {
return;
}
el.classList.add("recipe-transfer-eligible");
});
}
function startSession(recipeId, fromDispenserId, fromPeriodId) {
clearSlotPickUi();
session = { recipeId, fromDispenserId, fromPeriodId };
slotPick = false;
/* Подсветку целей не включаем здесь — иначе reorder в том же периоде блокируется с первого mousedown. */
}
function setMobileTransferOverlay(active) {
if (!globalThis.matchMedia?.("(max-width: 768px)")?.matches) return;
document.body.classList.toggle("recipe-transfer-session", !!active);
}
let pointerCaptureEl = null;
let pointerCaptureId = null;
let pendingDragItem = null;
function releaseTransferPointerCapture() {
if (pointerCaptureEl && pointerCaptureId != null) {
try {
pointerCaptureEl.releasePointerCapture(pointerCaptureId);
} catch (_) {
/* ignore */
}
}
pointerCaptureEl = null;
pointerCaptureId = null;
}
function clearTransferSessionState({ emitDragCancel = true } = {}) {
if (listMutationTimer != null) {
clearTimeout(listMutationTimer);
listMutationTimer = null;
}
clearMobilePressState();
releaseTransferPointerCapture();
pendingDragItem = null;
mouseSessionActive = false;
session = null;
slotPick = false;
pointerSessionId = null;
document.body.classList.remove("recipe-period-transfer-active");
document.body.classList.remove("recipe-mobile-transfer-armed");
setMobileTransferOverlay(false);
clearEligibleHighlights();
clearSlotPickUi();
clearLongHover();
if (emitDragCancel) {
recipesList?.dispatchEvent(
new CustomEvent("recipe-list-drag-cancel", { bubbles: true })
);
}
}
function endSession() {
clearTransferSessionState({ emitDragCancel: true });
}
function clearSlotPickUi() {
document.querySelectorAll(".recipe-transfer-slot").forEach((n) => n.remove());
}
function isValidDropTarget(toDispenser, toPeriod) {
if (!toDispenser || !toPeriod || !session) return false;
return !(
String(toDispenser) === String(session.fromDispenserId) &&
String(toPeriod) === String(session.fromPeriodId)
);
}
/** Позиция вставки по Y относительно карточек (без учёта кнопок-слотов). */
function insertIndexFromRecipesListDrop(listRoot, e) {
if (!listRoot) return 0;
const y = e.clientY;
const items = [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")];
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect();
if (y < r.top + r.height / 2) return i;
}
return items.length;
}
async function commitTransfer(toIndex) {
if (!session || transferInFlight) return;
transferInFlight = true;
const sess = session;
const toDispenser = getSelectedDispenser();
const toPeriod = getSelectedPeriod();
try {
if (!sess || !toDispenser || !toPeriod || !isValidDropTarget(toDispenser, toPeriod)) {
endSession();
return;
}
const url = `/api/feed_dispensers/${encodeURIComponent(toDispenser)}/periods/${encodeURIComponent(toPeriod)}/recipes/${encodeURIComponent(sess.recipeId)}/transfer`;
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
from_dispenser_id: sess.fromDispenserId,
from_period_id: sess.fromPeriodId,
to_index: toIndex,
}),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = j.message || "Ошибка переноса";
if (notyf) {
if (r.status === 409) {
if (typeof notyf.warning === "function") notyf.warning(msg);
else notyf.error(msg);
} else {
notyf.error(msg);
}
}
clearSlotPickUi();
slotPick = false;
if (r.status === 409) {
try {
if (typeof loadPeriods === "function") await loadPeriods(toDispenser);
if (typeof loadRecipes === "function") await loadRecipes(toPeriod);
} catch (_) {
/* ignore */
}
}
endSession();
return;
}
clearSlotPickUi();
slotPick = false;
if (notyf) notyf.success(j.message || "Рейс перенесён");
if (typeof loadPeriods === "function") await loadPeriods(toDispenser);
if (typeof loadRecipes === "function") await loadRecipes(toPeriod);
endSession();
} catch (err) {
console.error(err);
if (notyf) notyf.error(err.message, { fallback: "Ошибка переноса" });
endSession();
} finally {
transferInFlight = false;
}
}
function scheduleHoverCheck() {
if (rafHoverId != null) return;
const tick = () => {
rafHoverId = null;
if (!session || longHoverState.kind == null) return;
const elapsed = performance.now() - longHoverState.since;
if (!longHoverState.fired && elapsed >= LONG_HOVER_MS) {
longHoverState.fired = true;
void fireLongHover(longHoverState.kind, longHoverState.id);
return;
}
if (!longHoverState.fired && session) {
rafHoverId = requestAnimationFrame(tick);
}
};
rafHoverId = requestAnimationFrame(tick);
}
function trackLongHover(kind, id) {
if (!session) return;
if (longHoverState.kind !== kind || longHoverState.id !== id) {
longHoverState = { kind, id, since: performance.now(), fired: false };
applyEligibleHighlights();
}
setMobileTransferOverlay(true);
scheduleHoverCheck();
}
async function fireLongHover(kind, id) {
if (!session) return;
transferHoverCommitPending = true;
try {
await fireLongHoverImpl(kind, id);
} finally {
transferHoverCommitPending = false;
}
}
async function fireLongHoverImpl(kind, id) {
if (!session) return;
if (kind === "dispenser") {
if (String(id) === String(getSelectedDispenser())) return;
await selectDispenser(id);
document.body.classList.add("recipe-period-transfer-active");
applyEligibleHighlights();
return;
}
if (kind === "period") {
if (
String(id) === String(session.fromPeriodId) &&
String(getSelectedDispenser()) === String(session.fromDispenserId)
) {
if (notyf) notyf.error("Выберите другой период");
return;
}
if (slotPick && String(id) === String(getSelectedPeriod())) return;
await selectPeriod(id);
document.body.classList.add("recipe-period-transfer-active");
enterSlotPickMode();
}
}
function enterSlotPickMode() {
slotPick = true;
clearEligibleHighlights();
clearLongHover();
setMobileTransferOverlay(true);
requestAnimationFrame(() => injectSlots());
}
/** Список рейсов перерисован (loadRecipes, смена периода, перестановка) — убрать «Вставить здесь», если они уже не в DOM. */
function syncSlotPickAfterListMutation() {
if (!session || !slotPick) return;
const list = document.getElementById("recipesList");
if (!list || list.querySelector(".recipe-transfer-slot")) return;
endSession();
}
function scheduleSyncAfterListMutation() {
clearTimeout(listMutationTimer);
listMutationTimer = setTimeout(() => {
listMutationTimer = null;
syncSlotPickAfterListMutation();
}, 80);
}
if (recipesList) {
new MutationObserver(() => scheduleSyncAfterListMutation()).observe(recipesList, {
childList: true,
subtree: false,
});
}
function injectSlots() {
clearSlotPickUi();
const list = document.getElementById("recipesList");
if (!list || !session) return;
const items = [...list.querySelectorAll(":scope > .list-item[data-recipe-id]")];
for (let i = 0; i <= items.length; i++) {
const slot = document.createElement("button");
slot.type = "button";
slot.className = "recipe-transfer-slot";
slot.dataset.insertIndex = String(i);
slot.innerHTML =
'<span class="recipe-transfer-slot-inner"><span class="recipe-transfer-slot-label">Вставить здесь</span></span>';
slot.setAttribute("aria-label", `Вставить рейс на позицию ${i + 1}`);
slot.addEventListener("click", onSlotClick);
if (i < items.length) {
list.insertBefore(slot, items[i]);
} else {
list.appendChild(slot);
}
}
}
function onSlotClick(e) {
const slot = e.target.closest(".recipe-transfer-slot");
if (!slot || !session) return;
const toIndex = Number(slot.dataset.insertIndex);
if (Number.isNaN(toIndex)) return;
void commitTransfer(toIndex);
}
function tryBeginSessionFromPendingDrag(clientX, clientY) {
if (session || !pendingDragItem) return false;
const target = document.elementFromPoint(clientX, clientY);
if (target?.closest("#recipesList .list-item[data-recipe-id]")) return false;
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (!dItem && !pItem) return false;
if (dItem?.dataset.dispenserType === "mill") return false;
beginHandleSession(pendingDragItem);
return true;
}
function onDocumentDragOver(e) {
if (!session) {
if (tryBeginSessionFromPendingDrag(e.clientX, e.clientY)) {
handleSessionHoverAtPoint(e.clientX, e.clientY, e);
}
return;
}
handleSessionHoverAtPoint(e.clientX, e.clientY, e);
}
function handleSessionHoverAtPoint(clientX, clientY, dragEvent) {
if (!session) return;
if (slotPick && recipesList && recipesList.contains(document.elementFromPoint(clientX, clientY))) {
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
return;
}
if (slotPick) return;
const target = document.elementFromPoint(clientX, clientY);
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (dItem) {
if (dItem.dataset.dispenserType === "mill") {
clearLongHover();
return;
}
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
trackLongHover("dispenser", dItem.dataset.dispenserId);
return;
}
if (pItem) {
if (dragEvent?.preventDefault) {
dragEvent.preventDefault();
if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move";
}
trackLongHover("period", pItem.dataset.periodId);
return;
}
clearLongHover();
}
function handleSessionDropAtPoint(clientX, clientY, dragEvent) {
if (!session) return false;
const target = document.elementFromPoint(clientX, clientY);
if (slotPick && recipesList && target && recipesList.contains(target)) {
const toDispenser = getSelectedDispenser();
const toPeriod = getSelectedPeriod();
if (!isValidDropTarget(toDispenser, toPeriod)) {
dragEvent?.preventDefault?.();
dragEvent?.stopPropagation?.();
endSession();
return true;
}
dragEvent?.preventDefault?.();
dragEvent?.stopPropagation?.();
const toIndex = insertIndexFromRecipesListDrop(recipesList, { clientY });
void commitTransfer(toIndex);
return true;
}
if (slotPick) return false;
const recipeDrop = target?.closest("#recipesList .list-item[data-recipe-id]");
if (recipeDrop) return false;
const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]");
const pItem = target?.closest("#periodsList .list-item[data-period-id]");
if (dItem || pItem) {
dragEvent?.preventDefault?.();
return true;
}
dragEvent?.preventDefault?.();
endSession();
return true;
}
function onDocumentDropCapture(e) {
if (!session) return;
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
function beginHandleSession(item) {
if (!item || item.dataset.index === undefined) return;
if (!isTransferAllowed()) return;
const recipeId = item.dataset.recipeId;
const fromDispenserId = getSelectedDispenser();
const fromPeriodId = getSelectedPeriod();
startSession(recipeId, fromDispenserId, fromPeriodId);
}
function onDragStartCapture(e) {
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item) return;
/* Сессию transfer не открываем сразу — иначе ломается повторный reorder в том же списке. */
pendingDragItem = item;
try {
e.dataTransfer.setData(
TRANSFER_MIME,
JSON.stringify({
recipeId: item.dataset.recipeId,
fromDispenserId: getSelectedDispenser(),
fromPeriodId: getSelectedPeriod(),
})
);
} catch (_) {
/* ignore */
}
}
function onDragEndCapture(e) {
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
if (slotPick && session) return;
pendingDragItem = null;
releaseTransferPointerCapture();
pointerSessionId = null;
mouseSessionActive = false;
if (recipesList.classList.contains("recipe-list-reorder-active")) {
if (!slotPick) clearTransferSessionState({ emitDragCancel: false });
return;
}
if (session) clearTransferSessionState({ emitDragCancel: true });
}
let pointerSessionId = null;
let mouseSessionActive = false;
let transferHoverCommitPending = false;
let mobilePressState = null;
function isMobileTransferUi() {
return globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches ?? false;
}
function clearMobilePressState() {
if (mobilePressState?.longPressTimer) {
clearTimeout(mobilePressState.longPressTimer);
}
mobilePressState = null;
}
function cancelMobilePressForReorder() {
clearMobilePressState();
}
function armMobileTransferSession(item) {
if (!item || !isTransferAllowed()) return;
beginHandleSession(item);
document.body.classList.add("recipe-mobile-transfer-armed");
recipesList?.dispatchEvent(new CustomEvent("recipe-transfer-armed", { bubbles: true }));
globalThis.navigator?.vibrate?.(12);
if (notyf) {
notyf.success("Перетащите рейс на период или оборудование");
}
}
function onPointerDownCapture(e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item) return;
if (!isTransferAllowed()) return;
if (isMobileTransferUi()) {
clearMobilePressState();
mobilePressState = {
pointerId: e.pointerId,
item,
startX: e.clientX,
startY: e.clientY,
longPressTimer: globalThis.setTimeout(() => {
if (!mobilePressState) return;
if (recipesList?.classList.contains("recipe-list-reorder-active")) {
clearMobilePressState();
return;
}
mobilePressState.armed = true;
pointerSessionId = mobilePressState.pointerId;
armMobileTransferSession(mobilePressState.item);
}, MOBILE_TRANSFER_LONG_PRESS_MS),
};
return;
}
/* Desktop: reorder владеет ручкой через pointer/HTML5; transfer — через dragstart + hover. */
}
function onPointerMoveCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId && !mobilePressState.armed) {
const dx = Math.abs(e.clientX - mobilePressState.startX);
const dy = Math.abs(e.clientY - mobilePressState.startY);
if (dx + dy >= MOBILE_TRANSFER_MOVE_CANCEL_PX) {
clearMobilePressState();
}
}
if (!session || pointerSessionId == null || e.pointerId !== pointerSessionId) return;
if (globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches && e.clientY < window.innerHeight * 0.38) {
setMobileTransferOverlay(true);
}
handleSessionHoverAtPoint(e.clientX, e.clientY, null);
}
function onPointerUpCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId) {
const wasArmed = mobilePressState.armed;
clearMobilePressState();
if (!wasArmed) return;
}
const ownsPointer = pointerSessionId != null && e.pointerId === pointerSessionId;
if (ownsPointer) {
releaseTransferPointerCapture();
pointerSessionId = null;
mouseSessionActive = false;
}
if (!session) return;
if (!ownsPointer) return;
if (transferHoverCommitPending || (slotPick && session)) return;
const target = document.elementFromPoint(e.clientX, e.clientY);
const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]");
if (overRecipe && !slotPick) {
clearTransferSessionState({ emitDragCancel: true });
return;
}
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
function onPointerCancelCapture(e) {
if (mobilePressState && e.pointerId === mobilePressState.pointerId) {
clearMobilePressState();
}
if (pointerSessionId == null || e.pointerId !== pointerSessionId) return;
pointerSessionId = null;
if (slotPick && session) return;
endSession();
}
recipesList?.addEventListener("dragstart", onDragStartCapture, true);
recipesList?.addEventListener("dragend", onDragEndCapture, true);
recipesList?.addEventListener("recipe-reorder-active", () => {
cancelMobilePressForReorder();
pendingDragItem = null;
/* Не шлём recipe-list-drag-cancel — иначе reorder обрывается в pointer-path. */
if (!slotPick) clearTransferSessionState({ emitDragCancel: false });
});
function onMouseDownCapture(e) {
/* Desktop mousedown-сессия ломала повторный reorder; transfer на десктопе — dragstart + dragover. */
if (!isMobileTransferUi()) return;
if (e.button !== 0 || pointerSessionId != null) return;
const handle = e.target.closest(".recipe-drag-handle");
if (!handle || !recipesList || !recipesList.contains(handle)) return;
const item = handle.closest(".list-item[data-recipe-id]");
if (!item || !isTransferAllowed()) return;
beginHandleSession(item);
mouseSessionActive = true;
}
function onMouseMoveCapture(e) {
if (!session || !mouseSessionActive || slotPick) return;
handleSessionHoverAtPoint(e.clientX, e.clientY, null);
}
function onMouseUpCapture(e) {
if (!mouseSessionActive) return;
mouseSessionActive = false;
if (!session) return;
if (transferHoverCommitPending || (slotPick && session)) return;
const target = document.elementFromPoint(e.clientX, e.clientY);
const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]");
if (overRecipe && !slotPick) {
endSession();
return;
}
handleSessionDropAtPoint(e.clientX, e.clientY, e);
}
recipesList?.addEventListener("pointerdown", onPointerDownCapture, true);
recipesList?.addEventListener("mousedown", onMouseDownCapture, true);
document.addEventListener("mousemove", onMouseMoveCapture, true);
document.addEventListener("mouseup", onMouseUpCapture, true);
document.addEventListener("pointermove", onPointerMoveCapture, true);
document.addEventListener("pointerup", onPointerUpCapture, true);
document.addEventListener("pointercancel", onPointerCancelCapture, true);
document.addEventListener("dragover", onDocumentDragOver, true);
document.addEventListener("drop", onDocumentDropCapture, true);
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape" || !session) return;
endSession();
});
return { endSession };
}
@@ -0,0 +1,35 @@
const LIST_LABELS = {
dispensers: "Загрузка кормораздатчиков",
periods: "Загрузка периодов",
recipes: "Загрузка рейсов",
mill: "Загрузка рецептов",
period: "Загрузка рейсов",
};
/**
* Structural loading — один spinner на список (не 4 копии).
*/
export function buildRecipeRowsSkeletonHtml(variant = "period") {
const label = LIST_LABELS[variant] || LIST_LABELS.period;
return `<div class="zt-loading-list dashboard-list-skeleton" data-skeleton-variant="${variant}" aria-busy="true" aria-label="${label}">
<div class="zt-loading-placeholder">
<div class="zt-spinner" role="status" aria-hidden="true"></div>
<p class="zt-loading-caption">${label}…</p>
</div>
</div>`;
}
export function cloneTemplateOuterHtml(templateId) {
const tpl = document.getElementById(templateId);
const el = tpl?.content?.firstElementChild;
if (!el) return "";
return el.outerHTML;
}
export function cloneTemplateInto(container, templateId) {
const tpl = document.getElementById(templateId);
const el = tpl?.content?.firstElementChild;
if (!el || !container) return false;
container.appendChild(el.cloneNode(true));
return true;
}
@@ -0,0 +1,42 @@
export class SyncPanel {
constructor(handlers) {
this.handlers = handlers;
}
async handleAction(action, event, actionEl) {
switch (action) {
case "open-settings":
event.preventDefault();
await this.handlers.openSettings(event);
return true;
case "close-settings":
this.handlers.closeSettings();
return true;
case "toggle-credentials-form":
this.handlers.toggleCredentialsForm();
return true;
case "toggle-sync-form":
this.handlers.toggleSyncForm();
return true;
case "change-credentials":
await this.handlers.changeCredentials();
return true;
case "sync-edit-client":
event.stopPropagation();
this.handlers.editSyncClientSettings(actionEl);
return true;
case "sync-delete-client":
event.stopPropagation();
this.handlers.deleteSyncClientSettings(actionEl);
return true;
case "sync-save-client":
this.handlers.saveSyncClientDisplayName(actionEl.dataset.nodeId || "");
return true;
case "sync-cancel-edit":
await this.handlers.loadSyncClientsSettings();
return true;
default:
return false;
}
}
}
@@ -0,0 +1,40 @@
export class ReconnectingEventSource {
constructor(url, { retryDelayMs = 2000, maxRetryDelayMs = 15000 } = {}) {
this.url = url;
this.retryDelayMs = retryDelayMs;
this.maxRetryDelayMs = maxRetryDelayMs;
this.onmessage = null;
this.onerror = null;
this._closed = false;
this._es = null;
this._currentDelay = retryDelayMs;
this._connect();
}
_connect() {
if (this._closed) return;
this._es = new EventSource(this.url);
this._es.onmessage = (event) => {
this._currentDelay = this.retryDelayMs;
if (this.onmessage) this.onmessage(event);
};
this._es.onerror = (event) => {
if (this.onerror) this.onerror(event);
this._scheduleReconnect();
};
}
_scheduleReconnect() {
if (this._closed) return;
if (this._es) this._es.close();
const delay = this._currentDelay;
this._currentDelay = Math.min(this._currentDelay * 2, this.maxRetryDelayMs);
setTimeout(() => this._connect(), delay);
}
close() {
this._closed = true;
if (this._es) this._es.close();
this._es = null;
}
}
File diff suppressed because one or more lines are too long
@@ -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);
@@ -0,0 +1,100 @@
/**
* Reads site JWT from localStorage and seeds __WESP_ORCH__ before zootech pages load.
*/
(function (global) {
const TOKEN_KEY = "compton.access_token";
const ENTERPRISE_KEY = "compton.enterprise_id";
const USER_KEY = "compton.user";
const DEBUG_ENDPOINT = "http://127.0.0.1:7898/ingest/d209dffb-c63e-477b-8b08-a57520eaee9b";
const SESSION = "785e22";
function debugLog(hypothesisId, message, data) {
// #region agent log
fetch(DEBUG_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": SESSION },
body: JSON.stringify({
sessionId: SESSION,
runId: "login-fix",
hypothesisId,
location: "wesp-orchestrator-auth-bridge.js",
message,
data,
timestamp: Date.now(),
}),
}).catch(function () {});
// #endregion
}
function readJson(key) {
try {
const raw = global.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
} catch (_err) {
return null;
}
}
const token = global.localStorage.getItem(TOKEN_KEY) || "";
const enterpriseId =
global.localStorage.getItem(ENTERPRISE_KEY) ||
new URLSearchParams(global.location.search).get("enterprise_id") ||
"";
const user = readJson(USER_KEY);
global.__WESP_ORCH__ = Object.assign({}, global.__WESP_ORCH__ || {}, {
apiBase: "/api/v1",
accessToken: token,
enterpriseId: enterpriseId,
userEmail: user && user.email ? user.email : "",
});
debugLog("H1", "auth bridge init", {
hasToken: Boolean(token),
hasEnterprise: Boolean(enterpriseId),
path: global.location.pathname,
});
if (token && !enterpriseId) {
fetch("/api/v1/enterprise/enterprises", {
headers: { Authorization: "Bearer " + token },
})
.then(function (resp) {
return resp.ok ? resp.json() : [];
})
.then(function (rows) {
const entId = Array.isArray(rows) && rows[0] && rows[0].id ? String(rows[0].id) : "";
if (!entId) {
debugLog("H1", "enterprise resolve failed", { count: Array.isArray(rows) ? rows.length : -1 });
return;
}
global.localStorage.setItem(ENTERPRISE_KEY, entId);
global.__WESP_ORCH__ = Object.assign({}, global.__WESP_ORCH__ || {}, { enterpriseId: entId });
debugLog("H1", "enterprise resolved async", { enterpriseId: entId });
})
.catch(function () {
debugLog("H1", "enterprise resolve error", {});
});
}
global.ComptonWespAuth = {
TOKEN_KEY,
ENTERPRISE_KEY,
USER_KEY,
persistSession(accessToken, userPayload, entId) {
if (accessToken) global.localStorage.setItem(TOKEN_KEY, accessToken);
if (userPayload) global.localStorage.setItem(USER_KEY, JSON.stringify(userPayload));
if (entId) global.localStorage.setItem(ENTERPRISE_KEY, entId);
global.__WESP_ORCH__ = Object.assign({}, global.__WESP_ORCH__ || {}, {
accessToken: accessToken || "",
enterpriseId: entId || "",
userEmail: userPayload && userPayload.email ? userPayload.email : "",
});
},
clearSession() {
global.localStorage.removeItem(TOKEN_KEY);
global.localStorage.removeItem(ENTERPRISE_KEY);
global.localStorage.removeItem(USER_KEY);
},
};
})(window);
@@ -0,0 +1,101 @@
/**
* Orchestrator boot adapter: JWT, enterprise scope, API base for copied WESP static UI.
*/
(function (global) {
const cfg = global.__WESP_ORCH__ || {};
const API_BASE = cfg.apiBase || "/api/v1";
const AUTH_MAP = {
"/api/auth/change_credentials": "/api/v1/users/me/password",
};
const WESP_API_PREFIXES = [
"/api/admin/",
"/api/notifications",
"/api/updates/",
"/api/sync/",
"/api/feed_dispensers",
"/api/components",
"/api/recipes",
"/api/reports",
"/api/analytics/",
"/api/feed-quality/",
"/api/periods",
"/api/auth/",
];
function readToken() {
return (global.__WESP_ORCH__ && global.__WESP_ORCH__.accessToken) || "";
}
function readEnterpriseId() {
return (global.__WESP_ORCH__ && global.__WESP_ORCH__.enterpriseId) || "";
}
function isExternalUrl(url) {
return /^https?:\/\//i.test(url);
}
function patchUrl(url) {
if (typeof url !== "string" || isExternalUrl(url)) return url;
for (const [from, to] of Object.entries(AUTH_MAP)) {
if (url === from || url.startsWith(from + "?")) {
return to + url.slice(from.length);
}
}
if (url.startsWith("/api/v1/sync/")) {
return url;
}
for (let i = 0; i < WESP_API_PREFIXES.length; i++) {
if (url.startsWith(WESP_API_PREFIXES[i])) {
return url;
}
}
if (url.startsWith("/api/") && !url.startsWith("/api/v1/")) {
return API_BASE + url.slice(4);
}
return url;
}
function withAuth(url, init) {
if (isExternalUrl(url)) {
return init || {};
}
const token = readToken();
const enterpriseId = readEnterpriseId();
const headers = new Headers((init && init.headers) || {});
if (token && !headers.has("Authorization")) {
headers.set("Authorization", "Bearer " + token);
}
if (enterpriseId && !headers.has("X-Enterprise-Id")) {
headers.set("X-Enterprise-Id", enterpriseId);
}
return Object.assign({}, init || {}, { headers });
}
function appendEnterpriseQuery(url) {
if (isExternalUrl(url)) return url;
const enterpriseId = readEnterpriseId();
if (!enterpriseId || url.indexOf("enterprise_id=") >= 0) {
return url;
}
const sep = url.indexOf("?") >= 0 ? "&" : "?";
return url + sep + "enterprise_id=" + encodeURIComponent(enterpriseId);
}
const nativeFetch = global.fetch.bind(global);
global.fetch = function (input, init) {
if (typeof input === "string") {
const patched = appendEnterpriseQuery(patchUrl(input));
return nativeFetch(patched, withAuth(patched, init));
}
return nativeFetch(input, withAuth("", init));
};
global.WespOrchestratorBoot = {
apiBase: API_BASE,
get enterpriseId() {
return readEnterpriseId();
},
};
})(window);
@@ -0,0 +1,85 @@
/**
* Orchestrator login adapter for WESP login.html persist JWT + enterprise after /api/auth/login.
*/
(function (global) {
const DEBUG_ENDPOINT = "http://127.0.0.1:7898/ingest/d209dffb-c63e-477b-8b08-a57520eaee9b";
const SESSION = "785e22";
function debugLog(hypothesisId, message, data) {
// #region agent log
fetch(DEBUG_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Debug-Session-Id": SESSION },
body: JSON.stringify({
sessionId: SESSION,
runId: "login-fix",
hypothesisId,
location: "wesp-orchestrator-login.js",
message,
data,
timestamp: Date.now(),
}),
}).catch(function () {});
// #endregion
}
async function resolveEnterpriseId(accessToken) {
try {
const resp = await fetch("/api/v1/enterprise/enterprises", {
headers: { Authorization: "Bearer " + accessToken },
});
if (!resp.ok) return "";
const rows = await resp.json();
return Array.isArray(rows) && rows[0] && rows[0].id ? String(rows[0].id) : "";
} catch (_err) {
return "";
}
}
async function persistOrchestratorSession(payload) {
const token = payload && payload.access_token ? String(payload.access_token) : "";
const user = payload && payload.user ? payload.user : null;
if (!token || !user) {
debugLog("H2", "login missing token/user", { hasToken: Boolean(token), hasUser: Boolean(user) });
return false;
}
const enterpriseId = await resolveEnterpriseId(token);
if (global.ComptonWespAuth && typeof global.ComptonWespAuth.persistSession === "function") {
global.ComptonWespAuth.persistSession(token, user, enterpriseId);
}
global.__WESP_ORCH__ = Object.assign({}, global.__WESP_ORCH__ || {}, {
accessToken: token,
enterpriseId: enterpriseId,
userEmail: user.email || "",
});
debugLog("H1", "session persisted", { hasEnterprise: Boolean(enterpriseId), email: user.email || "" });
return Boolean(enterpriseId);
}
const nativeFetch = global.fetch.bind(global);
global.fetch = function orchestratorLoginFetch(input, init) {
const url = typeof input === "string" ? input : input && input.url ? input.url : "";
if (url.indexOf("/api/auth/login") === -1) {
return nativeFetch(input, init);
}
debugLog("H3", "wesp login request", { url: url.split("?")[0] });
return nativeFetch(input, init).then(async function (response) {
const cloned = response.clone();
let data = {};
try {
data = await cloned.json();
} catch (_err) {
debugLog("H3", "login response not json", { status: response.status });
return response;
}
if (response.ok && data.status === "success") {
await persistOrchestratorSession(data);
} else {
debugLog("H3", "login failed", { status: response.status, bodyStatus: data.status });
}
return response;
});
};
debugLog("H4", "orchestrator login adapter ready", { path: global.location.pathname });
})(window);
@@ -0,0 +1,202 @@
/**
* Bootstrap shell-модалки (.wesp-shell-modal) единая анимация открытия/закрытия.
*/
(function (global) {
const WIRED = "data-wesp-shell-modal-wired";
const ALLOW_HIDE = "data-wesp-shell-allow-hide";
const MODAL_MS = 240;
function prefersReducedMotion() {
return global.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false;
}
function getDialog(el) {
return el?.querySelector(".modal-dialog--shell");
}
function wireShellModal(el) {
if (!el || el.getAttribute(WIRED)) return;
el.setAttribute(WIRED, "1");
el.addEventListener("show.bs.modal", function () {
el.classList.remove("wesp-shell-closing");
if (prefersReducedMotion()) {
el.classList.add("wesp-shell-visible");
return;
}
el.classList.remove("wesp-shell-visible");
requestAnimationFrame(function () {
requestAnimationFrame(function () {
el.classList.add("wesp-shell-visible");
});
});
});
el.addEventListener("shown.bs.modal", function () {
el.removeAttribute("aria-hidden");
});
el.addEventListener("hide.bs.modal", function (e) {
if (el.getAttribute(ALLOW_HIDE) === "1") {
el.removeAttribute(ALLOW_HIDE);
el.classList.remove("wesp-shell-visible", "wesp-shell-closing");
return;
}
if (prefersReducedMotion()) {
el.classList.remove("wesp-shell-visible");
return;
}
e.preventDefault();
el.classList.add("wesp-shell-closing");
el.classList.remove("wesp-shell-visible");
const dialog = getDialog(el);
let finished = false;
const finish = function () {
if (finished) return;
finished = true;
el.setAttribute(ALLOW_HIDE, "1");
const instance = global.bootstrap?.Modal?.getInstance(el);
instance?.hide();
};
if (dialog) {
dialog.addEventListener(
"transitionend",
function (ev) {
if (ev.target === dialog) finish();
},
{ once: true }
);
}
global.setTimeout(finish, MODAL_MS + 50);
});
el.addEventListener("hidden.bs.modal", function () {
el.classList.remove("wesp-shell-closing", "wesp-shell-visible");
el.setAttribute("aria-hidden", "true");
});
}
function wireAllShellModals() {
document.querySelectorAll(".modal.wesp-shell-modal").forEach(wireShellModal);
}
function show(modalId) {
const el = document.getElementById(modalId);
if (!el) return null;
wireShellModal(el);
const instance = global.bootstrap?.Modal?.getOrCreateInstance(el, {
backdrop: true,
keyboard: true,
focus: true,
});
instance?.show();
return instance;
}
function hide(modalId) {
const el = document.getElementById(modalId);
if (!el) return;
const instance = global.bootstrap?.Modal?.getInstance(el);
if (instance) instance.hide();
}
function whenPageEnterDone(run) {
const html = document.documentElement;
let ran = false;
let observer = null;
let fallbackTimer = 0;
const once = function () {
if (ran) return;
ran = true;
if (observer) {
observer.disconnect();
observer = null;
}
if (fallbackTimer) {
global.clearTimeout(fallbackTimer);
fallbackTimer = 0;
}
requestAnimationFrame(run);
};
if (html.classList.contains("wesp-page-enter-done")) {
once();
return;
}
if (
!html.classList.contains("wesp-page-enter-pending") &&
!html.classList.contains("wesp-page-enter-ready")
) {
once();
return;
}
observer = new MutationObserver(function () {
if (!html.classList.contains("wesp-page-enter-done")) return;
once();
});
observer.observe(html, { attributes: true, attributeFilter: ["class"] });
fallbackTimer = global.setTimeout(function () {
if (!html.classList.contains("wesp-page-enter-done")) return;
once();
}, 1200);
}
function animateGridCards(gridSelector, options) {
if (prefersReducedMotion()) return;
const force = Boolean(options && options.force);
const run = function () {
const grid = document.querySelector(gridSelector);
if (!grid) return;
const cards = grid.querySelectorAll(".dispenser-card, .component-card, .report-card");
if (
!force &&
cards.length > 0 &&
Array.from(cards).every(function (card) {
return card.classList.contains("wesp-grid-enter-item");
})
) {
return;
}
cards.forEach(function (card, i) {
card.classList.remove("wesp-grid-enter-item");
card.style.removeProperty("--wesp-grid-i");
void card.offsetWidth;
card.style.setProperty("--wesp-grid-i", String(Math.min(i, 12)));
card.classList.add("wesp-grid-enter-item");
});
};
whenPageEnterDone(run);
}
function revealContent(el) {
if (!el || prefersReducedMotion()) return;
el.classList.remove("wesp-content-reveal");
void el.offsetWidth;
el.classList.add("wesp-content-reveal");
}
global.WespZootechModals = {
show,
hide,
wireAllShellModals,
animateGridCards,
revealContent,
prefersReducedMotion,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", wireAllShellModals);
} else {
wireAllShellModals();
}
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,17 @@
/** Локальные SVG-иконки зоотех-UI (офлайн, без CDN). Исходники: /static/icons/*.svg */
export const ROUTE_ICON_PATH =
"M12 22q-2.65 0-4.325-.837T6 19q0-.6.363-1.112T7.374 17l1.575 1.475q-.225.1-.488.225t-.412.3q.325.4 1.5.7T12 20t2.463-.3t1.512-.7q-.175-.2-.45-.325T15 18.45l1.55-1.5q.7.4 1.075.913T18 19q0 1.325-1.675 2.163T12 22m0-3q-3.525-2.6-5.262-5.05T5 9.15q0-1.775.638-3.113T7.275 3.8t2.25-1.35T12 2t2.475.45t2.25 1.35t1.638 2.238T19 9.15q0 2.35-1.737 4.8T12 19m0-8q.825 0 1.413-.587T14 9t-.587-1.412T12 7t-1.412.588T10 9t.588 1.413T12 11";
export const ROUTE_ICON_VIEWBOX = "5 2 14 20";
export const COW_ICON_PATH =
"M634 276.8l-9.999-13.88L624 185.7c0-11.88-12.5-19.49-23.12-14.11c-10.88 5.375-19.5 13.5-26.38 23l-65.75-90.92C490.6 78.71 461.8 64 431 64H112C63.37 64 24 103.4 24 152v86.38C9.5 250.1 0 267.9 0 288v32h8c35.38 0 64-28.62 64-64L72 152c0-16.88 10.5-31.12 25.38-37C96.5 119.1 96 123.5 96 128l.0002 304c0 8.875 7.126 16 16 16h63.1c8.875 0 16-7.125 16-16l.0006-112c9.375 9.375 20.25 16.5 32 21.88V368c0 8.875 7.252 16 16 16c8.875 0 15.1-7.125 15.1-16v-17.25c9.125 1 12.88 2.25 32-.125V368c0 8.875 7.25 16 16 16c8.875 0 16-7.125 16-16v-26.12C331.8 336.5 342.6 329.2 352 320l-.0012 112c0 8.875 7.125 16 15.1 16h64c8.75 0 16-7.125 16-16V256l31.1 32l.0006 41.55c0 12.62 3.752 24.95 10.75 35.45l41.25 62C540.8 440.1 555.5 448 571.4 448c22.5 0 41.88-15.88 46.25-38l21.75-108.6C641.1 292.8 639.1 283.9 634 276.8zM377.3 167.4l-22.88 22.75C332.5 211.8 302.9 224 272.1 224S211.5 211.8 189.6 190.1L166.8 167.4C151 151.8 164.4 128 188.9 128h166.2C379.6 128 393 151.8 377.3 167.4zM576 352c-8.875 0-16-7.125-16-16s7.125-16 16-16s16 7.125 16 16S584.9 352 576 352z";
export function routeIconSvg() {
return `<svg viewBox="${ROUTE_ICON_VIEWBOX}" fill="currentColor" aria-hidden="true"><path d="${ROUTE_ICON_PATH}"/></svg>`;
}
export function cowIconSvg() {
return `<svg viewBox="0 0 640 512" fill="currentColor" aria-hidden="true" focusable="false"><path d="${COW_ICON_PATH}"/></svg>`;
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Общий навбар операторских страниц.
* <div id="wespZootechNavMount" data-active-page="recipes"></div>
*/
(function () {
/* Один файл — одинаковый слот в светлой и тёмной теме (logo.png другой aspect ratio). */
const LOGO_LIGHT = "/static/logo2.png";
const LOGO_DARK = "/static/logo2.png";
function brandLogoHtml() {
return `<img src="${LOGO_LIGHT}" alt="Комптон" class="logo logo--light-theme">
<img src="${LOGO_DARK}" alt="" class="logo logo--dark-theme" aria-hidden="true">`;
}
const PAGES = [
{ id: "recipes", href: "/recipes", icon: "fa-utensils", label: "Рецепты" },
{ id: "lab", href: "/lab", icon: "fa-flask", label: "Lab" },
{ id: "feed_dispensers", href: "/feed_dispensers", icon: "fa-server", label: "Оборудование" },
{ id: "components", href: "/components", icon: "fa-box", label: "Компоненты" },
{ id: "reports", href: "/reports", icon: "fa-chart-bar", label: "Отчеты" },
{ id: "feed_consumption", href: "/feed_consumption", icon: "fa-chart-line", label: "Учёт кормов" },
];
function navItem(page, active, mobile) {
const cls = mobile
? `mobile-menu-item${active ? " active" : ""}`
: `menu-btn${active ? " active" : ""}`;
const tag = mobile ? "a" : "a";
if (page.id === "settings") {
return `<${tag} href="javascript:void(0)" data-action="open-settings" onclick="typeof openSettings==='function'&&openSettings(event)" class="${cls}">
<i class="fas fa-cog"></i><span>Настройки</span></${tag}>`;
}
return `<${tag} href="${page.href}" class="${cls}">
<i class="fas ${page.icon}"></i><span>${page.label}</span></${tag}>`;
}
function pagesForNav(canLab) {
if (canLab) return PAGES;
return PAGES.filter((p) => p.id !== "lab");
}
function renderNav(activeId, pages) {
const desktop = pages.map((p) => navItem(p, p.id === activeId, false)).join("");
const mobile = pages.map((p) => navItem(p, p.id === activeId, true)).join("");
const settingsDesktop = navItem({ id: "settings" }, false, false);
const settingsMobile = navItem({ id: "settings" }, false, true);
return `<nav class="mobile-navbar">
<div class="container">
<div class="nav-content">
<div class="nav-brand">
<a href="/recipes" class="nav-logo-link" aria-label="Рецепты">
${brandLogoHtml()}
</a>
</div>
<div class="hamburger-menu" id="hamburgerMenu" role="button" aria-label="Меню" tabindex="0">
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
</div>
<div class="desktop-menu">${desktop}${settingsDesktop}</div>
<div class="user-section">
<span id="userInfo" class="user-info"><i class="fas fa-user"></i> <span id="userLogin"></span></span>
<a href="#" data-action="logout" onclick="typeof logout==='function'&&logout();return false;" class="logout-btn">
<i class="fas fa-sign-out-alt"></i><span>Выход</span>
</a>
</div>
</div>
</div>
<div class="mobile-menu-overlay" id="mobileMenuOverlay">
<div class="mobile-menu">
<div class="mobile-menu-header">
<div class="mobile-brand">
<a href="/recipes" class="nav-logo-link" aria-label="Рецепты">
${brandLogoHtml()}
</a>
</div>
<div class="close-menu" id="closeMenu" role="button" aria-label="Закрыть" tabindex="0">
<i class="fas fa-times"></i>
</div>
</div>
<div class="mobile-menu-items">${mobile}${settingsMobile}</div>
<div class="mobile-menu-footer">
<div class="mobile-user-info" id="mobileUserInfo"></div>
<a href="#" data-action="logout" onclick="typeof logout==='function'&&logout();return false;" class="mobile-logout-btn">
<i class="fas fa-sign-out-alt"></i><span>Выход</span>
</a>
</div>
</div>
</div>
</nav>`;
}
function wireMobileMenu() {
const hamburgerMenu = document.getElementById("hamburgerMenu");
const mobileMenuOverlay = document.getElementById("mobileMenuOverlay");
const closeMenu = document.getElementById("closeMenu");
if (!hamburgerMenu || !mobileMenuOverlay) return;
const open = () => {
mobileMenuOverlay.classList.add("active");
hamburgerMenu.classList.add("active");
};
const close = () => {
mobileMenuOverlay.classList.remove("active");
hamburgerMenu.classList.remove("active");
};
hamburgerMenu.addEventListener("click", open);
closeMenu?.addEventListener("click", close);
mobileMenuOverlay.addEventListener("click", (e) => {
if (e.target === mobileMenuOverlay) close();
});
}
async function fetchCanLab() {
try {
const response = await fetch("/api/auth/check");
const data = await response.json();
if (data.status === "success" && data.authenticated) {
return Boolean(data.can_lab);
}
} catch (error) {
console.error("Ошибка проверки доступа к Lab:", error);
}
return false;
}
async function mount() {
const mountEl = document.getElementById("wespZootechNavMount");
if (!mountEl) return;
const active = mountEl.getAttribute("data-active-page") || "";
const canLab = await fetchCanLab();
window.WESP_CAN_LAB = canLab;
mountEl.outerHTML = renderNav(active, pagesForNav(canLab));
wireMobileMenu();
window.WespPageEnter?.init?.();
window.WespLabAccess?.apply?.(canLab);
}
window.WespZootechNav = { remount: mount };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount);
} else {
mount();
}
})();
@@ -0,0 +1,835 @@
/**
* Hub «К»: уведомления и план на день. FAB «К», модалка, badge.
*/
(function (global) {
const MODAL_ID = "zootechNotificationCenterModal";
const FAB_CLASS = "wesp-zt-notify-fab";
const POLL_MS = 60000;
const SCROLL_LOAD_THRESHOLD = 80;
let fabEl = null;
let badgeEl = null;
let modalEl = null;
let listEl = null;
let detailEl = null;
let hubEl = null;
let planMountEl = null;
let multiserverMountEl = null;
let titleEl = null;
let readAllBtn = null;
let backBtn = null;
let normsBtn = null;
let pollTimer = null;
let itemsCache = [];
let currentView = "hub";
let dailyPlanPanel = null;
let toolbarEl = null;
let scrollRootEl = null;
let categoryFilter = "all";
let sortMode = "newest";
let unreadOnly = false;
let prevDate = null;
let loadingMore = false;
let listLoading = false;
let scrollWired = false;
const STORAGE_KEY = "wesp-notify-prefs";
const CATEGORY_LABELS = {
daily_plan: "План на день",
recipe: "Рейсы",
sync: "Синхронизация",
feed_quality: "Отклонения",
component: "Компоненты",
sklad: "Склад",
equipment: "Оборудование",
report: "Отчёты",
general: "Общее",
};
const CATEGORY_FILTERS = [
{ id: "all", label: "Все" },
{ id: "daily_plan", label: "План" },
{ id: "recipe", label: "Рейсы" },
{ id: "sync", label: "Синхр." },
{ id: "feed_quality", label: "Отклон." },
{ id: "component", label: "Компон." },
{ id: "sklad", label: "Склад" },
{ id: "equipment", label: "Оборуд." },
];
const KIND_ORDER = { error: 0, warning: 1, info: 2, success: 3 };
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function resolveNotificationHref(item) {
if (!item || !item.linkKind) return null;
if (item.linkKind === "component") return "/components";
if (item.linkKind === "sklad") return "/feed_consumption";
if (item.linkKind === "equipment") return "/feed_dispensers";
if (!item.linkId) return null;
const id = encodeURIComponent(item.linkId);
if (item.linkKind === "recipe") return `/recipes?recipe=${id}`;
if (item.linkKind === "report_loading") return `/reports?report=${id}`;
if (item.linkKind === "daily_plan") return `/recipes?dailyPlan=${id}`;
return null;
}
async function markReadQuiet(id) {
const item = itemsCache.find((x) => x.id === id);
if (!item || item.read) return;
try {
await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: "PATCH" });
item.read = true;
await refreshBadge();
} catch {
/* ignore */
}
}
async function openNotificationTarget(item) {
if (isDailyPlanItem(item)) {
await markReadQuiet(item.id);
openDailyPlan();
return;
}
const href = resolveNotificationHref(item);
if (!href) {
await showDetail(item.id);
return;
}
await markReadQuiet(item.id);
if (global.bootstrap?.Modal) {
const inst = global.bootstrap.Modal.getInstance(modalEl);
inst?.hide();
}
global.location.href = href;
}
function relativeTime(iso) {
if (!iso) return "";
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const min = Math.floor(diff / 60000);
if (min < 1) return "только что";
if (min < 60) return `${min} мин назад`;
const hrs = Math.floor(min / 60);
if (hrs < 24) return `${hrs} ч назад`;
const days = Math.floor(hrs / 24);
return `${days} дн назад`;
}
function loadPrefs() {
try {
const raw = global.sessionStorage?.getItem(STORAGE_KEY);
if (!raw) return;
const prefs = JSON.parse(raw);
if (prefs.categoryFilter) categoryFilter = prefs.categoryFilter;
if (prefs.sortMode) sortMode = prefs.sortMode;
if (typeof prefs.unreadOnly === "boolean") unreadOnly = prefs.unreadOnly;
} catch {
/* ignore */
}
}
function savePrefs() {
try {
global.sessionStorage?.setItem(
STORAGE_KEY,
JSON.stringify({ categoryFilter, sortMode, unreadOnly })
);
} catch {
/* ignore */
}
}
function itemTimestamp(item) {
return item?.createdAt ? new Date(item.createdAt).getTime() : 0;
}
function sortItems(items) {
const list = [...items];
if (sortMode === "oldest") {
return list.sort((a, b) => itemTimestamp(a) - itemTimestamp(b));
}
if (sortMode === "unread_first") {
return list.sort((a, b) => {
const ra = a.read ? 1 : 0;
const rb = b.read ? 1 : 0;
if (ra !== rb) return ra - rb;
return itemTimestamp(b) - itemTimestamp(a);
});
}
if (sortMode === "severity") {
return list.sort((a, b) => {
const ka = KIND_ORDER[a.kind] ?? 9;
const kb = KIND_ORDER[b.kind] ?? 9;
if (ka !== kb) return ka - kb;
return itemTimestamp(b) - itemTimestamp(a);
});
}
return list.sort((a, b) => itemTimestamp(b) - itemTimestamp(a));
}
function formatDayLabel(isoDate) {
if (!isoDate) return "Ранее";
const parts = String(isoDate).slice(0, 10).split("-");
if (parts.length !== 3) return isoDate;
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
const now = new Date();
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const startThat = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diffDays = Math.round((startToday - startThat) / 86400000);
if (diffDays === 0) return "Сегодня";
if (diffDays === 1) return "Вчера";
return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" });
}
function dayKeyFromItem(item) {
if (!item?.createdAt) return "";
const d = new Date(item.createdAt);
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 inferCategory(item) {
const raw = item?.category || "general";
if (raw === "daily_plan") return "daily_plan";
if (item?.linkKind === "daily_plan" || item?.page === "daily_plan") return "daily_plan";
const text = `${item?.title || ""} ${item?.detail || ""}`.toLowerCase();
if (
text.includes("из плана") ||
text.includes("в плане") ||
text.includes("замен") ||
text.includes("исключ") ||
text.includes("снова в план") ||
text.includes("план на день")
) {
return "daily_plan";
}
return raw;
}
function isDailyPlanItem(item) {
return inferCategory(item) === "daily_plan";
}
function categoryLabel(item) {
return CATEGORY_LABELS[inferCategory(item)] || CATEGORY_LABELS.general;
}
function buildListUrl(date) {
const params = new URLSearchParams({ sort: sortMode });
if (date) params.set("date", date);
if (categoryFilter && categoryFilter !== "all") params.set("category", categoryFilter);
if (unreadOnly) params.set("unread", "1");
return `/api/notifications?${params.toString()}`;
}
function ensureFab() {
if (fabEl && fabEl.isConnected) return fabEl;
fabEl = document.querySelector(`.${FAB_CLASS}`);
if (!fabEl) {
fabEl = document.createElement("button");
fabEl.type = "button";
fabEl.className = FAB_CLASS;
fabEl.setAttribute("data-action", "open-notification-center");
fabEl.setAttribute("aria-label", "Центр «К»");
fabEl.innerHTML =
'<span class="wesp-zt-notify-fab__glyph" aria-hidden="true">К</span>' +
'<span class="wesp-zt-notify-fab__badge" hidden aria-hidden="true"></span>';
document.body.appendChild(fabEl);
}
badgeEl = fabEl.querySelector(".wesp-zt-notify-fab__badge");
fabEl.addEventListener("click", openCenter);
return fabEl;
}
function categoryFilterButtonsHtml() {
return CATEGORY_FILTERS.map(
(f) =>
`<button type="button" class="zt-tab${f.id === categoryFilter ? " active" : ""}" ` +
`data-notify-category="${escapeHtml(f.id)}">${escapeHtml(f.label)}</button>`
).join("");
}
function modalTemplate() {
return (
`<div class="modal wesp-shell-modal" id="${MODAL_ID}" tabindex="-1" aria-hidden="true">` +
'<div class="modal-dialog modal-dialog--shell modal-dialog--shell-wide">' +
'<div class="modal-content wesp-shell-bsmodal border-0 shadow-none zt-k-hub-modal">' +
'<div class="zt-k-hub-modal__header">' +
'<div class="zt-k-hub-modal__header-left">' +
'<button type="button" class="zt-k-hub-modal__back" data-action="k-hub-back" hidden>&larr; Назад</button>' +
'<h2 class="wesp-shell-bsmodal-title zt-k-hub-modal__title" data-k-hub-title>Комптон - хаб</h2>' +
"</div>" +
'<div class="zt-k-hub-modal__header-right">' +
'<button type="button" class="btn btn-sm btn-outline-primary zt-k-hub-plan-norms-btn" data-action="k-hub-open-component-norms" hidden title="Изменить СВ% компонентов только в плане на день — рецепты в справочнике не меняются">Коррекция СВ%</button>' +
'<button type="button" class="zt-notify-center__read-all" data-action="notify-read-all" hidden>Прочитать все</button>' +
'<button type="button" class="wesp-shell-bsmodal-close" data-bs-dismiss="modal" aria-label="Закрыть">×</button>' +
"</div></div>" +
'<div class="zt-k-hub-modal__body">' +
'<div class="zt-k-hub" data-k-hub-panel></div>' +
'<div class="zt-notify-center__notify" data-k-notify-panel hidden>' +
'<div class="zt-notify-toolbar" data-notify-toolbar>' +
'<div class="zt-notify-toolbar__filters zt-tabs" role="tablist" data-notify-categories>' +
categoryFilterButtonsHtml() +
"</div>" +
'<div class="zt-notify-toolbar__row">' +
'<label class="zt-notify-toolbar__unread">' +
'<input type="checkbox" data-notify-unread-only> Только непрочит.' +
"</label>" +
'<label class="zt-notify-toolbar__sort">' +
'<span class="visually-hidden">Сортировка</span>' +
'<select data-notify-sort aria-label="Сортировка">' +
'<option value="newest">Сначала новые</option>' +
'<option value="oldest">Сначала старые</option>' +
'<option value="unread_first">Непрочит. сверху</option>' +
'<option value="severity">По важности</option>' +
"</select></label></div></div>" +
'<div class="zt-notify-list" data-notify-list></div>' +
'<div class="zt-notify-load-more" data-notify-load-more hidden></div>' +
'<div class="zt-notify-detail" data-notify-detail hidden></div>' +
"</div>" +
'<div class="zt-k-hub-plan-mount" data-k-plan-panel hidden></div>' +
'<div class="zt-k-hub-multiserver-mount" data-k-multiserver-panel hidden></div>' +
"</div></div></div></div>"
);
}
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);
}
hubEl = modalEl.querySelector("[data-k-hub-panel]");
listEl = modalEl.querySelector("[data-notify-list]");
detailEl = modalEl.querySelector("[data-notify-detail]");
toolbarEl = modalEl.querySelector("[data-notify-toolbar]");
scrollRootEl = modalEl.querySelector(".zt-k-hub-modal__body");
planMountEl = modalEl.querySelector("[data-k-plan-panel]");
multiserverMountEl = modalEl.querySelector("[data-k-multiserver-panel]");
titleEl = modalEl.querySelector("[data-k-hub-title]");
readAllBtn = modalEl.querySelector("[data-action='notify-read-all']");
backBtn = modalEl.querySelector("[data-action='k-hub-back']");
normsBtn = modalEl.querySelector("[data-action='k-hub-open-component-norms']");
global.WespZootechModals?.wireAllShellModals?.();
readAllBtn?.addEventListener("click", readAll);
backBtn?.addEventListener("click", () => showHub());
normsBtn?.addEventListener("click", () => {
global.openDailyPlanComponentNormsModal?.();
});
wireNotifyToolbar();
wireScrollLoad();
hubEl?.querySelectorAll("[data-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.getAttribute("data-action");
if (action === "hub-open-notifications") openNotifications();
if (action === "hub-open-daily-plan") openDailyPlan();
if (action === "hub-open-multiserver") openMultiserver();
});
});
wireModalLifecycle();
renderHubCards();
return modalEl;
}
function syncToolbarUi() {
if (!toolbarEl) return;
toolbarEl.querySelectorAll("[data-notify-category]").forEach((btn) => {
const cat = btn.getAttribute("data-notify-category") || "all";
btn.classList.toggle("active", cat === categoryFilter);
});
const sortSelect = toolbarEl.querySelector("[data-notify-sort]");
if (sortSelect) sortSelect.value = sortMode;
const unreadCb = toolbarEl.querySelector("[data-notify-unread-only]");
if (unreadCb) unreadCb.checked = unreadOnly;
}
function wireNotifyToolbar() {
if (!toolbarEl || toolbarEl.dataset.wired === "1") return;
toolbarEl.dataset.wired = "1";
loadPrefs();
syncToolbarUi();
toolbarEl.querySelectorAll("[data-notify-category]").forEach((btn) => {
btn.addEventListener("click", () => {
categoryFilter = btn.getAttribute("data-notify-category") || "all";
savePrefs();
syncToolbarUi();
reloadNotifications();
});
});
toolbarEl.querySelector("[data-notify-sort]")?.addEventListener("change", (ev) => {
sortMode = ev.target.value || "newest";
savePrefs();
reloadNotifications();
});
toolbarEl.querySelector("[data-notify-unread-only]")?.addEventListener("change", (ev) => {
unreadOnly = Boolean(ev.target.checked);
savePrefs();
reloadNotifications();
});
}
function wireScrollLoad() {
if (scrollWired || !scrollRootEl) return;
scrollWired = true;
scrollRootEl.addEventListener("scroll", () => {
if (currentView !== "notifications" || !prevDate || loadingMore || listLoading) return;
const nearBottom =
scrollRootEl.scrollTop + scrollRootEl.clientHeight >=
scrollRootEl.scrollHeight - SCROLL_LOAD_THRESHOLD;
if (nearBottom) loadMoreNotifications();
});
}
function itemPreview(item) {
const detail = String(item?.detail || "").trim();
if (detail) {
const cut = detail.replace(/\.\s+\d{1,2}\s+\S+\s+\d{4}.*$/u, "").trim();
const text = cut || detail;
return text.length > 140 ? `${text.slice(0, 137)}` : text;
}
return item?.title || "";
}
function renderNotifyItem(item) {
const unread = !item.read ? " zt-notify-item--unread" : "";
const cat = categoryLabel(item);
const preview = itemPreview(item);
const showPreview = preview && preview !== item.title;
return (
`<button type="button" class="zt-notify-item${unread}" data-notify-id="${escapeHtml(item.id)}">` +
`<span class="zt-notify-item__dot zt-notify-item__dot--${escapeHtml(item.kind || "info")}"></span>` +
`<span class="zt-notify-item__main">` +
`<span class="zt-notify-item__title">${escapeHtml(item.title)}</span>` +
(showPreview
? `<span class="zt-notify-item__preview">${escapeHtml(preview)}</span>`
: "") +
`<span class="zt-notify-item__meta">` +
`<span class="zt-notify-item__category">${escapeHtml(cat)}</span>` +
`<span class="zt-notify-item__time">${escapeHtml(relativeTime(item.createdAt))}</span>` +
"</span></span></button>"
);
}
function renderLoadMoreState() {
const el = modalEl?.querySelector("[data-notify-load-more]");
if (!el) return;
if (loadingMore) {
el.hidden = false;
el.innerHTML =
'<div class="zt-notify-load-more__spinner"><div class="zt-spinner" role="status"></div>' +
'<span>Загрузка…</span></div>';
return;
}
if (prevDate) {
el.hidden = false;
el.innerHTML =
`<p class="zt-notify-load-more__hint">Прокрутите вниз — загрузятся уведомления за ${escapeHtml(formatDayLabel(prevDate))}</p>`;
return;
}
el.hidden = true;
el.innerHTML = "";
}
function renderHubCards() {
if (!hubEl) return;
hubEl.innerHTML =
'<div class="zt-k-hub__grid">' +
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-notifications">' +
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-bell"></i></span>' +
'<span class="zt-k-hub-card__text">' +
'<span class="zt-k-hub-card__title">Уведомления</span>' +
'<span class="zt-k-hub-card__desc">План, рейсы, синхронизация и отклонения</span>' +
"</span></button>" +
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-daily-plan">' +
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-calendar-day"></i></span>' +
'<span class="zt-k-hub-card__text">' +
'<span class="zt-k-hub-card__title">План на день</span>' +
'<span class="zt-k-hub-card__desc">Рейсы, ингредиенты и выгрузка по периодам</span>' +
"</span></button>" +
'<button type="button" class="zt-k-hub-card zt-card zt-card--interactive" data-action="hub-open-multiserver">' +
'<span class="zt-k-hub-card__icon" aria-hidden="true"><i class="fas fa-server"></i></span>' +
'<span class="zt-k-hub-card__text">' +
'<span class="zt-k-hub-card__title">Мультисервер</span>' +
'<span class="zt-k-hub-card__desc">Конфликты синхронизации оркестр ↔ фермы</span>' +
"</span></button></div>";
hubEl.querySelectorAll("[data-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.getAttribute("data-action");
if (action === "hub-open-notifications") openNotifications();
if (action === "hub-open-daily-plan") openDailyPlan();
if (action === "hub-open-multiserver") openMultiserver();
});
});
}
function applyModalLayout(view) {
if (!modalEl) return;
const dialog = modalEl.querySelector(".modal-dialog");
const body = modalEl.querySelector(".zt-k-hub-modal__body");
modalEl.classList.remove("zt-k-hub--plan", "zt-k-hub--notify");
body?.classList.remove("zt-k-hub-modal__body--plan");
dialog?.classList.remove("modal-dialog--shell-plan", "modal-dialog--shell-notify");
if (view === "daily-plan") {
modalEl.classList.add("zt-k-hub--plan");
body?.classList.add("zt-k-hub-modal__body--plan");
dialog?.classList.add("modal-dialog--shell-plan");
} else if (view === "notifications") {
modalEl.classList.add("zt-k-hub--notify");
dialog?.classList.add("modal-dialog--shell-notify");
}
}
function updateHeader(view) {
currentView = view;
applyModalLayout(view);
if (!titleEl || !backBtn || !readAllBtn) return;
if (normsBtn) normsBtn.hidden = view !== "daily-plan";
if (view === "hub") {
titleEl.textContent = "Комптон - хаб";
backBtn.hidden = true;
readAllBtn.hidden = true;
} else if (view === "notifications") {
titleEl.textContent = "Уведомления";
backBtn.hidden = false;
readAllBtn.hidden = false;
} else if (view === "daily-plan") {
titleEl.textContent = "План на день";
backBtn.hidden = false;
readAllBtn.hidden = true;
} else if (view === "multiserver") {
titleEl.textContent = "Мультисервер";
backBtn.hidden = false;
readAllBtn.hidden = true;
}
}
function dismissPlanOverlays() {
modalEl?.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => el.remove());
}
function destroyDailyPlanPanel() {
dismissPlanOverlays();
global.closeDailyPlanComponentNormsModal?.();
dailyPlanPanel?.destroy?.();
dailyPlanPanel = null;
global.WespDailyPlanPanel?._setActivePanel?.(null);
}
function wireModalLifecycle() {
ensureModal();
if (modalEl.dataset.kHubLifecycleWired === "1") return;
modalEl.dataset.kHubLifecycleWired = "1";
modalEl.addEventListener("hide.bs.modal", dismissPlanOverlays);
modalEl.addEventListener("hidden.bs.modal", destroyDailyPlanPanel);
}
function showHub() {
ensureModal();
updateHeader("hub");
hubEl.hidden = false;
modalEl.querySelector("[data-k-notify-panel]").hidden = true;
planMountEl.hidden = true;
if (multiserverMountEl) multiserverMountEl.hidden = true;
destroyDailyPlanPanel();
listEl.hidden = false;
detailEl.hidden = true;
}
function setBadge(unreadCount) {
ensureFab();
if (!badgeEl) return;
const count = Number(unreadCount) || 0;
if (count <= 0) {
badgeEl.hidden = true;
badgeEl.textContent = "";
fabEl.classList.remove("has-unread");
return;
}
badgeEl.hidden = false;
badgeEl.textContent = count > 9 ? "9+" : String(count);
fabEl.classList.add("has-unread");
}
async function refreshBadge() {
try {
const resp = await fetch("/api/notifications?summary=1");
if (!resp.ok) return;
const data = await resp.json();
setBadge(data.unreadCount);
} catch {
/* ignore */
}
}
function mergeItems(existing, incoming) {
const map = new Map(existing.map((x) => [x.id, x]));
incoming.forEach((item) => map.set(item.id, item));
return Array.from(map.values());
}
function matchesCategoryFilter(item) {
if (!categoryFilter || categoryFilter === "all") return true;
return inferCategory(item) === categoryFilter;
}
function filterVisibleItems(items) {
let list = items.filter(matchesCategoryFilter);
if (unreadOnly) list = list.filter((x) => !x.read);
return list;
}
async function fetchDay(date) {
const resp = await fetch(buildListUrl(date));
if (!resp.ok) throw new Error("Не удалось загрузить уведомления");
return resp.json();
}
async function loadInitialNotifications() {
let date = null;
let found = [];
for (let step = 0; step < 31; step += 1) {
const data = await fetchDay(date);
setBadge(data.unreadCount);
if (data.items?.length) {
found = mergeItems(found, data.items);
prevDate = data.hasMore ? data.prevDate : null;
return found;
}
if (!data.hasMore || !data.prevDate) {
prevDate = null;
return found;
}
date = data.prevDate;
}
prevDate = null;
return found;
}
async function reloadNotifications() {
listLoading = true;
itemsCache = [];
prevDate = null;
detailEl.hidden = true;
listEl.hidden = false;
listEl.innerHTML =
'<div class="zt-notify-empty zt-notify-empty--loading">' +
'<div class="zt-spinner" role="status"></div><p>Загрузка…</p></div>';
renderLoadMoreState();
try {
itemsCache = await loadInitialNotifications();
renderList();
} catch {
itemsCache = [];
prevDate = null;
renderList();
} finally {
listLoading = false;
renderLoadMoreState();
}
}
async function loadMoreNotifications() {
if (!prevDate || loadingMore || listLoading) return;
loadingMore = true;
renderLoadMoreState();
const loadDate = prevDate;
try {
const data = await fetchDay(loadDate);
itemsCache = mergeItems(itemsCache, data.items || []);
prevDate = data.hasMore ? data.prevDate : null;
setBadge(data.unreadCount);
renderList();
} catch {
/* keep current list */
} finally {
loadingMore = false;
renderLoadMoreState();
}
}
function wireListClicks() {
listEl.querySelectorAll("[data-notify-id]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-notify-id");
if (id) showDetail(id);
});
});
}
function renderList() {
ensureModal();
wireNotifyToolbar();
syncToolbarUi();
detailEl.hidden = true;
listEl.hidden = false;
const sorted = sortItems(filterVisibleItems(itemsCache));
if (!sorted.length) {
const hint =
unreadOnly || categoryFilter !== "all"
? prevDate
? "За сегодня ничего нет — прокрутите вниз для более ранних"
: "Нет уведомлений по выбранному фильтру"
: "Нет уведомлений";
listEl.innerHTML =
`<div class="zt-notify-empty"><i class="fas fa-bell-slash"></i><p>${escapeHtml(hint)}</p></div>`;
renderLoadMoreState();
return;
}
let html = "";
let lastGroup = null;
sorted.forEach((item) => {
const group = dayKeyFromItem(item);
if (group !== lastGroup) {
html += `<div class="zt-notify-group">${escapeHtml(formatDayLabel(group))}</div>`;
lastGroup = group;
}
html += renderNotifyItem(item);
});
listEl.innerHTML = html;
wireListClicks();
renderLoadMoreState();
}
async function showDetail(id) {
const item = itemsCache.find((x) => x.id === id);
if (!item) return;
ensureModal();
listEl.hidden = true;
modalEl.querySelector("[data-notify-load-more]")?.setAttribute("hidden", "");
detailEl.hidden = false;
detailEl.innerHTML =
`<button type="button" class="zt-notify-detail__back" data-action="notify-back">&larr; Назад</button>` +
`<p class="zt-notify-detail__kind zt-notify-detail__kind--${escapeHtml(item.kind)}">${escapeHtml(item.title)}</p>` +
`<p class="zt-notify-detail__meta">${escapeHtml(categoryLabel(item))}</p>` +
`<p class="zt-notify-detail__text">${escapeHtml(item.detail)}</p>` +
`<p class="zt-notify-detail__when">${escapeHtml(relativeTime(item.createdAt))}</p>` +
(resolveNotificationHref(item) || isDailyPlanItem(item)
? `<button type="button" class="btn btn-primary btn-sm mt-3" data-action="notify-open-target">Открыть план</button>`
: "");
detailEl.querySelector("[data-action='notify-open-target']")?.addEventListener("click", () => {
openNotificationTarget(item);
});
detailEl.querySelector("[data-action='notify-back']")?.addEventListener("click", () => {
renderList();
});
if (!item.read) {
try {
await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: "PATCH" });
item.read = true;
await refreshBadge();
} catch {
/* ignore */
}
}
}
async function readAll() {
try {
await fetch("/api/notifications/read-all", { method: "PATCH" });
await reloadNotifications();
} catch {
/* ignore */
}
}
function showModal() {
if (global.WespZootechModals?.show) {
global.WespZootechModals.show(MODAL_ID);
} else if (global.bootstrap?.Modal) {
global.bootstrap.Modal.getOrCreateInstance(modalEl).show();
}
}
async function openCenter() {
ensureModal();
showHub();
showModal();
}
async function openNotifications() {
ensureModal();
updateHeader("notifications");
hubEl.hidden = true;
modalEl.querySelector("[data-k-notify-panel]").hidden = false;
planMountEl.hidden = true;
destroyDailyPlanPanel();
await reloadNotifications();
showModal();
}
function openDailyPlan() {
ensureModal();
updateHeader("daily-plan");
hubEl.hidden = true;
modalEl.querySelector("[data-k-notify-panel]").hidden = true;
planMountEl.hidden = false;
destroyDailyPlanPanel();
dailyPlanPanel = global.WespDailyPlanPanel?.createDailyPlanPanel?.();
if (dailyPlanPanel && planMountEl) {
dailyPlanPanel.mount(planMountEl);
global.WespDailyPlanPanel?._setActivePanel?.(dailyPlanPanel);
} else if (planMountEl) {
planMountEl.innerHTML =
'<div class="zt-k-hub-plan__empty"><p>Модуль плана на день не загружен.</p></div>';
}
showModal();
}
function openMultiserver() {
ensureModal();
updateHeader("multiserver");
hubEl.hidden = true;
modalEl.querySelector("[data-k-notify-panel]").hidden = true;
planMountEl.hidden = true;
multiserverMountEl.hidden = false;
destroyDailyPlanPanel();
const enterpriseId = global.__WESP_ORCH__?.enterpriseId || "";
if (global.WespMultiserverPanel?.mount) {
global.WespMultiserverPanel.mount(multiserverMountEl, enterpriseId);
} else if (multiserverMountEl) {
multiserverMountEl.innerHTML =
'<div class="zt-k-hub-plan__empty"><p>Модуль мультисервера не загружен.</p></div>';
}
showModal();
}
function init() {
if (!document.body.classList.contains("zootech-app-body")) return;
loadPrefs();
ensureFab();
ensureModal();
refreshBadge();
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(refreshBadge, POLL_MS);
}
global.WespZootechNotificationCenter = {
init,
openCenter,
openNotifications,
openDailyPlan,
openMultiserver,
refreshBadge,
setBadge,
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,148 @@
/**
* Человекочитаемые title/detail для центра уведомлений.
*/
(function (global) {
const MONTHS = [
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
];
function formatWhen(date) {
const d = date instanceof Date ? date : new Date();
const day = d.getDate();
const month = MONTHS[d.getMonth()];
const year = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${day} ${month} ${year}, ${hh}:${mm}`;
}
function normalizeTitle(text) {
const t = String(text || "").trim();
if (/^Сохранено\b/i.test(t)) return "Сохранено";
return t || "Уведомление";
}
function detectCategory(page, text, record) {
if (record.category) return record.category;
if (page === "recipes") return "recipe";
if (page === "components") return "component";
if (page === "feed_consumption") return "sklad";
if (page === "feed_dispensers") return "equipment";
if (page === "reports") return "report";
if (record.category === "feed_quality") return "feed_quality";
if (record.category === "daily_plan") return "daily_plan";
if (page === "daily_plan") return "daily_plan";
const lower = text.toLowerCase();
if (
lower.includes("план на день") ||
lower.includes("из плана") ||
lower.includes("в плане") ||
lower.includes("замен")
) {
return "daily_plan";
}
if (lower.includes("рейс") || lower.includes("рецепт")) return "recipe";
if (lower.includes("компонент")) return "component";
if (lower.includes("склад") || lower.includes("остат")) return "sklad";
if (lower.includes("синхрон")) return "sync";
if (lower.includes("перегруз") || lower.includes("недогруз") || lower.includes("миксер") || lower.includes("смешиван")) return "feed_quality";
return "general";
}
function resolvePage(explicit) {
if (explicit) return explicit;
const path = (global.location && global.location.pathname) || "";
if (path.startsWith("/recipes")) return "recipes";
if (path.startsWith("/components")) return "components";
if (path.startsWith("/feed_dispensers")) return "feed_dispensers";
if (path.startsWith("/reports")) return "reports";
if (path.startsWith("/feed_consumption")) return "feed_consumption";
return "general";
}
function q(name) {
return name ? `«${name}»` : "";
}
function buildNotificationRecord(kind, plainText, opts) {
const options = opts && typeof opts === "object" ? opts : {};
const record = options.record && typeof options.record === "object" ? options.record : {};
const when = formatWhen();
const page = resolvePage(record.page || options.page);
const text = String(plainText || "").trim();
let title = record.title || normalizeTitle(text);
let detail = record.detail || "";
if (!detail) {
const recipe = record.recipeName || record.recipe;
const component = record.componentName || record.component;
const period = record.periodName || record.period;
if (/^Сохранено$/i.test(title) && recipe) {
detail = `Рейс ${q(recipe)} сохранён — ${when}`;
} else if (/рейс удал/i.test(text) && recipe && period) {
detail = `Рейс ${q(recipe)} удалён из периода ${q(period)}${when}`;
} else if (/рейс удал/i.test(text) && recipe) {
detail = `Рейс ${q(recipe)} удалён — ${when}`;
} else if (/рейс скопирован/i.test(text) && recipe) {
detail = `Рейс ${q(recipe)} скопирован — ${when}`;
} else if (/вставлен из буфера/i.test(text)) {
detail = `Рейс вставлен из буфера — ${when}`;
} else if (/перенес/i.test(text) && recipe && period) {
detail = `Рейс ${q(recipe)} перенесён в период ${q(period)}${when}`;
} else if (/компонент добавлен/i.test(text) && component) {
detail = `Компонент ${q(component)} добавлен — ${when}`;
} else if (/компонент обнов/i.test(text) && component) {
detail = `Компонент ${q(component)} обновлён — ${when}`;
} else if (/компонент удал/i.test(text) && component) {
detail = `Компонент ${q(component)} удалён — ${when}`;
} else if (/убрано со склада/i.test(text) && component) {
detail = `Компонент ${q(component)} убран со склада — ${when}`;
} else if (/на склад/i.test(text) && component) {
detail = `Компонент ${q(component)} добавлен на склад — ${when}`;
} else if (/настройки сохранены/i.test(text)) {
detail = `Настройки сохранены — ${when}`;
} else if (record.category === "feed_quality" || page === "reports" && /перегруз|недогруз|миксер|смешиван/i.test(text)) {
detail = record.detail || (text ? `${text}${when}` : `Отклонение по рейсу — ${when}`);
} else if (text) {
detail = `${text}${when}`;
} else {
detail = `Событие — ${when}`;
}
}
return {
title,
detail,
kind: kind || "info",
category: detectCategory(page, text, record),
page,
linkKind:
record.linkKind ||
(page === "components"
? "component"
: page === "feed_consumption"
? "sklad"
: page === "feed_dispensers"
? "equipment"
: null),
};
}
global.WespZootechNotificationMessages = {
formatWhen,
buildNotificationRecord,
resolvePage,
};
})(typeof window !== "undefined" ? window : globalThis);
@@ -0,0 +1,285 @@
/**
* Toast-уведомления зоотех-страниц (нижний pill-toast, стиль Яндекс Музыки).
*/
(function (global) {
const HOST_ID = "wespZtToastHost";
const DEFAULT_DURATION_MS = 4000;
const EXIT_MS = 220;
const UM = () => global.WespUserMessages || {};
const userFacingMessage = (message, fallback) => {
const api = UM();
if (typeof api.userFacingMessage === "function") {
return api.userFacingMessage(message, fallback);
}
const text = message == null ? "" : String(message).trim();
return text || fallback || "Не удалось выполнить операцию. Попробуйте ещё раз.";
};
let hostEl = null;
let activeToast = null;
let dismissTimer = null;
function ensureHost() {
if (hostEl && hostEl.isConnected) return hostEl;
hostEl = document.getElementById(HOST_ID);
if (!hostEl) {
hostEl = document.createElement("div");
hostEl.id = HOST_ID;
hostEl.className = "wesp-zt-toast-host";
hostEl.setAttribute("aria-live", "polite");
hostEl.setAttribute("aria-atomic", "true");
document.body.appendChild(hostEl);
}
return hostEl;
}
function clearDismissTimer() {
if (dismissTimer) {
clearTimeout(dismissTimer);
dismissTimer = null;
}
}
function normalizeType(type) {
if (type === "error" || type === "warning") return type;
return "success";
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function normalizeDisplayMessage(message) {
if (message && typeof message === "object") {
if (Array.isArray(message.parts)) return message;
if (message.text != null) message = message.text;
}
const text = message == null ? "" : String(message).trim();
if (/^Сохранено\b/i.test(text)) {
return "Сохранено";
}
return text;
}
function renderMessageContent(message, opts) {
if (message && typeof message === "object") {
if (Array.isArray(message.parts)) {
return message.parts
.map((part) => {
const text = escapeHtml(part && part.text != null ? part.text : "");
return part && part.bold ? `<strong>${text}</strong>` : text;
})
.join("");
}
if (message.text != null) {
return escapeHtml(message.text);
}
}
const text = message == null ? "" : String(message);
if (opts && Array.isArray(opts.parts)) {
return opts.parts
.map((part) => {
const chunk = escapeHtml(part && part.text != null ? part.text : "");
return part && part.bold ? `<strong>${chunk}</strong>` : chunk;
})
.join("");
}
return escapeHtml(text);
}
function removeToast(toastEl, immediate) {
if (!toastEl || !toastEl.isConnected) return Promise.resolve();
clearDismissTimer();
if (immediate) {
toastEl.remove();
if (activeToast === toastEl) activeToast = null;
return Promise.resolve();
}
return new Promise((resolve) => {
toastEl.classList.add("is-exiting");
const onEnd = () => {
toastEl.removeEventListener("animationend", onEnd);
toastEl.remove();
if (activeToast === toastEl) activeToast = null;
resolve();
};
toastEl.addEventListener("animationend", onEnd);
setTimeout(onEnd, EXIT_MS + 40);
});
}
function dismissAll() {
clearDismissTimer();
if (!activeToast) return;
const toast = activeToast;
activeToast = null;
return removeToast(toast, false);
}
function plainTextFromMessage(message) {
const api = UM();
if (typeof api.plainText === "function") return api.plainText(message);
if (message && typeof message === "object") {
if (message instanceof Error) return "";
if (Array.isArray(message.parts)) {
return message.parts.map((p) => (p && p.text != null ? String(p.text) : "")).join("");
}
if (message.text != null) return String(message.text);
}
return message == null ? "" : String(message);
}
function persistNotification(type, message, opts) {
if (opts && opts.skipRecord) return;
const plain = plainTextFromMessage(
message && typeof message === "object" && Array.isArray(message.parts)
? message
: normalizeDisplayMessage(message)
);
if (!plain.trim() && !(opts && opts.record)) return;
const builder = global.WespZootechNotificationMessages;
const payload = builder
? builder.buildNotificationRecord(type, plain, opts)
: {
title: plain.slice(0, 200) || "Уведомление",
detail: plain,
kind: type,
category: "general",
page: "general",
};
fetch("/api/notifications", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(() => {
global.WespZootechNotificationCenter?.refreshBadge?.();
})
.catch(() => {
/* fire-and-forget */
});
}
function show(type, message, opts) {
const normalizedType = normalizeType(type);
const duration =
opts && Number.isFinite(opts.duration) ? opts.duration : DEFAULT_DURATION_MS;
const displayMessage =
message && typeof message === "object" && Array.isArray(message.parts)
? message
: normalizeDisplayMessage(message);
const html = renderMessageContent(displayMessage, opts);
const plainText =
displayMessage && typeof displayMessage === "object"
? ""
: String(displayMessage || "");
const isMultiline = plainText.length > 42 || /\n/.test(plainText);
const run = async () => {
if (activeToast) {
await removeToast(activeToast, false);
}
const root = ensureHost();
const toast = document.createElement("div");
toast.className = `wesp-zt-toast wesp-zt-toast--${normalizedType}${
isMultiline ? " wesp-zt-toast--multiline" : ""
}`;
toast.setAttribute("role", "status");
const accent = document.createElement("span");
accent.className = "wesp-zt-toast__accent";
accent.setAttribute("aria-hidden", "true");
const messageEl = document.createElement("p");
messageEl.className = "wesp-zt-toast__message";
messageEl.innerHTML = html;
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "wesp-zt-toast__close";
closeBtn.setAttribute("aria-label", "Закрыть уведомление");
closeBtn.textContent = "×";
closeBtn.addEventListener("click", () => {
dismissAll();
});
toast.append(accent, messageEl, closeBtn);
root.appendChild(toast);
activeToast = toast;
if (duration > 0) {
dismissTimer = setTimeout(() => {
dismissAll();
}, duration);
}
};
persistNotification(normalizedType, displayMessage, opts);
run();
}
function success(message, opts) {
show("success", message, opts);
}
function error(message, opts) {
const safe = userFacingMessage(message, opts && opts.fallback);
show("error", safe, opts);
}
function warning(message, opts) {
const safe = userFacingMessage(message, opts && opts.fallback);
show("warning", safe, opts);
}
function open(payload) {
const data = payload && typeof payload === "object" ? payload : {};
show(data.type || "success", data.message, data);
}
function createAdapter() {
return {
success,
error,
warning,
open,
dismissAll,
};
}
global.WespZootechNotify = {
success,
error,
warning,
open,
dismissAll,
createAdapter,
userFacingMessage,
messageFromResponseBody: (body, fallback) => {
const api = UM();
if (typeof api.messageFromResponseBody === "function") {
return api.messageFromResponseBody(body, fallback);
}
return userFacingMessage(null, fallback);
},
isTechnicalMessage: (message) => {
const api = UM();
if (typeof api.isTechnicalMessage === "function") return api.isTechnicalMessage(message);
return false;
},
DEFAULT_ERROR_MESSAGE: UM().DEFAULT_ERROR_MESSAGE || "Не удалось выполнить операцию. Попробуйте ещё раз.",
get instance() {
return createAdapter();
},
};
})(typeof window !== "undefined" ? window : globalThis);