1063 lines
42 KiB
JavaScript
1063 lines
42 KiB
JavaScript
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,
|
||
};
|
||
}
|