666 lines
26 KiB
JavaScript
666 lines
26 KiB
JavaScript
/**
|
||
* Коррекция СВ% компонентов в плане — 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(/</g, "<")
|
||
);
|
||
}
|
||
|
||
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);
|