Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,930 @@
|
||||
/**
|
||||
* Панель «План на день» внутри 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);
|
||||
Reference in New Issue
Block a user