Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* Коррекция СВ% компонентов в плане — overlay в стиле K-hub.
|
||||
* Редактируется только СВ%; превью через /api/recipes/calculate.
|
||||
*/
|
||||
(function (global) {
|
||||
function todayIso() {
|
||||
return global.WespDailyPlanSkipDuration?.todayIso?.() || new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return (
|
||||
global.WespDailyPlanSkipDuration?.escapeHtml?.(value) ||
|
||||
String(value ?? "").replace(/&/g, "&").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);
|
||||
Reference in New Issue
Block a user