Files
site/apps/web/public/wesp/js/pages/recipes-data-controller.js
T

1063 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { buildRecipeRowsSkeletonHtml } from "../modules/recipes/recipe-skeleton.js";
import { cowIconSvg, routeIconSvg } from "../wesp-zootech-icons.js";
import {
peekPreloadedApiPayload,
clearPreloadedApiPayload,
clearRecipesBootOverlay,
PRELOAD_FEED_DISPENSERS_KEY,
PRELOAD_FEED_DISPENSERS_MAX_AGE_MS,
} from "../wesp-nav-preload.js";
export function createRecipesDataController({
notyf,
getSelectedDispenser,
setSelectedDispenser,
getSelectedPeriod,
setSelectedPeriod,
getSelectedRecipe,
setSelectedRecipe,
setCurrentDispenserType,
getCurrentDispenserType,
loadRecipeDetails,
showRecipeEdit,
updatePasteButtons,
}) {
/** Сравнение id (UUID/число) без гонок при переключении оборудования/периода */
function isSameId(a, b) {
return String(a ?? "") === String(b ?? "");
}
function recipeSkipBadgeHtml(label, title) {
return (
`<span class="recipe-skip-badge ms-2" title="${title}">` +
'<span class="recipe-skip-badge__dot" aria-hidden="true"></span>' +
`${label}</span>`
);
}
function recipeAdjustBadgeHtml(label, title) {
return (
`<span class="recipe-adjust-badge ms-2" title="${title}">` +
'<span class="recipe-row-adjust-dot" aria-hidden="true"></span>' +
`${label}</span>`
);
}
function recipeSkipRestoreBtn(action, recipeId, title) {
return (
`<button type="button" class="btn btn-sm btn-outline-success recipe-skip-restore-btn ms-1" ` +
`data-action="${action}" data-recipe-id="${recipeId}" ` +
`title="${title}"><i class="fas fa-undo me-1"></i>Вернуть</button>`
);
}
function dashboardSkipWarningHtml(title = "Сегодня есть отклонения от плана") {
return (
`<span class="dashboard-skip-warning ms-2" title="${title}" aria-label="${title}">` +
'<i class="fas fa-exclamation-triangle" aria-hidden="true"></i></span>'
);
}
function recipeSkipExtrasHtml(recipe) {
if (recipe.skippedToday) {
return (
recipeSkipBadgeHtml("сегодня не кормим", "Сегодня не кормим") +
recipeSkipRestoreBtn("unskip-recipe-today", recipe.id, "Вернуть рейс в план")
);
}
let html = "";
if (recipe.skippedIngredientToday) {
html +=
recipeSkipBadgeHtml("сегодня убрали ингредиент", "Сегодня убрали ингредиент") +
recipeSkipRestoreBtn("unskip-ingredient-parts-today", recipe.id, "Вернуть все компоненты в план");
}
if (recipe.skippedGroupToday) {
html +=
recipeSkipBadgeHtml("сегодня убрали группу", "Сегодня убрали группу") +
recipeSkipRestoreBtn("unskip-group-parts-today", recipe.id, "Вернуть все группы в план");
}
if (recipe.adjustedIngredientToday) {
html += recipeAdjustBadgeHtml("сегодня изменили норму", "Сегодня изменили норму в плане");
}
if (recipe.replacedIngredientToday) {
html += recipeSkipBadgeHtml("сегодня заменили компонент", "Сегодня заменили компонент в плане");
}
return html;
}
function recipePlanBadgesRowHtml(recipe) {
const badges = recipeSkipExtrasHtml(recipe);
if (!badges) return "";
return `<div class="recipe-plan-badges">${badges}</div>`;
}
/** Верхние 3 списка на десктопе: до 4 карточек без прокрутки; дальше — max-height + scroll */
const DASHBOARD_MAX_LIST_ITEMS = 4;
const DASHBOARD_LIST_IDS = new Set(["dispensersList", "periodsList", "recipesList"]);
/** Минимум времени показа скелетона (мс), чтобы не мигал при очень быстром ответе API */
const DASHBOARD_SKELETON_MIN_MS = 220;
/** Длительность FLIP-перестановки карточек (мс) */
const RECIPES_REORDER_FLIP_MS = 620;
const MOBILE_DASHBOARD_MQ = "(max-width: 768px)";
const MOBILE_DASHBOARD_STEPS = ["dispensers", "periods", "recipes"];
function isMobileDashboardViewport() {
return globalThis.matchMedia?.(MOBILE_DASHBOARD_MQ)?.matches ?? false;
}
function getMobileDashboardStepFromBody() {
for (const step of MOBILE_DASHBOARD_STEPS) {
if (document.body.classList.contains(`dashboard-mobile-step-${step}`)) {
return step;
}
}
return "dispensers";
}
function getListItemTitle(listId, dataAttr, id) {
if (id == null || id === "") return "";
const item = document.querySelector(`#${listId} .list-item[${dataAttr}="${CSS.escape(String(id))}"]`);
if (!item) return "";
const heading = item.querySelector("h4");
return heading?.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
function updateMobileDashboardNav(step) {
const nav = document.getElementById("recipesMobileNav");
const titleEl = document.getElementById("recipesMobileNavTitle");
if (!nav || !titleEl) return;
if (!isMobileDashboardViewport() || step === "dispensers") {
nav.hidden = true;
titleEl.textContent = "";
return;
}
nav.hidden = false;
const dispenserTitle = getListItemTitle("dispensersList", "data-dispenser-id", getSelectedDispenser());
const periodTitle = getListItemTitle("periodsList", "data-period-id", getSelectedPeriod());
if (step === "periods") {
titleEl.textContent = dispenserTitle || "Периоды кормления";
return;
}
if (getCurrentDispenserType() === "mill") {
titleEl.textContent = dispenserTitle || "Рецепты";
return;
}
const parts = [dispenserTitle, periodTitle].filter(Boolean);
titleEl.textContent = parts.length > 0 ? parts.join(" · ") : "Рейсы";
}
function setMobileDashboardStep(step) {
document.body.classList.remove(
...MOBILE_DASHBOARD_STEPS.map((s) => `dashboard-mobile-step-${s}`)
);
if (!isMobileDashboardViewport()) {
updateMobileDashboardNav("dispensers");
return;
}
const resolvedStep = MOBILE_DASHBOARD_STEPS.includes(step) ? step : "dispensers";
document.body.classList.add(`dashboard-mobile-step-${resolvedStep}`);
updateMobileDashboardNav(resolvedStep);
}
function mobileDashboardBack() {
if (!isMobileDashboardViewport()) return;
const step = getMobileDashboardStepFromBody();
if (step === "recipes") {
showRecipeEdit(false);
if (getCurrentDispenserType() === "mill") {
setMobileDashboardStep("dispensers");
} else {
setMobileDashboardStep("periods");
}
return;
}
if (step === "periods") {
setSelectedDispenser(null);
setSelectedPeriod(null);
setMobileDashboardStep("dispensers");
document.querySelectorAll("#dispensersList .list-item").forEach((item) => {
item.classList.remove("active");
});
}
}
function hasMobileDashboardStepClass() {
return MOBILE_DASHBOARD_STEPS.some((s) =>
document.body.classList.contains(`dashboard-mobile-step-${s}`)
);
}
function syncMobileDashboardLayout() {
if (!isMobileDashboardViewport()) {
document.body.classList.remove(
...MOBILE_DASHBOARD_STEPS.map((s) => `dashboard-mobile-step-${s}`)
);
updateMobileDashboardNav("dispensers");
return;
}
if (document.body.classList.contains("recipe-edit-mobile-open")) {
return;
}
if (!hasMobileDashboardStepClass()) {
setMobileDashboardStep("dispensers");
} else {
updateMobileDashboardNav(getMobileDashboardStepFromBody());
}
}
globalThis.addEventListener("resize", () => {
syncMobileDashboardLayout();
DASHBOARD_LIST_IDS.forEach((listId) => {
const listEl = document.getElementById(listId);
if (listEl) scheduleDashboardListLayout(listEl);
});
if (!isMobileDashboardViewport()) {
document.body.classList.remove("recipe-edit-mobile-open");
const recipeEdit = document.getElementById("recipeEdit");
if (recipeEdit) {
recipeEdit.classList.remove("recipe-edit--mobile-active");
}
document.documentElement.style.overflow = "";
document.body.style.overflow = "";
}
});
function clearRecipesListReorderClasses(listEl) {
if (!listEl) return;
listEl.classList.remove("recipes-list-reorder-pending", "recipes-list-reorder-settle");
}
function captureRecipeCardRects(listEl) {
const map = new Map();
if (!listEl) return map;
listEl.querySelectorAll(":scope > .list-item[data-recipe-id]").forEach((el) => {
map.set(el.dataset.recipeId, el.getBoundingClientRect());
});
return map;
}
function playRecipesListReorderFlip(listEl, firstRects) {
if (!listEl || !firstRects || firstRects.size === 0) {
clearRecipesListReorderClasses(listEl);
return;
}
const reduceMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
listEl.classList.remove("recipes-list-reorder-pending");
if (reduceMotion) {
clearRecipesListReorderClasses(listEl);
return;
}
const items = [...listEl.querySelectorAll(":scope > .list-item[data-recipe-id]")];
const toAnimate = [];
items.forEach((el) => {
const id = el.dataset.recipeId;
const first = firstRects.get(id);
if (!first) return;
const last = el.getBoundingClientRect();
const dx = first.left - last.left;
const dy = first.top - last.top;
if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return;
el.style.transition = "none";
el.style.transformOrigin = "center center";
el.style.willChange = "transform";
el.style.boxShadow = "var(--recipe-card-flip-shadow, 0 8px 24px rgba(15, 23, 42, 0.12))";
el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
el.style.zIndex = "2";
el.classList.add("recipe-card-reorder-flip");
toAnimate.push(el);
});
if (toAnimate.length === 0) {
clearRecipesListReorderClasses(listEl);
return;
}
void listEl.offsetHeight;
requestAnimationFrame(() => {
const easing = "cubic-bezier(0.33, 1, 0.68, 1)";
const sec = RECIPES_REORDER_FLIP_MS / 1000;
toAnimate.forEach((el) => {
el.style.transition = `transform ${sec}s ${easing}, box-shadow ${sec}s ${easing}`;
el.style.transform = "translate3d(0, 0, 0)";
el.style.boxShadow = "";
});
});
globalThis.setTimeout(() => {
toAnimate.forEach((el) => {
el.style.transition = "";
el.style.transform = "";
el.style.willChange = "";
el.style.transformOrigin = "";
el.style.zIndex = "";
el.style.boxShadow = "";
el.classList.remove("recipe-card-reorder-flip");
});
clearRecipesListReorderClasses(listEl);
}, RECIPES_REORDER_FLIP_MS + 100);
}
async function awaitDashboardSkeletonMin(skeletonStartedAt, options = {}) {
if (options.skip) return;
const elapsed = performance.now() - skeletonStartedAt;
const rest = DASHBOARD_SKELETON_MIN_MS - elapsed;
if (rest > 0) {
await new Promise((resolve) => setTimeout(resolve, rest));
}
}
function getDashboardListScrollCap(listId) {
if (isMobileDashboardViewport()) return null;
return DASHBOARD_MAX_LIST_ITEMS;
}
function syncDashboardListScrollCap(listEl) {
if (!listEl?.id || !DASHBOARD_LIST_IDS.has(listEl.id)) return;
listEl.classList.add("dashboard-list-layout-settling");
listEl.classList.remove("dashboard-list-scroll-cap");
listEl.style.removeProperty("max-height");
const maxItems = getDashboardListScrollCap(listEl.id);
if (!maxItems) {
requestAnimationFrame(() => {
listEl.classList.remove("dashboard-list-layout-settling");
});
return;
}
const items = listEl.querySelectorAll(":scope > .list-item");
if (items.length <= maxItems) {
requestAnimationFrame(() => {
listEl.classList.remove("dashboard-list-layout-settling");
});
return;
}
let capPx = 0;
for (let i = 0; i < maxItems; i++) {
capPx += items[i].offsetHeight;
if (i < maxItems - 1) {
const a = items[i].getBoundingClientRect();
const b = items[i + 1].getBoundingClientRect();
capPx += Math.max(0, b.top - a.bottom);
}
}
listEl.style.setProperty("max-height", `${Math.ceil(capPx)}px`, "important");
listEl.classList.add("dashboard-list-scroll-cap");
requestAnimationFrame(() => {
listEl.classList.remove("dashboard-list-layout-settling");
});
}
function ensureDashboardListScrollObservers() {
if (window._dashboardListScrollObserversReady) return;
window._dashboardListScrollObserversReady = true;
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const listEl = entry.target;
if (listEl?.id && DASHBOARD_LIST_IDS.has(listEl.id)) {
syncDashboardListScrollCap(listEl);
}
});
});
DASHBOARD_LIST_IDS.forEach((listId) => {
const listEl = document.getElementById(listId);
if (listEl) observer.observe(listEl);
});
}
function scheduleDashboardListLayout(listEl) {
if (!listEl?.id || !DASHBOARD_LIST_IDS.has(listEl.id)) return;
ensureDashboardListScrollObservers();
const run = () => syncDashboardListScrollCap(listEl);
requestAnimationFrame(() => {
requestAnimationFrame(run);
});
}
function prefersReducedMotion() {
return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
}
function getDashboardListSkeletonHtml(listId) {
const variantByList = {
dispensersList: "dispensers",
periodsList: "periods",
recipesList: "period",
millRecipesList: "mill",
};
return buildRecipeRowsSkeletonHtml(variantByList[listId] || "period");
}
function markListLoading(listEl, loading) {
if (!listEl) return;
listEl.classList.toggle("list-container--loading", Boolean(loading));
}
function setListSkeleton(listEl, listId) {
if (!listEl) return;
listEl.innerHTML = getDashboardListSkeletonHtml(listId);
markListLoading(listEl, true);
scheduleDashboardListLayout(listEl);
}
function setRecipesColumnIdle(idle) {
const recipesCol = document.getElementById("recipesCol");
if (recipesCol) {
recipesCol.classList.toggle("dashboard-col--idle", Boolean(idle));
}
}
function setPeriodsColumnIdle(idle) {
const periodsCol = document.getElementById("periodsCol");
if (periodsCol) {
periodsCol.classList.toggle("dashboard-col--idle", Boolean(idle));
}
}
function fadeOutListSkeleton(listEl) {
const skel = listEl?.querySelector(
":scope > .dashboard-list-skeleton, :scope > .zt-loading-list",
);
if (!skel) return;
skel.remove();
}
function replaceDashboardListContent(listEl, html) {
if (!listEl) return;
fadeOutListSkeleton(listEl);
listEl.innerHTML = html;
markListLoading(listEl, false);
scheduleDashboardListLayout(listEl);
}
function clearStaleDashboardListSkeleton(listEl) {
if (!listEl?.querySelector(":scope > .dashboard-list-skeleton, :scope > .zt-loading-list")) return;
listEl.innerHTML = "";
markListLoading(listEl, false);
scheduleDashboardListLayout(listEl);
}
async function loadDispensers() {
const dispensersList = document.getElementById("dispensersList");
let dispensers = peekPreloadedApiPayload(
PRELOAD_FEED_DISPENSERS_KEY,
PRELOAD_FEED_DISPENSERS_MAX_AGE_MS,
);
const fromPreload = Array.isArray(dispensers);
const skeletonStartedAt = performance.now();
if (!fromPreload) {
setListSkeleton(dispensersList, "dispensersList");
}
try {
if (!fromPreload) {
const response = await fetch("/api/feed_dispensers");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
dispensers = await response.json();
}
const awaitSkeleton = awaitDashboardSkeletonMin(skeletonStartedAt, { skip: fromPreload });
const finishList = async (html) => {
await awaitSkeleton;
await replaceDashboardListContent(dispensersList, html);
if (fromPreload) {
clearPreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY);
}
clearRecipesBootOverlay();
};
if (dispensers.length === 0) {
await finishList(
`<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Нет доступных кормораздатчиков
</div>`,
);
setMobileDashboardStep("dispensers");
return;
}
const html = dispensers
.map((dispenser) => {
const isMill = dispenser.type === "mill";
const deviceIcon = isMill ? "fa-industry" : "fa-tractor";
return `
<div class="list-item" data-action="select-dispenser" data-dispenser-id="${dispenser.id}" data-dispenser-type="${dispenser.type || "dispenser"}">
<div class="list-item-header">
<h4 class="list-item-title">
<i class="fas ${deviceIcon} me-2"></i>
${dispenser.name}${dispenser.hasSkipToday ? dashboardSkipWarningHtml(`Отклонения от плана: ${dispenser.name}`) : ""}
</h4>
${!isMill ? `<span class="badge bg-primary">${dispenser.periods ? dispenser.periods.length : 0} периодов</span>` : ""}
</div>
<div class="list-item-meta">
<small class="text-muted">
<i class="fas fa-user me-1"></i>
${dispenser.operator || "Не указан"}
</small>
<small class="text-muted">
<i class="fas fa-building me-1"></i>
${dispenser.farm || "Не указана"}
</small>
</div>
</div>
`;
})
.join("");
await finishList(html);
setMobileDashboardStep("dispensers");
} catch (error) {
console.error("Ошибка при загрузке кормораздатчиков:", error);
clearPreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY);
await awaitDashboardSkeletonMin(skeletonStartedAt);
await replaceDashboardListContent(
dispensersList,
`<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
Ошибка при загрузке кормораздатчиков: ${error.message}
</div>`
);
clearRecipesBootOverlay();
notyf.error("Ошибка при загрузке кормораздатчиков");
}
}
async function selectDispenser(dispenserId) {
setSelectedDispenser(dispenserId);
setSelectedPeriod(null);
showRecipeEdit(false, { skipMobileStep: true });
const dispenserIdStr = String(dispenserId);
document.querySelectorAll("#dispensersList .list-item").forEach((item) => {
item.classList.toggle("active", item.dataset.dispenserId === dispenserIdStr);
});
const periodsListEl = document.getElementById("periodsList");
const recipesListEl = document.getElementById("recipesList");
periodsListEl.innerHTML = "";
markListLoading(periodsListEl, false);
recipesListEl.innerHTML = "";
markListLoading(recipesListEl, false);
setPeriodsColumnIdle(true);
setRecipesColumnIdle(true);
scheduleDashboardListLayout(periodsListEl);
scheduleDashboardListLayout(recipesListEl);
const response = await fetch("/api/feed_dispensers");
const dispensers = await response.json();
const dispenser = dispensers.find((d) => isSameId(d.id, dispenserId));
if (!isSameId(getSelectedDispenser(), dispenserId)) {
return;
}
if (isMobileDashboardViewport()) {
setMobileDashboardStep(dispenser && dispenser.type === "mill" ? "recipes" : "periods");
}
const periodsCol = document.getElementById("periodsCol");
const recipesCol = document.getElementById("recipesCol");
const recipesHeaderText = document.getElementById("recipesCardHeaderText");
const recipesCardIcon = document.getElementById("recipesCardIcon");
const createRecipeBtn = document.getElementById("createRecipeBtn");
if (dispenser && dispenser.type === "mill") {
setCurrentDispenserType("mill");
setPeriodsColumnIdle(true);
if (periodsCol) {
periodsCol.style.display = "none";
periodsCol.classList.remove("col-md-4");
}
if (recipesCol) {
recipesCol.classList.remove("col-md-4");
recipesCol.classList.add("col-md-8");
}
if (recipesHeaderText) recipesHeaderText.textContent = "Рецепты";
if (recipesCardIcon) {
recipesCardIcon.innerHTML = '<i class="fas fa-clipboard-list me-2"></i>';
recipesCardIcon.classList.remove("route-icon");
}
if (createRecipeBtn) createRecipeBtn.style.display = "";
setRecipesColumnIdle(false);
if (!isSameId(getSelectedDispenser(), dispenserId)) return;
await loadMillRecipes(dispenserId);
} else {
setCurrentDispenserType("dispenser");
if (createRecipeBtn) createRecipeBtn.style.display = "none";
setRecipesColumnIdle(true);
setPeriodsColumnIdle(false);
if (periodsCol) {
periodsCol.style.display = "";
periodsCol.classList.add("col-md-4");
}
if (recipesCol) {
recipesCol.classList.remove("col-md-8");
recipesCol.classList.add("col-md-4");
}
if (recipesHeaderText) recipesHeaderText.textContent = "Рейсы";
if (recipesCardIcon) {
recipesCardIcon.innerHTML = routeIconSvg();
recipesCardIcon.classList.add("route-icon");
recipesCardIcon.classList.add("me-2");
}
if (!isSameId(getSelectedDispenser(), dispenserId)) return;
await loadPeriods(dispenserId);
}
}
async function loadMillRecipes(dispenserId) {
const recipesList = document.getElementById("recipesList");
setListSkeleton(recipesList, "millRecipesList");
const skeletonStartedAt = performance.now();
try {
const response = await fetch(`/api/feed_dispensers/${dispenserId}/recipes`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const recipes = await response.json();
if (!isSameId(getSelectedDispenser(), dispenserId)) {
clearStaleDashboardListSkeleton(recipesList);
return;
}
if (recipes.length === 0) {
await awaitDashboardSkeletonMin(skeletonStartedAt);
await replaceDashboardListContent(
recipesList,
`<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Нет доступных рецептов. Создайте первый рецепт.
</div>`
);
return;
}
const selectedRecipe = getSelectedRecipe();
await awaitDashboardSkeletonMin(skeletonStartedAt);
const html = recipes
.map(
(recipe) => `
<div class="list-item ${selectedRecipe === recipe.id ? "active" : ""}" data-action="select-recipe" data-recipe-id="${recipe.id}">
<div class="list-item-header">
<h4 class="list-item-title recipe-title">
<i class="fas fa-clipboard-list me-2"></i>
<span class="recipe-title-text">${recipe.name}</span>
</h4>
<div class="d-flex align-items-center justify-content-end flex-nowrap recipe-table-row-actions recipe-actions list-item-actions" role="group">
<div class="btn-group recipe-table-move-group" role="group">
<button type="button"
class="btn btn-outline-secondary btn-sm recipe-table-move-btn"
data-action="copy-recipe"
data-recipe-id="${recipe.id}"
title="Копировать рейс">
<i class="fas fa-copy"></i>
</button>
</div>
<button type="button"
class="btn btn-sm recipe-table-delete-btn"
data-action="delete-recipe"
data-recipe-id="${recipe.id}"
title="Удалить рейс">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
${recipePlanBadgesRowHtml(recipe)}
<div class="list-item-meta recipe-card-meta">
<small class="text-muted">
<span class="cow-icon me-1" aria-hidden="true">${cowIconSvg()}</span>
${recipe.heads_count ?? recipe.headsPerTrip ?? 0} голов
</small>
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
${recipe.mixing_time ?? recipe.mixingTime ?? 0} мин
</small>
<span class="badge bg-primary">
<i class="fas fa-percentage me-1"></i>
${recipe.trip_percent ?? recipe.tripPercent ?? 100}%
</span>
</div>
</div>
`
)
.join("");
await replaceDashboardListContent(recipesList, html);
} catch (error) {
console.error("Ошибка при загрузке рецептов кормоцеха:", error);
if (!isSameId(getSelectedDispenser(), dispenserId)) {
clearStaleDashboardListSkeleton(recipesList);
return;
}
await awaitDashboardSkeletonMin(skeletonStartedAt);
await replaceDashboardListContent(
recipesList,
`<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
Ошибка при загрузке рецептов: ${error.message}
</div>`
);
notyf.error("Ошибка при загрузке рецептов");
}
}
async function loadPeriods(dispenserId) {
const periodsList = document.getElementById("periodsList");
setPeriodsColumnIdle(false);
setListSkeleton(periodsList, "periodsList");
const skeletonStartedAt = performance.now();
try {
const response = await fetch(`/api/feed_dispensers/${dispenserId}/periods`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const periods = Array.isArray(data) ? data : [];
if (!isSameId(getSelectedDispenser(), dispenserId)) {
clearStaleDashboardListSkeleton(periodsList);
return;
}
if (periods.length === 0) {
await awaitDashboardSkeletonMin(skeletonStartedAt);
await replaceDashboardListContent(
periodsList,
`<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Нет доступных периодов
</div>`
);
return;
}
await awaitDashboardSkeletonMin(skeletonStartedAt);
const html = periods
.map(
(period) => `
<div class="list-item" data-action="select-period" data-period-id="${period.id}">
<div class="list-item-header">
<h4 class="list-item-title">
<i class="fas fa-calendar me-2"></i>
${period.name}${period.hasSkipToday ? dashboardSkipWarningHtml(`Отклонения от плана: ${period.name}`) : ""}
</h4>
${period.recipes && period.recipes.length > 0
? `
<span class="badge bg-primary">
<i class="fas fa-check me-1"></i>
${period.recipes.length} рейсов
</span>
`
: `
<span class="badge bg-warning">
<i class="fas fa-exclamation-triangle me-1"></i>
Нет рейсов
</span>
`}
</div>
<div class="list-item-meta list-item-meta--actions">
<div class="btn-group list-item-actions">
<button class="btn btn-sm btn-outline-success paste-recipe-btn"
data-action="paste-recipe"
data-period-id="${period.id}"
disabled title="Вставить рейс из буфера">
<i class="fas fa-paste me-1"></i>
Вставить
</button>
${period.recipes && period.recipes.length > 0
? `
<button class="btn btn-sm btn-outline-primary" data-action="create-recipe" data-period-id="${period.id}">
<i class="fas fa-plus me-1"></i>
Добавить рейс
</button>
`
: `
<button class="btn btn-sm btn-primary" data-action="create-recipe" data-period-id="${period.id}">
<i class="fas fa-plus me-1"></i>
Создать первый рейс
</button>
`}
</div>
</div>
</div>
`
)
.join("");
await replaceDashboardListContent(periodsList, html);
updatePasteButtons();
} catch (error) {
console.error("Ошибка при загрузке периодов:", error);
if (!isSameId(getSelectedDispenser(), dispenserId)) {
clearStaleDashboardListSkeleton(periodsList);
return;
}
await awaitDashboardSkeletonMin(skeletonStartedAt);
await replaceDashboardListContent(
periodsList,
`<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
Ошибка при загрузке периодов: ${error.message}
</div>`
);
notyf.error("Ошибка при загрузке периодов");
}
}
async function selectPeriod(periodId) {
setSelectedPeriod(periodId);
document.querySelectorAll("#periodsList .list-item").forEach((item) => {
item.classList.remove("active");
});
document.querySelector(`[data-period-id="${periodId}"]`)?.classList.add("active");
if (isMobileDashboardViewport()) {
setMobileDashboardStep("recipes");
}
setRecipesColumnIdle(false);
const recipesListClear = document.getElementById("recipesList");
recipesListClear.innerHTML = "";
markListLoading(recipesListClear, false);
scheduleDashboardListLayout(recipesListClear);
try {
await loadRecipes(periodId);
} catch (error) {
console.error("Ошибка при загрузке рейсов:", error);
notyf.error("Ошибка при загрузке рейсов");
}
}
async function loadRecipes(periodId, options = {}) {
if (!periodId) return;
const skipRecipesSkeleton = Boolean(options.skipRecipesSkeleton);
const recipesListPre = document.getElementById("recipesList");
let skeletonStartedAt = performance.now();
if (recipesListPre) {
if (skipRecipesSkeleton) {
clearRecipesListReorderClasses(recipesListPre);
recipesListPre.classList.add("recipes-list-reorder-pending");
} else {
setListSkeleton(recipesListPre, "recipesList");
skeletonStartedAt = performance.now();
}
}
try {
const response = await fetch(`/api/periods/${periodId}/recipes`);
if (!response.ok) {
throw new Error("Ошибка при загрузке рецептов");
}
const recipes = await response.json();
const recipesList = document.getElementById("recipesList");
const selectedRecipe = getSelectedRecipe();
if (!isSameId(getSelectedPeriod(), periodId)) {
if (recipesList) clearRecipesListReorderClasses(recipesList);
if (!skipRecipesSkeleton) clearStaleDashboardListSkeleton(recipesList);
return;
}
if (recipes.length === 0) {
if (!skipRecipesSkeleton) {
await awaitDashboardSkeletonMin(skeletonStartedAt);
} else if (recipesList) {
clearRecipesListReorderClasses(recipesList);
}
const emptyHtml = `
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Нет доступных рейсов
</div>`;
if (skipRecipesSkeleton) {
recipesList.innerHTML = emptyHtml;
markListLoading(recipesList, false);
scheduleDashboardListLayout(recipesList);
} else {
await replaceDashboardListContent(recipesList, emptyHtml);
}
return;
}
let firstRects = null;
if (skipRecipesSkeleton && recipesList) {
firstRects = captureRecipeCardRects(recipesList);
}
if (!skipRecipesSkeleton) {
await awaitDashboardSkeletonMin(skeletonStartedAt);
}
const html = recipes
.map(
(recipe, index) => `
<div class="list-item ${selectedRecipe === recipe.id ? "active" : ""}" data-action="select-recipe" data-recipe-id="${recipe.id}" data-index="${index}">
<div class="list-item-header recipe-actions-row">
<div class="list-item-leading">
<button type="button"
class="btn btn-outline-secondary btn-sm recipe-drag-handle recipe-table-move-btn"
draggable="true"
data-action="recipe-drag-handle"
title="Потяните ручку вверх или вниз. У края списка — автопрокрутка. Долгое нажатие — перенос."
aria-label="Перетащить рейс: потяните вверх или вниз, у края списка — прокрутка">
<i class="fas fa-grip-vertical" aria-hidden="true"></i>
</button>
</div>
<div class="d-flex align-items-center justify-content-end flex-nowrap recipe-table-row-actions recipe-actions list-item-actions" role="group">
<div class="btn-group recipe-table-move-group" role="group">
<button type="button"
class="btn btn-outline-secondary btn-sm recipe-table-move-btn"
data-action="move-recipe-up"
data-recipe-id="${recipe.id}"
data-index="${index}"
title="Переместить вверх"
${index === 0 ? "disabled" : ""}>
<i class="fas fa-arrow-up"></i>
</button>
<button type="button"
class="btn btn-outline-secondary btn-sm recipe-table-move-btn"
data-action="move-recipe-down"
data-recipe-id="${recipe.id}"
data-index="${index}"
title="Переместить вниз"
${index === recipes.length - 1 ? "disabled" : ""}>
<i class="fas fa-arrow-down"></i>
</button>
<button type="button"
class="btn btn-outline-secondary btn-sm recipe-table-move-btn"
data-action="copy-recipe"
data-recipe-id="${recipe.id}"
title="Копировать рейс">
<i class="fas fa-copy"></i>
</button>
</div>
<button type="button"
class="btn btn-sm recipe-table-delete-btn"
data-action="delete-recipe"
data-recipe-id="${recipe.id}"
title="Удалить рейс">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
<h4 class="list-item-title recipe-title mb-0">
<span class="recipe-title-text">${recipe.name}</span>
</h4>
${recipePlanBadgesRowHtml(recipe)}
<div class="list-item-meta recipe-card-meta">
<small class="text-muted">
<span class="cow-icon me-1" aria-hidden="true">${cowIconSvg()}</span>
${recipe.heads_count ?? recipe.headsPerTrip ?? 0} голов
</small>
<small class="text-muted">
<i class="fas fa-clock me-1"></i>
${recipe.mixing_time ?? recipe.mixingTime ?? 0} мин
</small>
<span class="badge bg-primary">
<i class="fas fa-percentage me-1"></i>
${recipe.trip_percent ?? recipe.tripPercent ?? 100}%
</span>
</div>
</div>
`
)
.join("");
if (skipRecipesSkeleton) {
recipesList.innerHTML = html;
markListLoading(recipesList, false);
scheduleDashboardListLayout(recipesList);
} else {
await replaceDashboardListContent(recipesList, html);
}
if (skipRecipesSkeleton && firstRects && firstRects.size > 0) {
/* Третий кадр после innerHTML: к этому моменту уже отработал scheduleDashboardListLayout (2× rAF + sync) */
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => playRecipesListReorderFlip(recipesList, firstRects));
});
});
} else if (skipRecipesSkeleton) {
clearRecipesListReorderClasses(recipesList);
}
} catch (error) {
console.error("Ошибка при загрузке рецептов:", error);
if (!isSameId(getSelectedPeriod(), periodId)) {
return;
}
if (!skipRecipesSkeleton) {
await awaitDashboardSkeletonMin(skeletonStartedAt);
}
const recipesListErr = document.getElementById("recipesList");
if (recipesListErr) {
clearRecipesListReorderClasses(recipesListErr);
const errHtml = `
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
Ошибка при загрузке рейсов
</div>`;
if (skipRecipesSkeleton) {
recipesListErr.innerHTML = errHtml;
markListLoading(recipesListErr, false);
scheduleDashboardListLayout(recipesListErr);
} else {
await replaceDashboardListContent(recipesListErr, errHtml);
}
}
notyf.error("Ошибка при загрузке рецептов");
}
}
async function selectRecipe(recipeId) {
setSelectedRecipe(recipeId);
document.querySelectorAll("#recipesList .list-item").forEach((item) => {
item.classList.remove("active");
});
document.querySelector(`[data-recipe-id="${recipeId}"]`)?.classList.add("active");
const skeletonStartedAt = performance.now();
showRecipeEdit(true, { loading: true });
await loadRecipeDetails(recipeId);
await awaitDashboardSkeletonMin(skeletonStartedAt);
showRecipeEdit(true, { loading: false });
}
return {
loadDispensers,
selectDispenser,
loadMillRecipes,
loadPeriods,
selectPeriod,
loadRecipes,
selectRecipe,
getCurrentDispenserType,
setMobileDashboardStep,
mobileDashboardBack,
};
}