/** * Коррекция СВ% компонентов в плане — overlay в стиле K-hub. * Редактируется только СВ%; превью через /api/recipes/calculate. */ (function (global) { function todayIso() { return global.WespDailyPlanSkipDuration?.todayIso?.() || new Date().toISOString().slice(0, 10); } function escapeHtml(value) { return ( global.WespDailyPlanSkipDuration?.escapeHtml?.(value) || String(value ?? "").replace(/&/g, "&").replace(/ 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 = `
${lockedCount} рец. с замком СВ
`; } else if (!editable) { meta = `
${escapeHtml(PHYS_WEIGHT_HINT)}
`; } else if (Number.isFinite(trips) && trips > 1) { meta = `
${trips} рейсов в плане
`; } const lockIcon = editable ? "" : ``; return ( `
` + `
` + lockIcon + `
${escapeHtml(item.componentName)}
` + `
` + meta + "
" ); } 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 ( `` ); } 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 `${escapeHtml(now)}`; } return ( `` + `${escapeHtml(was)}` + `${escapeHtml(now)}` + `` ); } 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 ( `` + `База: ${escapeHtml(label)}` ); } 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 = `${escapeHtml(now)}`; 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 = `${escapeHtml(masterPrev.dmPh)}`; } if (wphEl) { wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare"); wphEl.innerHTML = `${escapeHtml(masterPrev.wph)}`; } 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 ( `
` + `
` + componentCellHtml(item) + "
" + `
` + `
` + `` + baseDryMatterHintHtml(item) + `
` + `
` + `
` + `
` + `
` + `
` + `
` + applyBtn + replaceBtn + undo + "
" ); } function ensureOverlay() { closeModal(); overlayEl = document.createElement("div"); overlayEl.className = "zt-k-hub-plan-overlay zt-k-hub-plan-norms"; overlayEl.innerHTML = '"; 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 = '
' + (lockedOnlyFilter ? "Нет компонентов в рецептах с замком СВ на эту дату" : "Нет компонентов в плане на эту дату") + "
"; 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);