Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
(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);
|
||||
Reference in New Issue
Block a user