273 lines
11 KiB
JavaScript
273 lines
11 KiB
JavaScript
(function (global) {
|
||
"use strict";
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function createLabMasterPanel() {
|
||
let root = null;
|
||
let recipeId = null;
|
||
let state = null;
|
||
let components = [];
|
||
let profiles = [];
|
||
async function api(path, opts) {
|
||
const resp = await fetch(path, opts);
|
||
const data = await resp.json().catch(() => ({}));
|
||
if (!resp.ok) throw new Error(data.message || "Ошибка API");
|
||
return data;
|
||
}
|
||
|
||
async function ensureCatalogs(rationType) {
|
||
const [compResp, profData] = await Promise.all([
|
||
components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()),
|
||
api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`),
|
||
]);
|
||
if (Array.isArray(compResp)) components = compResp;
|
||
profiles = profData.profiles || [];
|
||
}
|
||
|
||
async function loadRecipes() {
|
||
const data = await api("/api/lab/recipes");
|
||
return data.recipes || [];
|
||
}
|
||
|
||
async function loadRation(id) {
|
||
return api(`/api/lab/rations/${encodeURIComponent(id)}`);
|
||
}
|
||
|
||
function componentOptionsHtml(selectedId) {
|
||
const opts = ['<option value="">— компонент —</option>'];
|
||
components.forEach((c) => {
|
||
const sel = String(c.id) === String(selectedId) ? " selected" : "";
|
||
opts.push(`<option value="${escapeHtml(c.id)}"${sel}>${escapeHtml(c.name)}</option>`);
|
||
});
|
||
return opts.join("");
|
||
}
|
||
|
||
function profileOptionsHtml(selectedId) {
|
||
const opts = ['<option value="">— профиль норм —</option>'];
|
||
profiles.forEach((p) => {
|
||
const sel = String(p.id) === String(selectedId) ? " selected" : "";
|
||
opts.push(
|
||
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(p.label)} (${escapeHtml(p.rationType)})</option>`
|
||
);
|
||
});
|
||
return opts.join("");
|
||
}
|
||
|
||
function render() {
|
||
if (!root) return;
|
||
const indicators = (state?.rationResults?.indicators || []).slice(0, 8);
|
||
const lines = state?.lines || [];
|
||
root.innerHTML =
|
||
`<div class="zt-k-hub-lab">` +
|
||
`<div class="zt-k-hub-lab__toolbar">` +
|
||
`<label class="zt-k-hub-lab__field">Рецепт <select data-lab-recipe-select class="form-select form-select-sm"></select></label>` +
|
||
`<label class="zt-k-hub-lab__field">Профиль <select data-lab-profile-select class="form-select form-select-sm">${profileOptionsHtml(state?.animalProfileId)}</select></label>` +
|
||
`<label class="zt-k-hub-lab__field">Тип <select data-lab-ration-type class="form-select form-select-sm">` +
|
||
`<option value="BEEF" ${state?.rationType === "BEEF" ? "selected" : ""}>Мясное</option>` +
|
||
`<option value="DAIRY" ${state?.rationType === "DAIRY" ? "selected" : ""}>Дойное</option>` +
|
||
`</select></label>` +
|
||
`<button type="button" class="btn btn-sm btn-primary" data-action="lab-recalculate">Пересчёт</button>` +
|
||
`<button type="button" class="btn btn-sm btn-outline-primary" data-action="lab-save">Сохранить</button>` +
|
||
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-add-line">+ Строка</button>` +
|
||
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-print-ration" title="Печать рациона">Печать</button>` +
|
||
`<button type="button" class="btn btn-sm btn-outline-secondary" data-action="lab-print-compound" title="Печать комбикорма">Комб.</button>` +
|
||
`</div>` +
|
||
`<div class="zt-k-hub-lab__subtitle">Зоотехнический мастер · ${escapeHtml(state?.recipeName || "")}</div>` +
|
||
`<table class="table table-sm zt-k-hub-lab__table"><thead><tr>` +
|
||
`<th>Компонент WESP</th><th>кг/день</th><th>В рац.</th><th>Комб.</th><th></th></tr></thead><tbody>` +
|
||
(lines.length
|
||
? lines
|
||
.map(
|
||
(l, i) =>
|
||
`<tr data-line-idx="${i}">` +
|
||
`<td><select class="form-select form-select-sm" data-field="componentId">${componentOptionsHtml(l.componentId)}</select></td>` +
|
||
`<td><input class="form-control form-control-sm" data-field="dailyKg" type="number" step="0.01" value="${l.dailyKg ?? ""}"></td>` +
|
||
`<td class="text-center"><input type="checkbox" data-field="inRation" ${l.inRation ? "checked" : ""}></td>` +
|
||
`<td class="text-center"><input type="checkbox" data-field="inCompound" ${l.inCompound ? "checked" : ""}></td>` +
|
||
`<td><button type="button" class="btn btn-sm btn-link text-danger p-0" data-action="lab-remove-line" data-line-idx="${i}" title="Удалить">×</button></td>` +
|
||
`</tr>`
|
||
)
|
||
.join("")
|
||
: `<tr><td colspan="5" class="text-muted text-center">Нет строк — добавьте или создайте из рецепта</td></tr>`) +
|
||
`</tbody></table>` +
|
||
`<div class="zt-k-hub-lab__indicators">` +
|
||
indicators
|
||
.map(
|
||
(ind) =>
|
||
`<span class="badge bg-light text-dark me-1 mb-1">${escapeHtml(ind.label)}: ${escapeHtml(ind.content)} ${escapeHtml(ind.unit)}</span>`
|
||
)
|
||
.join("") +
|
||
`</div></div>`;
|
||
|
||
const sel = root.querySelector("[data-lab-recipe-select]");
|
||
if (sel && sel.options.length === 0) {
|
||
loadRecipes().then((recipes) => {
|
||
sel.innerHTML =
|
||
'<option value="">—</option>' +
|
||
recipes
|
||
.map(
|
||
(r) =>
|
||
`<option value="${escapeHtml(r.id)}" ${r.id === recipeId ? "selected" : ""}>${escapeHtml(r.name)}</option>`
|
||
)
|
||
.join("");
|
||
});
|
||
}
|
||
}
|
||
|
||
async function reload() {
|
||
if (!recipeId) return;
|
||
try {
|
||
state = await loadRation(recipeId);
|
||
await ensureCatalogs(state.rationType);
|
||
if (!state.exists) {
|
||
const ok = await global.WespDialog?.confirm?.(
|
||
"Создать мастер из текущего рецепта?",
|
||
{ title: "Мастер рациона" }
|
||
);
|
||
if (ok) {
|
||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, {
|
||
method: "POST",
|
||
});
|
||
state = await loadRation(recipeId);
|
||
await ensureCatalogs(state.rationType);
|
||
} else {
|
||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, {
|
||
method: "POST",
|
||
});
|
||
state = await loadRation(recipeId);
|
||
await ensureCatalogs(state.rationType);
|
||
}
|
||
}
|
||
render();
|
||
} catch (e) {
|
||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
|
||
}
|
||
}
|
||
|
||
function collectLines() {
|
||
if (!root || !state) return [];
|
||
return (state.lines || []).map((line, i) => {
|
||
const row = root.querySelector(`tr[data-line-idx="${i}"]`);
|
||
if (!row) return line;
|
||
const componentId = row.querySelector('[data-field="componentId"]')?.value || null;
|
||
const comp = components.find((c) => String(c.id) === String(componentId));
|
||
return {
|
||
...line,
|
||
componentId,
|
||
ingredientName: comp?.name || line.ingredientName,
|
||
dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null,
|
||
inRation: row.querySelector('[data-field="inRation"]')?.checked,
|
||
inCompound: row.querySelector('[data-field="inCompound"]')?.checked,
|
||
};
|
||
});
|
||
}
|
||
|
||
function collectPayload() {
|
||
const rationType = root.querySelector("[data-lab-ration-type]")?.value || state?.rationType;
|
||
return {
|
||
lines: collectLines(),
|
||
rationType,
|
||
animalProfileId: root.querySelector("[data-lab-profile-select]")?.value || null,
|
||
};
|
||
}
|
||
|
||
return {
|
||
mount(el) {
|
||
root = el;
|
||
root.addEventListener("click", async (ev) => {
|
||
const btn = ev.target.closest("[data-action]");
|
||
if (!btn || !recipeId) return;
|
||
const action = btn.getAttribute("data-action");
|
||
try {
|
||
if (action === "lab-add-line") {
|
||
state.lines = [...(state?.lines || []), {
|
||
componentId: "",
|
||
ingredientName: "",
|
||
dailyKg: 0,
|
||
inRation: true,
|
||
inCompound: false,
|
||
}];
|
||
render();
|
||
return;
|
||
}
|
||
if (action === "lab-remove-line") {
|
||
const idx = parseInt(btn.getAttribute("data-line-idx"), 10);
|
||
if (!Number.isNaN(idx)) {
|
||
state.lines = (state.lines || []).filter((_, i) => i !== idx);
|
||
render();
|
||
}
|
||
return;
|
||
}
|
||
if (action === "lab-recalculate") {
|
||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, {
|
||
method: "POST",
|
||
});
|
||
await reload();
|
||
}
|
||
if (action === "lab-save") {
|
||
await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(collectPayload()),
|
||
});
|
||
await reload();
|
||
global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер сохранён");
|
||
}
|
||
if (action === "lab-print-ration" || action === "lab-print-compound") {
|
||
const mode = action === "lab-print-compound" ? "compound" : "ration";
|
||
global.open?.(
|
||
`/api/lab/rations/${encodeURIComponent(recipeId)}/print?mode=${mode}`,
|
||
"_blank",
|
||
"noopener"
|
||
);
|
||
}
|
||
} catch (e) {
|
||
global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" });
|
||
}
|
||
});
|
||
root.addEventListener("change", async (ev) => {
|
||
if (ev.target.matches("[data-lab-recipe-select]")) {
|
||
recipeId = ev.target.value;
|
||
reload();
|
||
return;
|
||
}
|
||
if (ev.target.matches("[data-lab-ration-type]")) {
|
||
const rt = ev.target.value;
|
||
state = { ...state, rationType: rt };
|
||
profiles = [];
|
||
await ensureCatalogs(rt);
|
||
render();
|
||
return;
|
||
}
|
||
if (ev.target.matches("[data-lab-profile-select]")) {
|
||
const profile = profiles.find((p) => String(p.id) === String(ev.target.value));
|
||
if (profile?.rationType) {
|
||
state = { ...state, rationType: profile.rationType, animalProfileId: profile.id };
|
||
const typeSel = root.querySelector("[data-lab-ration-type]");
|
||
if (typeSel) typeSel.value = profile.rationType;
|
||
}
|
||
}
|
||
});
|
||
},
|
||
destroy() {
|
||
root = null;
|
||
state = null;
|
||
},
|
||
reload,
|
||
setRecipeId(id) {
|
||
recipeId = id;
|
||
reload();
|
||
},
|
||
};
|
||
}
|
||
|
||
global.WespLabMasterPanel = { createLabMasterPanel };
|
||
})(window);
|