Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
let diffByComponent = new Map();
|
||||
let hooked = false;
|
||||
|
||||
function getActiveRecipeId() {
|
||||
const fromEdit = document.getElementById("recipeEdit")?.dataset?.recipeId;
|
||||
if (fromEdit) return fromEdit;
|
||||
return document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId || null;
|
||||
}
|
||||
|
||||
function ensureToolbarButtons() {
|
||||
const toolbar = document.querySelector("#recipeEdit .recipe-section-toolbar");
|
||||
if (!toolbar) return;
|
||||
if (!toolbar.querySelector('[data-action="sync-to-master"]')) {
|
||||
const syncBtn = document.createElement("button");
|
||||
syncBtn.type = "button";
|
||||
syncBtn.className = "btn btn-outline-secondary";
|
||||
syncBtn.setAttribute("data-action", "sync-to-master");
|
||||
syncBtn.innerHTML = '<i class="fas fa-arrow-up me-1"></i>В мастер';
|
||||
toolbar.insertBefore(syncBtn, toolbar.firstChild);
|
||||
}
|
||||
if (!toolbar.querySelector('[data-action="apply-from-master"]')) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn btn-outline-primary";
|
||||
btn.setAttribute("data-action", "apply-from-master");
|
||||
btn.hidden = true;
|
||||
btn.innerHTML = '<i class="fas fa-arrow-down me-1"></i>Из мастера';
|
||||
const addBtn = toolbar.querySelector('[data-action="add-ingredient"]');
|
||||
if (addBtn) toolbar.insertBefore(btn, addBtn);
|
||||
else toolbar.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureKHubButton() {
|
||||
const actions = document.querySelector("#recipeEdit .recipe-edit-header-actions .d-flex");
|
||||
if (!actions || actions.querySelector('[data-action="open-lab-k-hub"]')) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "btn btn-outline-secondary recipe-edit-header-icon-btn";
|
||||
btn.setAttribute("data-action", "open-lab-k-hub");
|
||||
btn.title = "Открыть зоотех-мастер в K-hub";
|
||||
btn.setAttribute("aria-label", "K-hub");
|
||||
btn.innerHTML = '<i class="fas fa-flask" aria-hidden="true"></i>';
|
||||
actions.insertBefore(btn, actions.firstChild);
|
||||
}
|
||||
|
||||
function ensureRationTypeField() {
|
||||
const grid = document.querySelector("#recipeEdit .recipe-form-grid");
|
||||
if (!grid || document.getElementById("rationType")) return;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "form-group";
|
||||
wrap.innerHTML =
|
||||
'<label for="rationType">Тип стада</label>' +
|
||||
'<select class="form-control" id="rationType">' +
|
||||
'<option value="">—</option><option value="DAIRY">Дойное</option><option value="BEEF">Мясное</option>' +
|
||||
"</select>";
|
||||
const tripWrap = document.getElementById("tripPercent")?.closest(".form-group");
|
||||
if (tripWrap?.parentElement === grid) {
|
||||
tripWrap.insertAdjacentElement("afterend", wrap);
|
||||
} else {
|
||||
grid.appendChild(wrap);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDiffStyles() {
|
||||
if (document.getElementById("wesp-lab-diff-styles")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wesp-lab-diff-styles";
|
||||
style.textContent =
|
||||
".lab-diff-badge{margin-left:6px;font-size:.7rem;vertical-align:middle;}" +
|
||||
".lab-diff-badge--warn{background:#fff3cd;color:#664d03;}" +
|
||||
".lab-diff-badge--miss{background:#f8d7da;color:#842029;}" +
|
||||
".lab-recipe-quality{margin-top:4px;font-size:.78rem;color:var(--bs-secondary-color,#6c757d);}" +
|
||||
".lab-recipe-quality__label{font-weight:600;margin-right:4px;}";
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function diffBadgeHtml(reasons) {
|
||||
if (!reasons?.length) return "";
|
||||
const title = reasons.join(", ");
|
||||
const cls = reasons.includes("missing_in_master") || reasons.includes("missing_in_execution")
|
||||
? "lab-diff-badge--miss"
|
||||
: "lab-diff-badge--warn";
|
||||
return `<span class="badge lab-diff-badge ${cls}" title="${title}">мастер</span>`;
|
||||
}
|
||||
|
||||
function applyDiffBadges() {
|
||||
document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach((row) => {
|
||||
const select = row.querySelector(".ingredient-select");
|
||||
const cell = row.querySelector('.ingredient-detail-cell[data-label="Ингредиент"] .recipe-row-name-with-skip');
|
||||
if (!select || !cell) return;
|
||||
cell.querySelectorAll(".lab-diff-badge").forEach((el) => el.remove());
|
||||
const compId = select.value;
|
||||
if (!compId) return;
|
||||
const reasons = diffByComponent.get(String(compId));
|
||||
if (!reasons?.length) return;
|
||||
cell.insertAdjacentHTML("beforeend", diffBadgeHtml(reasons));
|
||||
});
|
||||
}
|
||||
|
||||
function ensureQualityBlock() {
|
||||
const headerText = document.querySelector("#recipeEdit .recipe-edit-header-text");
|
||||
if (!headerText || document.getElementById("labRecipeQuality")) return;
|
||||
const block = document.createElement("div");
|
||||
block.id = "labRecipeQuality";
|
||||
block.className = "lab-recipe-quality";
|
||||
block.hidden = true;
|
||||
block.innerHTML =
|
||||
'<span class="lab-recipe-quality__label">Зоотех:</span>' +
|
||||
'<span class="lab-recipe-quality__items" data-lab-quality-items></span>';
|
||||
headerText.appendChild(block);
|
||||
}
|
||||
|
||||
async function refreshQuality(recipeId) {
|
||||
ensureQualityBlock();
|
||||
const block = document.getElementById("labRecipeQuality");
|
||||
const items = block?.querySelector("[data-lab-quality-items]");
|
||||
if (!block || !items || !recipeId) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}`);
|
||||
if (!resp.ok) throw new Error("no ration");
|
||||
const data = await resp.json();
|
||||
const indicators = (data.rationResults?.indicators || []).slice(0, 4);
|
||||
if (!data.exists || !indicators.length) {
|
||||
block.hidden = true;
|
||||
return;
|
||||
}
|
||||
items.textContent = indicators
|
||||
.map((ind) => `${ind.label}: ${ind.content ?? "—"} ${ind.unit || ""}`.trim())
|
||||
.join(" · ");
|
||||
block.hidden = false;
|
||||
} catch (_) {
|
||||
block.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDiff(recipeId) {
|
||||
const btn = document.querySelector('[data-action="apply-from-master"]');
|
||||
if (!recipeId) return;
|
||||
await refreshQuality(recipeId);
|
||||
if (!btn) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/diff`);
|
||||
const data = await resp.json();
|
||||
diffByComponent = new Map();
|
||||
(data.lines || []).forEach((line) => {
|
||||
if (line.componentId) diffByComponent.set(String(line.componentId), line.reasons || []);
|
||||
});
|
||||
btn.hidden = !data.hasChanges;
|
||||
applyDiffBadges();
|
||||
} catch (_) {
|
||||
btn.hidden = true;
|
||||
diffByComponent = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function patchRecipeLoad() {
|
||||
if (hooked) return;
|
||||
const orig = global.loadRecipeDetails;
|
||||
if (typeof orig !== "function") return;
|
||||
global.loadRecipeDetails = async function (recipeId, options) {
|
||||
const recipeEdit = document.getElementById("recipeEdit");
|
||||
if (recipeEdit && recipeId) recipeEdit.dataset.recipeId = String(recipeId);
|
||||
await orig(recipeId, options);
|
||||
await refreshDiff(recipeId);
|
||||
global.WespLabRecipesPlanOverlay?.decorateRows?.();
|
||||
};
|
||||
hooked = true;
|
||||
}
|
||||
|
||||
function patchRecipeSave() {
|
||||
const orig = global.doSaveRecipe;
|
||||
if (typeof orig !== "function" || orig.__labPatched) return;
|
||||
global.doSaveRecipe = async function (closeAfterSave) {
|
||||
const rationEl = document.getElementById("rationType");
|
||||
const prev = global.__wespLabRationTypeForSave;
|
||||
if (rationEl) {
|
||||
global.__wespLabRationTypeForSave = rationEl.value || null;
|
||||
}
|
||||
try {
|
||||
return await orig(closeAfterSave);
|
||||
} finally {
|
||||
global.__wespLabRationTypeForSave = prev;
|
||||
}
|
||||
};
|
||||
const origFetch = global.fetch.bind(global);
|
||||
global.fetch = async function (input, init) {
|
||||
const url = typeof input === "string" ? input : input?.url;
|
||||
const method = (init?.method || "GET").toUpperCase();
|
||||
if (
|
||||
method === "PUT" &&
|
||||
url &&
|
||||
/\/api\/recipes\/[^/]+$/.test(url) &&
|
||||
init?.body &&
|
||||
global.__wespLabRationTypeForSave !== undefined
|
||||
) {
|
||||
try {
|
||||
const body = JSON.parse(init.body);
|
||||
body.ration_type = global.__wespLabRationTypeForSave || null;
|
||||
init = { ...init, body: JSON.stringify(body) };
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return origFetch(input, init);
|
||||
};
|
||||
global.doSaveRecipe.__labPatched = true;
|
||||
}
|
||||
|
||||
document.addEventListener("click", async (ev) => {
|
||||
const btn = ev.target.closest("[data-action]");
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute("data-action");
|
||||
const recipeId = getActiveRecipeId();
|
||||
|
||||
if (action === "open-lab-k-hub") {
|
||||
global.WespZootechNotificationCenter?.openDailyPlan?.(recipeId || undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "sync-to-master") {
|
||||
if (!recipeId) return;
|
||||
const ok = await global.WespDialog?.confirm?.(
|
||||
"Сохранить текущий рецепт в зоотех-мастер?",
|
||||
{ title: "В мастер" }
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/lab/rations/${encodeURIComponent(recipeId)}/sync-from-execution`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.message || "Ошибка");
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер обновлён из рецепта");
|
||||
await refreshDiff(recipeId);
|
||||
} catch (e) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action !== "apply-from-master") return;
|
||||
if (!recipeId) return;
|
||||
const ok = await global.WespDialog?.confirm?.("Перенести ингредиенты из зоотех-мастера?", {
|
||||
title: "Из мастера",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.message || "Ошибка");
|
||||
if (typeof global.reloadRecipeEditor === "function") {
|
||||
await global.reloadRecipeEditor(recipeId);
|
||||
} else if (typeof global.loadRecipeDetails === "function") {
|
||||
await global.loadRecipeDetails(recipeId);
|
||||
}
|
||||
await refreshDiff(recipeId);
|
||||
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер применён к рецепту");
|
||||
} catch (e) {
|
||||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось применить мастер" });
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("change", (ev) => {
|
||||
if (!ev.target?.matches?.(".ingredient-select")) return;
|
||||
applyDiffBadges();
|
||||
});
|
||||
|
||||
global.WespLabRecipesOverlay = {
|
||||
init() {
|
||||
ensureDiffStyles();
|
||||
ensureToolbarButtons();
|
||||
ensureKHubButton();
|
||||
ensureRationTypeField();
|
||||
patchRecipeLoad();
|
||||
patchRecipeSave();
|
||||
},
|
||||
refreshDiff,
|
||||
refreshQuality,
|
||||
applyDiffBadges,
|
||||
};
|
||||
|
||||
function boot() {
|
||||
global.WespLabRecipesOverlay.init();
|
||||
if (typeof global.loadRecipeDetails === "function") patchRecipeLoad();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})(window);
|
||||
Reference in New Issue
Block a user