931 lines
37 KiB
JavaScript
931 lines
37 KiB
JavaScript
/**
|
||
* Панель «План на день» внутри hub-модалки «К».
|
||
*/
|
||
(function (global) {
|
||
const DAILY_PLAN_DISPENSER_KEY = "wesp-daily-plan-dispenser";
|
||
const ALL_DISPENSERS_ID = "__all_dispensers__";
|
||
const ALL_MILLS_ID = "__all_mills__";
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function formatDistribution(group) {
|
||
if (group?.distributionLabel) return group.distributionLabel;
|
||
const type = group?.distributionType || "percent";
|
||
const value = group?.value ?? "";
|
||
if (type === "heads") return `${value} гол.`;
|
||
return `${value}%`;
|
||
}
|
||
|
||
function todayIso() {
|
||
const d = new Date();
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
function createDailyPlanPanel() {
|
||
let rootEl = null;
|
||
let dateEl = null;
|
||
let dispenserEl = null;
|
||
let contentEl = null;
|
||
let dispensers = [];
|
||
let undoToastTimer = null;
|
||
|
||
function getSelectedDispenserId() {
|
||
return dispenserEl?.value || "";
|
||
}
|
||
|
||
function getSelectedPlanDate() {
|
||
return planDateValue();
|
||
}
|
||
|
||
function notifySuccess(message) {
|
||
try {
|
||
global.WespZootechNotify?.createAdapter?.()?.success?.(message, { skipRecord: true });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function notifyError(message, fallback) {
|
||
try {
|
||
global.WespZootechNotify?.createAdapter?.()?.error?.(message, {
|
||
fallback: fallback || message,
|
||
skipRecord: true,
|
||
});
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
function renderToolbar() {
|
||
return (
|
||
'<div class="zt-k-hub-plan__toolbar">' +
|
||
'<div class="zt-k-hub-plan__field">' +
|
||
'<label class="form-label">Дата</label>' +
|
||
'<input type="date" class="form-control form-control-sm" data-daily-plan-date>' +
|
||
"</div>" +
|
||
'<div class="zt-k-hub-plan__field">' +
|
||
'<label class="form-label">Кормораздатчик</label>' +
|
||
'<select class="form-control form-control-sm" data-daily-plan-dispenser>' +
|
||
'<option value="">Загрузка…</option></select>' +
|
||
"</div>" +
|
||
'<button type="button" class="btn btn-primary btn-sm zt-k-hub-plan__pdf" data-action="daily-plan-pdf">' +
|
||
'<i class="fas fa-file-pdf"></i> Скачать PDF</button>' +
|
||
"</div>"
|
||
);
|
||
}
|
||
|
||
function mount(container) {
|
||
rootEl = container;
|
||
rootEl.innerHTML =
|
||
'<div class="zt-k-hub-plan">' +
|
||
renderToolbar() +
|
||
'<div class="zt-k-hub-plan__content" data-daily-plan-content>' +
|
||
'<div class="zt-k-hub-plan__loading text-muted small">Загрузка плана…</div>' +
|
||
"</div></div>";
|
||
dateEl = rootEl.querySelector("[data-daily-plan-date]");
|
||
dispenserEl = rootEl.querySelector("[data-daily-plan-dispenser]");
|
||
contentEl = rootEl.querySelector("[data-daily-plan-content]");
|
||
if (dateEl) dateEl.value = todayIso();
|
||
dateEl?.addEventListener("change", () => loadPlan());
|
||
dispenserEl?.addEventListener("change", () => {
|
||
if (dispenserEl.value) {
|
||
try {
|
||
localStorage.setItem(DAILY_PLAN_DISPENSER_KEY, dispenserEl.value);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
loadPlan();
|
||
});
|
||
rootEl.querySelector("[data-action='daily-plan-pdf']")?.addEventListener("click", downloadPdf);
|
||
rootEl.addEventListener("click", onPanelClick);
|
||
loadDispensers();
|
||
}
|
||
|
||
function planDateValue() {
|
||
return dateEl?.value || todayIso();
|
||
}
|
||
|
||
async function onPanelClick(event) {
|
||
const btn = event.target.closest("[data-action]");
|
||
if (!btn || !rootEl?.contains(btn)) return;
|
||
const action = btn.dataset.action;
|
||
if (action === "daily-plan-skip-trip") {
|
||
event.preventDefault();
|
||
const recipeId = btn.dataset.recipeId;
|
||
const recipeName = btn.dataset.recipeName || "Рейс";
|
||
if (recipeId) await skipTrip(recipeId, recipeName);
|
||
return;
|
||
}
|
||
if (action === "daily-plan-unskip-trip") {
|
||
event.preventDefault();
|
||
const recipeId = btn.dataset.recipeId;
|
||
if (recipeId) await unskipTrip(recipeId);
|
||
return;
|
||
}
|
||
if (action === "daily-plan-skip-ingredient") {
|
||
event.preventDefault();
|
||
const recipeId = btn.dataset.recipeId;
|
||
const ingredientId = btn.dataset.ingredientId;
|
||
const label = btn.dataset.label || "Компонент";
|
||
if (recipeId && ingredientId) await skipIngredient(recipeId, ingredientId, label);
|
||
return;
|
||
}
|
||
if (action === "daily-plan-unskip-ingredient") {
|
||
event.preventDefault();
|
||
const recipeId = btn.dataset.recipeId;
|
||
const ingredientId = btn.dataset.ingredientId;
|
||
if (recipeId && ingredientId) await unskipIngredient(recipeId, ingredientId);
|
||
return;
|
||
}
|
||
if (action === "daily-plan-skip-unloading-group") {
|
||
event.preventDefault();
|
||
const recipeId = btn.dataset.recipeId;
|
||
const groupId = btn.dataset.groupId;
|
||
const label = btn.dataset.label || "Группа";
|
||
if (recipeId && groupId) await skipUnloadingGroup(recipeId, groupId, label);
|
||
return;
|
||
}
|
||
if (action === "daily-plan-unskip-unloading-group") {
|
||
event.preventDefault();
|
||
const recipeId = btn.dataset.recipeId;
|
||
const groupId = btn.dataset.groupId;
|
||
if (recipeId && groupId) await unskipUnloadingGroup(recipeId, groupId);
|
||
return;
|
||
}
|
||
if (action === "daily-plan-replace-ingredient") {
|
||
event.preventDefault();
|
||
const recipeId = btn.dataset.recipeId;
|
||
const ingredientId = btn.dataset.ingredientId;
|
||
const componentId = btn.dataset.componentId;
|
||
const label = btn.dataset.label || "Компонент";
|
||
if (recipeId && ingredientId && componentId) {
|
||
await openReplaceIngredientModal(recipeId, ingredientId, componentId, label);
|
||
}
|
||
return;
|
||
}
|
||
if (action === "daily-plan-undo-replace-ingredient") {
|
||
event.preventDefault();
|
||
const recipeId = btn.dataset.recipeId;
|
||
const ingredientId = btn.dataset.ingredientId;
|
||
if (recipeId && ingredientId) await undoReplaceIngredient(recipeId, ingredientId);
|
||
return;
|
||
}
|
||
if (action === "daily-plan-undo-adjust-norm") {
|
||
event.preventDefault();
|
||
const componentId = btn.dataset.componentId;
|
||
if (componentId) await undoComponentNormAdjustment(componentId);
|
||
}
|
||
}
|
||
|
||
function formatKg(value) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return "—";
|
||
if (Number.isInteger(n)) return String(n);
|
||
return n.toFixed(2).replace(/\.?0+$/, "");
|
||
}
|
||
|
||
function replacementRowTitle(ing) {
|
||
if (!ing?.replacedToday) return "";
|
||
if (ing.recalculationMode === "dry_matter") {
|
||
return (
|
||
`Пересчёт по СВ: ${formatKg(ing.originalDryMatterPerHead)} кг/гол сохранено, ` +
|
||
`вес ${formatKg(ing.originalWeightPerHead)} → ${formatKg(ing.weightPerHead)} кг/гол ` +
|
||
`(СВ ${formatKg(ing.originalDryMatterPct)}% → ${formatKg(ing.dryMatterPct)}%)`
|
||
);
|
||
}
|
||
return (
|
||
`Пересчёт по весу: ${formatKg(ing.weightPerHead)} кг/гол сохранено, ` +
|
||
`СВ/гол ${formatKg(ing.originalDryMatterPerHead)} → ${formatKg(ing.dryMatterPerHead)} кг/гол`
|
||
);
|
||
}
|
||
|
||
function adjustmentRowTitle(ing) {
|
||
if (!ing?.adjustedToday) return "";
|
||
return (
|
||
`Норма в плане: ${formatKg(ing.originalWeightPerHead)} → ${formatKg(ing.weightPerHead)} кг/гол, ` +
|
||
`СВ/гол ${formatKg(ing.originalDryMatterPerHead)} → ${formatKg(ing.dryMatterPerHead)}`
|
||
);
|
||
}
|
||
|
||
function skippedIngredientHint(ing) {
|
||
const wph = Number(ing?.baselineWeightPerHead ?? ing?.weightPerHead);
|
||
const total = Number(ing?.baselineTotalKg);
|
||
if (!Number.isFinite(wph) || !Number.isFinite(total)) return "";
|
||
return `Было: ${formatKg(wph)} кг/гол (${formatKg(total)} кг)`;
|
||
}
|
||
|
||
function renderSkippedValueCell(hint) {
|
||
if (!hint) {
|
||
return '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>';
|
||
}
|
||
return (
|
||
`<td class="zt-k-hub-plan-row--skipped text-muted zt-k-hub-plan-cell--hint" ` +
|
||
`data-hint="${escapeHtml(hint)}" title="${escapeHtml(hint)}" aria-label="${escapeHtml(hint)}">—</td>`
|
||
);
|
||
}
|
||
|
||
function renderPlanDot(title) {
|
||
return (
|
||
`<span class="recipe-skip-badge__dot zt-k-hub-plan-row__skip-dot" ` +
|
||
`title="${escapeHtml(title)}" aria-hidden="true"></span>`
|
||
);
|
||
}
|
||
|
||
function renderSkipDot() {
|
||
return renderPlanDot("Исключён из плана на этот день");
|
||
}
|
||
|
||
function renderReplaceDot(title) {
|
||
return renderPlanDot(title || "Сегодня заменили компонент в плане");
|
||
}
|
||
|
||
function renderAdjustDot(title = "Сегодня изменили норму в плане") {
|
||
return `<span class="zt-k-hub-plan-row__adjust-dot" title="${escapeHtml(title)}" aria-hidden="true"></span>`;
|
||
}
|
||
|
||
function renderRowActionBtn(action, attrs, title, iconClass) {
|
||
return (
|
||
`<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-row__skip" ` +
|
||
`data-action="${action}" ${attrs} title="${escapeHtml(title)}">` +
|
||
`<i class="fas ${iconClass}"></i></button>`
|
||
);
|
||
}
|
||
|
||
function renderSkipActionBtn(action, attrs, title) {
|
||
return renderRowActionBtn(action, attrs, title, "fa-times");
|
||
}
|
||
|
||
function hubModalEl() {
|
||
return (
|
||
rootEl?.closest(".modal.wesp-shell-modal") ||
|
||
document.getElementById("zootechNotificationCenterModal")
|
||
);
|
||
}
|
||
|
||
function planOverlayHost() {
|
||
return hubModalEl() || rootEl?.closest(".modal-content") || rootEl;
|
||
}
|
||
|
||
function dismissAllPlanOverlays() {
|
||
hubModalEl()
|
||
?.querySelectorAll(".zt-k-hub-plan-overlay")
|
||
.forEach((el) => el.remove());
|
||
}
|
||
|
||
function appendPlanOverlay(overlay) {
|
||
dismissAllPlanOverlays();
|
||
overlay.setAttribute("data-daily-plan-overlay", "1");
|
||
planOverlayHost()?.appendChild(overlay);
|
||
return overlay;
|
||
}
|
||
|
||
function promptSkipDuration(planDate, title = "На какой срок исключить?") {
|
||
const skip = global.WespDailyPlanSkipDuration;
|
||
if (!skip) return Promise.resolve(null);
|
||
return skip.promptSkipDuration({
|
||
planDate,
|
||
title,
|
||
overlayHost: planOverlayHost() || document.body,
|
||
beforeAppend: dismissAllPlanOverlays,
|
||
onError: notifyError,
|
||
});
|
||
}
|
||
|
||
function skipDurationBody(planDate, extra = {}, title) {
|
||
const skip = global.WespDailyPlanSkipDuration;
|
||
if (!skip) return Promise.resolve(null);
|
||
return skip.skipDurationBody(planDate, extra, title);
|
||
}
|
||
|
||
async function openReplaceIngredientModal(recipeId, ingredientId, componentId, label) {
|
||
const planDate = planDateValue();
|
||
const replaceModal = global.WespDailyPlanReplaceModal;
|
||
if (!replaceModal) return;
|
||
await replaceModal.open({
|
||
componentId,
|
||
label,
|
||
overlayHost: planOverlayHost() || document.body,
|
||
onError: notifyError,
|
||
onPick: async (replacementComponentId, replacementName) => {
|
||
const body = await skipDurationBody(
|
||
planDate,
|
||
{ recipeId, ingredientId, replacementComponentId },
|
||
"На какой срок заменить?"
|
||
);
|
||
if (!body) return;
|
||
const resp = await fetch("/api/daily-plan/replacements/ingredients", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось заменить компонент");
|
||
}
|
||
await loadPlan();
|
||
notifySuccess(`Компонент заменён на «${replacementName}»`);
|
||
},
|
||
});
|
||
}
|
||
|
||
async function undoReplaceIngredient(recipeId, ingredientId) {
|
||
const planDate = planDateValue();
|
||
try {
|
||
const params = new URLSearchParams({
|
||
recipe_id: recipeId,
|
||
ingredient_id: ingredientId,
|
||
date: planDate,
|
||
});
|
||
const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params.toString()}`, {
|
||
method: "DELETE",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось отменить замену");
|
||
}
|
||
await loadPlan();
|
||
notifySuccess("Замена отменена");
|
||
} catch (err) {
|
||
notifyError(err.message, "Не удалось отменить замену");
|
||
}
|
||
}
|
||
|
||
async function undoComponentNormAdjustment(componentId) {
|
||
const planDate = planDateValue();
|
||
try {
|
||
const params = new URLSearchParams({
|
||
component_id: componentId,
|
||
date: planDate,
|
||
});
|
||
const resp = await fetch(`/api/daily-plan/adjustments/components?${params.toString()}`, {
|
||
method: "DELETE",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось вернуть норму");
|
||
}
|
||
await loadPlan();
|
||
notifySuccess("Норма возвращена к мастеру");
|
||
if (typeof global.reloadAfterSkipRestore === "function") {
|
||
global.reloadAfterSkipRestore();
|
||
}
|
||
} catch (err) {
|
||
notifyError(err.message, "Не удалось вернуть норму");
|
||
}
|
||
}
|
||
|
||
async function skipTrip(recipeId, recipeName) {
|
||
const planDate = planDateValue();
|
||
const body = await skipDurationBody(planDate, { recipeId });
|
||
if (!body) return;
|
||
try {
|
||
const resp = await fetch("/api/daily-plan/skips", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось исключить рейс");
|
||
}
|
||
await loadPlan();
|
||
showUndoToast(
|
||
"daily-plan-unskip-trip",
|
||
`data-recipe-id="${escapeHtml(recipeId)}"`,
|
||
`Рейс «${escapeHtml(recipeName)}» убран из плана.`
|
||
);
|
||
} catch (err) {
|
||
notifyError(err.message, "Не удалось исключить рейс");
|
||
}
|
||
}
|
||
|
||
async function unskipTrip(recipeId) {
|
||
const planDate = planDateValue();
|
||
try {
|
||
const params = new URLSearchParams({ recipe_id: recipeId, date: planDate });
|
||
const resp = await fetch(`/api/daily-plan/skips?${params.toString()}`, {
|
||
method: "DELETE",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось вернуть рейс");
|
||
}
|
||
dismissUndoToast();
|
||
await loadPlan();
|
||
notifySuccess("Рейс снова в плане");
|
||
} catch (err) {
|
||
notifyError(err.message, "Не удалось вернуть рейс");
|
||
}
|
||
}
|
||
|
||
async function skipIngredient(recipeId, ingredientId, label) {
|
||
const planDate = planDateValue();
|
||
const body = await skipDurationBody(planDate, { recipeId, ingredientId });
|
||
if (!body) return;
|
||
try {
|
||
const resp = await fetch("/api/daily-plan/skips/ingredients", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось убрать компонент");
|
||
}
|
||
await loadPlan();
|
||
showUndoToast(
|
||
"daily-plan-unskip-ingredient",
|
||
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`,
|
||
`Компонент «${escapeHtml(label)}» убран из плана.`
|
||
);
|
||
} catch (err) {
|
||
notifyError(err.message, "Не удалось убрать компонент");
|
||
}
|
||
}
|
||
|
||
async function unskipIngredient(recipeId, ingredientId) {
|
||
const planDate = planDateValue();
|
||
try {
|
||
const params = new URLSearchParams({
|
||
recipe_id: recipeId,
|
||
ingredient_id: ingredientId,
|
||
date: planDate,
|
||
});
|
||
const resp = await fetch(`/api/daily-plan/skips/ingredients?${params.toString()}`, {
|
||
method: "DELETE",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось вернуть компонент");
|
||
}
|
||
dismissUndoToast();
|
||
await loadPlan();
|
||
notifySuccess("Компонент снова в плане");
|
||
} catch (err) {
|
||
notifyError(err.message, "Не удалось вернуть компонент");
|
||
}
|
||
}
|
||
|
||
async function skipUnloadingGroup(recipeId, groupId, label) {
|
||
const planDate = planDateValue();
|
||
const body = await skipDurationBody(planDate, { recipeId, unloadingGroupId: groupId });
|
||
if (!body) return;
|
||
try {
|
||
const resp = await fetch("/api/daily-plan/skips/unloading-groups", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось убрать группу");
|
||
}
|
||
await loadPlan();
|
||
showUndoToast(
|
||
"daily-plan-unskip-unloading-group",
|
||
`data-recipe-id="${escapeHtml(recipeId)}" data-group-id="${escapeHtml(groupId)}"`,
|
||
`Группа «${escapeHtml(label)}» убрана из плана.`
|
||
);
|
||
} catch (err) {
|
||
notifyError(err.message, "Не удалось убрать группу");
|
||
}
|
||
}
|
||
|
||
async function unskipUnloadingGroup(recipeId, groupId) {
|
||
const planDate = planDateValue();
|
||
try {
|
||
const params = new URLSearchParams({
|
||
recipe_id: recipeId,
|
||
unloading_group_id: groupId,
|
||
date: planDate,
|
||
});
|
||
const resp = await fetch(
|
||
`/api/daily-plan/skips/unloading-groups?${params.toString()}`,
|
||
{ method: "DELETE", headers: { Accept: "application/json" } }
|
||
);
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Не удалось вернуть группу");
|
||
}
|
||
dismissUndoToast();
|
||
await loadPlan();
|
||
notifySuccess("Группа снова в плане");
|
||
} catch (err) {
|
||
notifyError(err.message, "Не удалось вернуть группу");
|
||
}
|
||
}
|
||
|
||
function dismissUndoToast() {
|
||
if (undoToastTimer) {
|
||
clearTimeout(undoToastTimer);
|
||
undoToastTimer = null;
|
||
}
|
||
rootEl?.querySelector("[data-daily-plan-undo]")?.remove();
|
||
}
|
||
|
||
function showUndoToast(unskipAction, dataAttrs, message) {
|
||
dismissUndoToast();
|
||
const toast = document.createElement("div");
|
||
toast.className = "zt-k-hub-plan__undo-toast";
|
||
toast.dataset.dailyPlanUndo = "1";
|
||
toast.innerHTML =
|
||
`<span>${message}</span>` +
|
||
`<button type="button" class="btn btn-sm btn-link zt-k-hub-plan__undo-btn" ` +
|
||
`data-action="${unskipAction}" ${dataAttrs}>Отменить</button>`;
|
||
rootEl?.querySelector(".zt-k-hub-plan")?.appendChild(toast);
|
||
undoToastTimer = setTimeout(() => dismissUndoToast(), 8000);
|
||
}
|
||
|
||
async function loadDispensers() {
|
||
try {
|
||
const resp = await fetch("/api/feed_dispensers?limit=200");
|
||
if (!resp.ok) throw new Error("dispensers");
|
||
const data = await resp.json();
|
||
dispensers = Array.isArray(data) ? data : [];
|
||
if (!dispenserEl) return;
|
||
if (!dispensers.length) {
|
||
dispenserEl.innerHTML = '<option value="">Нет кормораздатчиков</option>';
|
||
renderEmpty("Добавьте кормораздатчик в разделе «Кормораздатчики».");
|
||
return;
|
||
}
|
||
const saved = (() => {
|
||
try {
|
||
return localStorage.getItem(DAILY_PLAN_DISPENSER_KEY) || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
})();
|
||
const dispenserItems = dispensers.filter((d) => d.type !== "mill");
|
||
const millItems = dispensers.filter((d) => d.type === "mill");
|
||
let options =
|
||
`<option value="${ALL_DISPENSERS_ID}"${saved === ALL_DISPENSERS_ID ? " selected" : ""}>Все кормораздатчики</option>`;
|
||
if (dispenserItems.length) {
|
||
options += '<optgroup label="Кормораздатчики">';
|
||
options += dispenserItems
|
||
.map((d) => {
|
||
const id = escapeHtml(d.id);
|
||
const name = escapeHtml(d.name || d.id);
|
||
const selected = d.id === saved ? " selected" : "";
|
||
return `<option value="${id}"${selected}>${name}</option>`;
|
||
})
|
||
.join("");
|
||
options += "</optgroup>";
|
||
}
|
||
options += `<option value="${ALL_MILLS_ID}"${saved === ALL_MILLS_ID ? " selected" : ""}>Все кормоцеха</option>`;
|
||
if (millItems.length) {
|
||
options += '<optgroup label="Кормоцеха">';
|
||
options += millItems
|
||
.map((d) => {
|
||
const id = escapeHtml(d.id);
|
||
const name = escapeHtml(d.name || d.id);
|
||
const selected = d.id === saved ? " selected" : "";
|
||
return `<option value="${id}"${selected}>${name}</option>`;
|
||
})
|
||
.join("");
|
||
options += "</optgroup>";
|
||
}
|
||
dispenserEl.innerHTML = options;
|
||
if (!dispenserEl.value) {
|
||
dispenserEl.value = saved || ALL_DISPENSERS_ID;
|
||
}
|
||
loadPlan();
|
||
} catch {
|
||
if (dispenserEl) {
|
||
dispenserEl.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||
}
|
||
renderEmpty("Не удалось загрузить список кормораздатчиков.");
|
||
}
|
||
}
|
||
|
||
function buildPlanUrl(pdf) {
|
||
const params = new URLSearchParams();
|
||
if (dateEl?.value) params.set("date", dateEl.value);
|
||
if (dispenserEl?.value) params.set("dispenser_id", dispenserEl.value);
|
||
const base = pdf ? "/api/daily-plan/pdf" : "/api/daily-plan";
|
||
return `${base}?${params.toString()}`;
|
||
}
|
||
|
||
async function loadPlan() {
|
||
if (!contentEl) return;
|
||
if (!dispenserEl?.value) {
|
||
renderEmpty("Выберите кормораздатчик.");
|
||
return;
|
||
}
|
||
contentEl.innerHTML =
|
||
'<div class="zt-k-hub-plan__loading text-muted small">Загрузка плана…</div>';
|
||
try {
|
||
const resp = await fetch(buildPlanUrl(false));
|
||
if (!resp.ok) {
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.message || "Ошибка загрузки");
|
||
}
|
||
const plan = await resp.json();
|
||
renderPlan(plan);
|
||
} catch (err) {
|
||
renderEmpty(global.WespUserMessages?.safe?.(err.message, "Не удалось загрузить план.") || "Не удалось загрузить план.");
|
||
}
|
||
}
|
||
|
||
function renderEmpty(message) {
|
||
if (!contentEl) return;
|
||
contentEl.innerHTML =
|
||
'<div class="zt-k-hub-plan__empty">' +
|
||
'<i class="fas fa-calendar-day"></i>' +
|
||
`<p>${escapeHtml(message)}</p></div>`;
|
||
}
|
||
|
||
function renderReplaceBtn(recipeId, ing) {
|
||
if (!ing.componentId) return "";
|
||
const label = ing.originalName || ing.name || "Компонент";
|
||
return renderRowActionBtn(
|
||
"daily-plan-replace-ingredient",
|
||
`data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ing.id)}" ` +
|
||
`data-component-id="${escapeHtml(ing.componentId)}" data-label="${escapeHtml(label)}"`,
|
||
"Заменить компонент",
|
||
"fa-exchange-alt"
|
||
);
|
||
}
|
||
|
||
function renderTotalsColumn(totals, grandTotalKg) {
|
||
if (!totals.length) {
|
||
return (
|
||
'<aside class="zt-k-hub-plan__totals-col">' +
|
||
'<h3 class="zt-k-hub-plan__col-title">Итого по компонентам</h3>' +
|
||
'<p class="zt-k-hub-plan__col-empty text-muted">Нет данных</p></aside>'
|
||
);
|
||
}
|
||
let rows = "";
|
||
totals.forEach((row) => {
|
||
rows += `<tr><td>${escapeHtml(row.name)}</td><td>${escapeHtml(formatKg(row.totalKg))}</td></tr>`;
|
||
});
|
||
const grand =
|
||
grandTotalKg ??
|
||
totals.reduce((sum, row) => sum + Number(row.totalKg || 0), 0);
|
||
return (
|
||
'<aside class="zt-k-hub-plan__totals-col">' +
|
||
'<h3 class="zt-k-hub-plan__col-title">Итого по компонентам</h3>' +
|
||
'<div class="zt-k-hub-plan__col-scroll">' +
|
||
'<table class="zt-k-hub-plan-table zt-k-hub-plan-table--totals">' +
|
||
"<thead><tr><th>Компонент</th><th>Всего, кг</th></tr></thead>" +
|
||
`<tbody>${rows}</tbody>` +
|
||
`<tfoot><tr class="zt-k-hub-plan-table__total-row"><th>Итого</th><th>${escapeHtml(formatKg(grand))}</th></tr></tfoot>` +
|
||
"</table></div></aside>"
|
||
);
|
||
}
|
||
|
||
function renderSkippedSection(plan) {
|
||
const skippedTrips = plan.skippedTrips || [];
|
||
if (!skippedTrips.length) return "";
|
||
let rows = "";
|
||
skippedTrips.forEach((trip) => {
|
||
rows +=
|
||
'<li class="zt-k-hub-plan-skipped__item">' +
|
||
`<span>${renderSkipDot()}<span class="text-muted">Рейс:</span> ${escapeHtml(trip.recipeName)}</span>` +
|
||
'<button type="button" class="btn btn-sm btn-outline-success" ' +
|
||
'data-action="daily-plan-unskip-trip" ' +
|
||
`data-recipe-id="${escapeHtml(trip.recipeId)}" title="Вернуть в план">` +
|
||
'<i class="fas fa-undo"></i> Вернуть</button></li>';
|
||
});
|
||
return (
|
||
'<section class="zt-k-hub-plan-skipped">' +
|
||
'<h3 class="zt-k-hub-plan-skipped__title">Исключено из плана</h3>' +
|
||
`<ul class="zt-k-hub-plan-skipped__list">${rows}</ul></section>`
|
||
);
|
||
}
|
||
|
||
function renderTripsColumn(periods) {
|
||
let html = '<div class="zt-k-hub-plan__trips-col"><div class="zt-k-hub-plan__col-scroll">';
|
||
periods.forEach((period) => {
|
||
const trips = period.trips || [];
|
||
if (!trips.length) return;
|
||
html +=
|
||
`<section class="zt-k-hub-plan__period"><h3 class="zt-k-hub-plan__period-title">${escapeHtml(period.name)}</h3><div class="zt-k-hub-plan__trips">`;
|
||
trips.forEach((trip) => {
|
||
const recipeId = escapeHtml(trip.recipeId);
|
||
const hasAdjustedNorm = (trip.ingredients || []).some((ing) => ing.adjustedToday);
|
||
const hasReplacedIngredient = (trip.ingredients || []).some((ing) => ing.replacedToday);
|
||
const adjustBadge = hasAdjustedNorm
|
||
? ' <span class="recipe-adjust-badge" title="Сегодня изменили норму в плане">' +
|
||
'<span class="zt-k-hub-plan-row__adjust-dot" aria-hidden="true"></span>сегодня изменили норму</span>'
|
||
: "";
|
||
const replaceBadge = hasReplacedIngredient
|
||
? ' <span class="recipe-skip-badge" title="Сегодня заменили компонент в плане">' +
|
||
'<span class="recipe-skip-badge__dot" aria-hidden="true"></span>заменён компонент</span>'
|
||
: "";
|
||
const ings = (trip.ingredients || [])
|
||
.map((ing) => {
|
||
const ingId = escapeHtml(ing.id);
|
||
const name = escapeHtml(ing.name);
|
||
const skipped = Boolean(ing.skippedToday);
|
||
const replaced = Boolean(ing.replacedToday);
|
||
const adjusted = Boolean(ing.adjustedToday);
|
||
const rowClass = skipped
|
||
? "zt-k-hub-plan-row--skipped"
|
||
: replaced
|
||
? "zt-k-hub-plan-row--replaced"
|
||
: adjusted
|
||
? "zt-k-hub-plan-row--adjusted"
|
||
: "";
|
||
const replaceTitle = replacementRowTitle(ing);
|
||
const adjustTitle = adjustmentRowTitle(ing);
|
||
const skipHint = skipped ? skippedIngredientHint(ing) : "";
|
||
const nameCell = skipped
|
||
? `<td class="zt-k-hub-plan-row--skipped">${renderSkipDot()}${name}</td>`
|
||
: replaced
|
||
? `<td class="zt-k-hub-plan-row--replaced" title="${escapeHtml(replaceTitle)}">${renderReplaceDot(replaceTitle)}${name}</td>`
|
||
: adjusted
|
||
? `<td class="zt-k-hub-plan-row--adjusted" title="${escapeHtml(adjustTitle)}">${renderAdjustDot(adjustTitle)}${name}</td>`
|
||
: `<td>${name}</td>`;
|
||
const weightCell = skipped
|
||
? renderSkippedValueCell(skipHint)
|
||
: `<td>${escapeHtml(formatKg(ing.weightPerHead))}</td>`;
|
||
const totalCell = skipped
|
||
? renderSkippedValueCell(skipHint)
|
||
: `<td>${escapeHtml(formatKg(ing.totalKg))}</td>`;
|
||
let actionCell = "";
|
||
if (ing.id) {
|
||
if (skipped) {
|
||
actionCell = renderRowActionBtn(
|
||
"daily-plan-unskip-ingredient",
|
||
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`,
|
||
"Вернуть в план",
|
||
"fa-undo"
|
||
);
|
||
} else {
|
||
if (replaced) {
|
||
actionCell += renderRowActionBtn(
|
||
"daily-plan-undo-replace-ingredient",
|
||
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`,
|
||
"Отменить замену",
|
||
"fa-undo"
|
||
);
|
||
} else if (adjusted && ing.componentId) {
|
||
actionCell += renderRowActionBtn(
|
||
"daily-plan-undo-adjust-norm",
|
||
`data-component-id="${escapeHtml(ing.componentId)}"`,
|
||
"Вернуть норму",
|
||
"fa-undo"
|
||
);
|
||
} else {
|
||
actionCell += renderReplaceBtn(recipeId, ing);
|
||
}
|
||
actionCell += renderSkipActionBtn(
|
||
"daily-plan-skip-ingredient",
|
||
`data-recipe-id="${recipeId}" data-ingredient-id="${ingId}" data-label="${escapeHtml(ing.originalName || ing.name)}"`,
|
||
"Убрать компонент из плана на этот день"
|
||
);
|
||
}
|
||
}
|
||
return (
|
||
`<tr class="${rowClass}">` +
|
||
`${nameCell}${weightCell}${totalCell}` +
|
||
`<td class="zt-k-hub-plan-table__actions zt-k-hub-plan-table__actions--multi">${actionCell}</td></tr>`
|
||
);
|
||
})
|
||
.join("");
|
||
const groups = (trip.unloadingGroups || [])
|
||
.map((g) => {
|
||
const groupId = escapeHtml(g.id);
|
||
const name = escapeHtml(g.name);
|
||
const skipped = Boolean(g.skippedToday);
|
||
const nameCell = skipped
|
||
? `<td class="zt-k-hub-plan-row--skipped">${renderSkipDot()}${name}</td>`
|
||
: `<td>${name}</td>`;
|
||
const weightCell = skipped
|
||
? '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>'
|
||
: `<td>${escapeHtml(g.weightKg)}</td>`;
|
||
const distCell = skipped
|
||
? '<td class="zt-k-hub-plan-row--skipped text-muted">—</td>'
|
||
: `<td>${escapeHtml(formatDistribution(g))}</td>`;
|
||
let actionCell = "";
|
||
if (g.id) {
|
||
if (skipped) {
|
||
actionCell = renderRowActionBtn(
|
||
"daily-plan-unskip-unloading-group",
|
||
`data-recipe-id="${recipeId}" data-group-id="${groupId}"`,
|
||
"Вернуть в план",
|
||
"fa-undo"
|
||
);
|
||
} else {
|
||
actionCell = renderSkipActionBtn(
|
||
"daily-plan-skip-unloading-group",
|
||
`data-recipe-id="${recipeId}" data-group-id="${groupId}" data-label="${name}"`,
|
||
"Убрать группу из плана на этот день"
|
||
);
|
||
}
|
||
}
|
||
return (
|
||
`<tr class="${skipped ? "zt-k-hub-plan-row--skipped" : ""}">` +
|
||
`${nameCell}${weightCell}${distCell}` +
|
||
`<td class="zt-k-hub-plan-table__actions">${actionCell}</td></tr>`
|
||
);
|
||
})
|
||
.join("");
|
||
html +=
|
||
'<article class="zt-k-hub-plan-trip zt-card">' +
|
||
'<div class="zt-k-hub-plan-trip__head">' +
|
||
`<h4 class="zt-k-hub-plan-trip__title">${escapeHtml(trip.recipeName)}${replaceBadge}${adjustBadge}` +
|
||
` <span class="text-muted">(${escapeHtml(trip.headsPerTrip)} гол., ${escapeHtml(trip.mixingTimeSec)} с)</span></h4>` +
|
||
'<button type="button" class="btn btn-sm recipe-table-delete-btn zt-k-hub-plan-trip__skip" ' +
|
||
'data-action="daily-plan-skip-trip" ' +
|
||
`data-recipe-id="${recipeId}" ` +
|
||
`data-recipe-name="${escapeHtml(trip.recipeName)}" ` +
|
||
'title="Убрать из плана на этот день">' +
|
||
'<i class="fas fa-trash"></i></button></div>' +
|
||
'<table class="zt-k-hub-plan-table"><thead><tr><th>Компонент</th><th>кг/гол</th><th>Всего, кг</th><th></th></tr></thead>' +
|
||
`<tbody>${ings || '<tr><td colspan="4" class="text-muted">—</td></tr>'}</tbody>` +
|
||
`<tfoot><tr class="zt-k-hub-plan-table__total-row"><td>Итого по рейсу</td><td></td><td>${escapeHtml(formatKg(trip.totalWeightKg))}</td><td></td></tr></tfoot></table>`;
|
||
if (groups) {
|
||
html +=
|
||
'<p class="zt-k-hub-plan-trip__sub">Выгрузка</p>' +
|
||
'<table class="zt-k-hub-plan-table"><thead><tr><th>Группа</th><th>кг</th><th>Распределение</th><th></th></tr></thead>' +
|
||
`<tbody>${groups}</tbody></table>`;
|
||
}
|
||
html += "</article>";
|
||
});
|
||
html += "</div></section>";
|
||
});
|
||
html += "</div></div>";
|
||
return html;
|
||
}
|
||
|
||
function renderPlan(plan) {
|
||
if (!contentEl) return;
|
||
const periods = plan.periods || [];
|
||
const skippedTrips = plan.skippedTrips || [];
|
||
const hasTrips = periods.some((p) => (p.trips || []).length);
|
||
const hasSkipped = skippedTrips.length;
|
||
if (!hasTrips && !hasSkipped) {
|
||
renderEmpty("Нет периодов или рейсов для выбранного кормораздатчика.");
|
||
return;
|
||
}
|
||
|
||
contentEl.innerHTML =
|
||
'<div class="zt-k-hub-plan__split">' +
|
||
renderTotalsColumn(plan.ingredientTotals || [], plan.ingredientGrandTotalKg) +
|
||
renderTripsColumn(periods) +
|
||
"</div>" +
|
||
renderSkippedSection(plan);
|
||
}
|
||
|
||
function downloadPdf() {
|
||
if (!dispenserEl?.value) return;
|
||
global.open(buildPlanUrl(true), "_blank", "noopener");
|
||
}
|
||
|
||
function destroy() {
|
||
dismissUndoToast();
|
||
dismissAllPlanOverlays();
|
||
if (rootEl) {
|
||
rootEl.removeEventListener("click", onPanelClick);
|
||
rootEl.innerHTML = "";
|
||
}
|
||
rootEl = null;
|
||
dateEl = null;
|
||
dispenserEl = null;
|
||
contentEl = null;
|
||
}
|
||
|
||
return {
|
||
mount,
|
||
destroy,
|
||
dismissAllPlanOverlays,
|
||
reload: loadPlan,
|
||
getSelectedDispenserId,
|
||
getSelectedPlanDate,
|
||
};
|
||
}
|
||
|
||
let activePanel = null;
|
||
|
||
global.WespDailyPlanPanel = {
|
||
createDailyPlanPanel,
|
||
_setActivePanel(panel) {
|
||
activePanel = panel || null;
|
||
},
|
||
getSelectedDispenserId() {
|
||
return activePanel?.getSelectedDispenserId?.() || "";
|
||
},
|
||
getSelectedPlanDate() {
|
||
return activePanel?.getSelectedPlanDate?.() || todayIso();
|
||
},
|
||
reload() {
|
||
return activePanel?.reload?.();
|
||
},
|
||
};
|
||
})(typeof window !== "undefined" ? window : globalThis);
|