Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,601 @@
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_OPTIMIZE_KEYS = [
|
||||
"dry_matter",
|
||||
"usp",
|
||||
"nel",
|
||||
"crude_protein",
|
||||
"rnb",
|
||||
"nfc_pct_dm_uk",
|
||||
];
|
||||
|
||||
let catalog = [];
|
||||
let groups = [];
|
||||
let profiles = [];
|
||||
let currentStep = 1;
|
||||
const selections = {};
|
||||
|
||||
function formulateError(message) {
|
||||
global.WespLabHeisenbergPrime?.error?.(message, "formulate");
|
||||
}
|
||||
|
||||
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 || `Наука, чёрт возьми — ошибка ${resp.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatNum(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function diffClass(diff) {
|
||||
if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na";
|
||||
const d = Number(diff);
|
||||
if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok";
|
||||
return "lab-sandbox-diff--bad";
|
||||
}
|
||||
|
||||
function totalSteps() {
|
||||
return groups.length + 1;
|
||||
}
|
||||
|
||||
function groupById(id) {
|
||||
return groups.find((g) => g.id === id);
|
||||
}
|
||||
|
||||
function selectedForGroup(gid) {
|
||||
return Array.from(document.querySelectorAll(`.lab-formulate-gcb[data-group="${gid}"]:checked`)).map(
|
||||
(el) => el.value
|
||||
);
|
||||
}
|
||||
|
||||
function allSelections() {
|
||||
const out = {};
|
||||
groups.forEach((g) => {
|
||||
out[g.id] = selectedForGroup(g.id);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function totalPicked() {
|
||||
return Object.values(allSelections()).reduce((n, arr) => n + arr.length, 0);
|
||||
}
|
||||
|
||||
function componentsForGroup(gid, filterText) {
|
||||
const q = (filterText || "").trim().toLowerCase();
|
||||
return catalog.filter((c) => {
|
||||
if (!c.eligible || c.feedGroup !== gid) return false;
|
||||
if (q && !String(c.name || "").toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderGroupTable(gid, filterText) {
|
||||
const body = document.querySelector(`#labFormulatePane-${gid} tbody`);
|
||||
if (!body) return;
|
||||
const picked = new Set(selectedForGroup(gid));
|
||||
const rows = componentsForGroup(gid, filterText);
|
||||
if (!rows.length) {
|
||||
body.innerHTML =
|
||||
'<tr><td colspan="4" class="lab-sandbox-empty">В этой фракции пусто — нечего готовить</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = rows
|
||||
.map((c) => {
|
||||
const checked = picked.has(c.id) ? " checked" : "";
|
||||
return (
|
||||
`<tr>` +
|
||||
`<td><input type="checkbox" class="form-check-input lab-formulate-gcb" data-group="${escapeHtml(gid)}" value="${escapeHtml(c.id)}"${checked}></td>` +
|
||||
`<td>${escapeHtml(c.name)}</td>` +
|
||||
`<td>${formatNum(c.mainFeedDmGPerKg)}</td>` +
|
||||
`<td>${formatNum(c.dryMatterPct)}%</td>` +
|
||||
`</tr>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildWizard() {
|
||||
const stepsEl = document.getElementById("labFormulateSteps");
|
||||
const panesEl = document.getElementById("labFormulatePanes");
|
||||
if (!stepsEl || !panesEl) return;
|
||||
|
||||
stepsEl.innerHTML =
|
||||
groups
|
||||
.map(
|
||||
(g, i) =>
|
||||
`<button type="button" class="lab-formulate-step${i === 0 ? " lab-formulate-step--active" : ""}" data-step="${i + 1}">${i + 1}. ${escapeHtml(g.shortLabel)}</button>`
|
||||
)
|
||||
.join("") +
|
||||
`<button type="button" class="lab-formulate-step" data-step="${groups.length + 1}">${groups.length + 1}. Кристалл</button>`;
|
||||
|
||||
panesEl.innerHTML = groups
|
||||
.map((g) => {
|
||||
const req = g.required
|
||||
? `<span class="lab-formulate-badge lab-formulate-badge--ok">без этого никак</span>`
|
||||
: `<span class="lab-formulate-badge">не мешай</span>`;
|
||||
return (
|
||||
`<div id="labFormulatePane-${g.id}" class="lab-formulate-pane${g.step === 1 ? "" : " d-none"}" data-group="${g.id}">` +
|
||||
`<div class="d-flex justify-content-between align-items-center mb-2">` +
|
||||
`<div class="lab-sandbox-panel__title mb-0">${escapeHtml(g.label)}</div>${req}</div>` +
|
||||
`<p class="small text-muted mb-2">${escapeHtml(g.hint)}</p>` +
|
||||
`<p class="small mb-2 lab-formulate-group-count" data-group="${g.id}">В партии: 0</p>` +
|
||||
`<div class="lab-sandbox-field">` +
|
||||
`<input type="search" class="form-control form-control-sm lab-formulate-filter" data-group="${g.id}" placeholder="Найти реагент">` +
|
||||
`</div>` +
|
||||
`<div class="table-responsive lab-formulate-pool-wrap">` +
|
||||
`<table class="table table-sm lab-sandbox-table mb-0">` +
|
||||
`<thead><tr><th></th><th>Реагент</th><th>Осн.</th><th>СВ%</th></tr></thead>` +
|
||||
`<tbody></tbody></table></div>` +
|
||||
`<div class="lab-sandbox-actions mt-2">` +
|
||||
(g.step > 1
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary lab-formulate-back" data-step="${g.step}">← Откат</button>`
|
||||
: "") +
|
||||
(!g.required
|
||||
? `<button type="button" class="btn btn-sm btn-outline-secondary lab-formulate-skip" data-step="${g.step}">Не мешай</button>`
|
||||
: "") +
|
||||
`<button type="button" class="btn btn-sm btn-primary lab-formulate-next" data-step="${g.step}">Вперёд →</button>` +
|
||||
`</div></div>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
groups.forEach((g) => renderGroupTable(g.id, ""));
|
||||
}
|
||||
|
||||
function updateCounters() {
|
||||
const sel = allSelections();
|
||||
groups.forEach((g) => {
|
||||
const el = document.querySelector(`.lab-formulate-group-count[data-group="${g.id}"]`);
|
||||
if (el) {
|
||||
const n = (sel[g.id] || []).length;
|
||||
el.textContent = g.required ? `В партии: ${n} (мин. ${g.minPick})` : `В партии: ${n}`;
|
||||
}
|
||||
});
|
||||
const total = totalPicked();
|
||||
const summaryEl = document.getElementById("labFormulateSummary");
|
||||
const prefilter = document.getElementById("labFormulatePrefilterNote");
|
||||
const roughOk = (sel.rough || []).length >= 1;
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = roughOk && total >= 3
|
||||
? `Партия из ${total} реагентов — можно готовить`
|
||||
: `Нужна база (≥1) и ≥3 реагента всего — сейчас ${total}. Нам нужно готовить.`;
|
||||
}
|
||||
if (prefilter) prefilter.classList.toggle("d-none", total <= 18);
|
||||
const runBtn = document.getElementById("labFormulateRun");
|
||||
if (runBtn) runBtn.disabled = !(roughOk && total >= 3 && currentStep === totalSteps());
|
||||
renderReview();
|
||||
}
|
||||
|
||||
function renderReview() {
|
||||
const el = document.getElementById("labFormulateReview");
|
||||
if (!el) return;
|
||||
const sel = allSelections();
|
||||
let html = "";
|
||||
groups.forEach((g) => {
|
||||
const ids = sel[g.id] || [];
|
||||
html += `<li><strong>${escapeHtml(g.shortLabel)}</strong> (${ids.length})</li>`;
|
||||
ids.forEach((id) => {
|
||||
const name = catalog.find((c) => c.id === id)?.name || id;
|
||||
html += `<li class="ms-3">${escapeHtml(name)}</li>`;
|
||||
});
|
||||
});
|
||||
el.innerHTML = html || "<li>—</li>";
|
||||
}
|
||||
|
||||
function setStep(step) {
|
||||
currentStep = step;
|
||||
const calcStep = totalSteps();
|
||||
groups.forEach((g) => {
|
||||
document.getElementById(`labFormulatePane-${g.id}`)?.classList.toggle("d-none", g.step !== step);
|
||||
});
|
||||
document.getElementById("labFormulateReviewPane")?.classList.toggle("d-none", step !== calcStep);
|
||||
document.querySelector(".lab-formulate-step-panel--calc")?.classList.toggle("d-none", step !== calcStep);
|
||||
document.querySelectorAll(".lab-formulate-step").forEach((btn) => {
|
||||
btn.classList.toggle("lab-formulate-step--active", Number(btn.dataset.step) === step);
|
||||
});
|
||||
updateCounters();
|
||||
}
|
||||
|
||||
function canGoToStep(step) {
|
||||
if (step <= 1) return true;
|
||||
const sel = allSelections();
|
||||
for (const g of groups) {
|
||||
if (g.step >= step) break;
|
||||
if (g.required && (sel[g.id] || []).length < (g.minPick || 1)) return false;
|
||||
}
|
||||
if (step === totalSteps()) {
|
||||
return (sel.rough || []).length >= 1 && totalPicked() >= 3;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readNumInput(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
const raw = String(el.value ?? "").trim();
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function readNormsMethod() {
|
||||
return document.getElementById("labFormulateNormsMethod")?.value || "wesp";
|
||||
}
|
||||
|
||||
function readNormsParams() {
|
||||
const method = readNormsMethod();
|
||||
if (method === "wesp") return {};
|
||||
const params = {
|
||||
milkFatPct: readNumInput("labFormulateFat") ?? 4,
|
||||
lactationNo: readNumInput("labFormulateLactation") ?? 2,
|
||||
};
|
||||
if (method === "racion_piter") {
|
||||
params.koncOeSv = readNumInput("labFormulateKonc") ?? 10.3;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function syncNormsMethodUi() {
|
||||
const method = readNormsMethod();
|
||||
const racionBox = document.getElementById("labFormulateRacionFields");
|
||||
const piterOnly = document.querySelectorAll(".lab-formulate-piter-only");
|
||||
if (racionBox) racionBox.classList.toggle("d-none", method === "wesp");
|
||||
piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter"));
|
||||
}
|
||||
|
||||
function readFormulateParams() {
|
||||
return {
|
||||
profileId: document.getElementById("labFormulateProfile")?.value || "",
|
||||
massKg: readNumInput("labFormulateMass"),
|
||||
milkYieldKg: readNumInput("labFormulateMilk"),
|
||||
totalKgPerHead: readNumInput("labFormulateTotalKg") ?? 7.3,
|
||||
costWeight: readNumInput("labFormulateCostWeight") ?? 100,
|
||||
optimizeKeys: Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value),
|
||||
groupSelections: allSelections(),
|
||||
normsMethod: readNormsMethod(),
|
||||
normsParams: readNormsParams(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMassMilk(profile) {
|
||||
return {
|
||||
mass: readNumInput("labFormulateMass") ?? profile?.massKg ?? null,
|
||||
milk: readNumInput("labFormulateMilk") ?? profile?.milkYieldKg ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function normsForOptimizeDisplay(profile) {
|
||||
const method = readNormsMethod();
|
||||
let norms =
|
||||
method === "wesp"
|
||||
? { ...(profile?.norms || profile?.normsData?.indicators || {}) }
|
||||
: {};
|
||||
const { mass, milk } = resolveMassMilk(profile);
|
||||
if (method !== "wesp" && (mass == null || milk == null)) return norms;
|
||||
if (mass == null && milk == null) return norms;
|
||||
try {
|
||||
const q = new URLSearchParams({ method });
|
||||
const profileId = document.getElementById("labFormulateProfile")?.value;
|
||||
if (profileId) q.set("profile_id", profileId);
|
||||
if (mass != null) q.set("mass_kg", String(mass));
|
||||
if (milk != null) q.set("milk_yield_kg", String(milk));
|
||||
const np = readNormsParams();
|
||||
if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct));
|
||||
if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo));
|
||||
if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv));
|
||||
const preview = await api(`/api/lab/norms-preview?${q.toString()}`);
|
||||
const resolved = preview.resolvedIndicators || {};
|
||||
const dynamic = preview.dynamicNorms || {};
|
||||
const merged = { ...resolved };
|
||||
Object.keys(dynamic).forEach((key) => {
|
||||
const d = dynamic[key];
|
||||
if (d?.min != null) merged[key] = { ...(merged[key] || {}), min: d.min };
|
||||
});
|
||||
if (method === "wesp") {
|
||||
Object.assign(norms, merged);
|
||||
} else {
|
||||
norms = merged;
|
||||
}
|
||||
} catch (_err) {
|
||||
/* preview optional */
|
||||
}
|
||||
return norms;
|
||||
}
|
||||
|
||||
async function refreshOptimizeKeysForInputs() {
|
||||
const profileId = document.getElementById("labFormulateProfile")?.value;
|
||||
const profile = profiles.find((p) => String(p.id) === String(profileId));
|
||||
if (!profile) return;
|
||||
const norms = await normsForOptimizeDisplay(profile);
|
||||
renderOptimizeKeys({ ...profile, norms });
|
||||
}
|
||||
|
||||
let optimizeKeysRefreshTimer = null;
|
||||
function scheduleOptimizeKeysRefresh() {
|
||||
if (optimizeKeysRefreshTimer) clearTimeout(optimizeKeysRefreshTimer);
|
||||
optimizeKeysRefreshTimer = setTimeout(() => {
|
||||
optimizeKeysRefreshTimer = null;
|
||||
refreshOptimizeKeysForInputs().catch(() => {});
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function profileOptions(selectedId) {
|
||||
const opts = ['<option value="">— стандарт чистоты —</option>'];
|
||||
profiles.forEach((p) => {
|
||||
const sel = String(p.id) === String(selectedId) ? " selected" : "";
|
||||
const key = p.profileKey ? `[${p.profileKey}] ` : "";
|
||||
opts.push(
|
||||
`<option value="${escapeHtml(p.id)}"${sel}>${escapeHtml(key)}${escapeHtml(p.label)}</option>`
|
||||
);
|
||||
});
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function fillProfileFields(profileId) {
|
||||
const profile = profiles.find((p) => String(p.id) === String(profileId));
|
||||
if (!profile) return;
|
||||
const massEl = document.getElementById("labFormulateMass");
|
||||
const milkEl = document.getElementById("labFormulateMilk");
|
||||
if (massEl && profile.massKg != null) massEl.value = profile.massKg;
|
||||
if (milkEl && profile.milkYieldKg != null) milkEl.value = profile.milkYieldKg;
|
||||
refreshOptimizeKeysForInputs().catch(() => renderOptimizeKeys(profile));
|
||||
}
|
||||
|
||||
function renderOptimizeKeys(profile) {
|
||||
const box = document.getElementById("labFormulateOptimizeKeys");
|
||||
if (!box) return;
|
||||
const norms = profile?.norms || profile?.normsData?.indicators || {};
|
||||
const keys = Object.keys(norms).filter((k) => norms[k]?.min != null || norms[k]?.max != null);
|
||||
const useKeys = keys.length ? keys : DEFAULT_OPTIMIZE_KEYS;
|
||||
const prevChecked = new Set(
|
||||
Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value)
|
||||
);
|
||||
const hasPrev = prevChecked.size > 0;
|
||||
box.innerHTML = useKeys
|
||||
.map((key) => {
|
||||
const checked =
|
||||
(hasPrev ? prevChecked.has(key) : DEFAULT_OPTIMIZE_KEYS.includes(key)) ? " checked" : "";
|
||||
const b = norms[key] || {};
|
||||
const bounds =
|
||||
b.min != null && b.max != null
|
||||
? `${formatNum(b.min)}–${formatNum(b.max)}`
|
||||
: b.max != null
|
||||
? `≤ ${formatNum(b.max)}`
|
||||
: b.min != null
|
||||
? `≥ ${formatNum(b.min)}`
|
||||
: "";
|
||||
return (
|
||||
`<label class="lab-formulate-opt-key">` +
|
||||
`<input type="checkbox" class="form-check-input lab-formulate-opt-cb" value="${escapeHtml(key)}"${checked}>` +
|
||||
`<span>${escapeHtml(key)}</span>` +
|
||||
`<small class="text-muted">${escapeHtml(bounds)}</small>` +
|
||||
`</label>`
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
const el = document.getElementById("labFormulateResult");
|
||||
if (!el) return;
|
||||
if (!data) {
|
||||
el.innerHTML = '<div class="lab-sandbox-empty">Скажи моё имя и жми кнопку</div>';
|
||||
return;
|
||||
}
|
||||
const stats = data.searchStats || {};
|
||||
const optimizeSet = new Set(data.optimizeKeys || []);
|
||||
let html =
|
||||
`<div class="lab-formulate-result-meta">` +
|
||||
`<span>Реагентов ${data.candidatePoolSize}</span>` +
|
||||
`<span>Троек ${stats.tripletsEvaluated || 0}</span>` +
|
||||
`<span>${stats.durationMs || 0} мс — время деньги</span></div>` +
|
||||
`<table class="table table-sm lab-sandbox-table mb-3"><thead><tr><th>Реагент</th><th>кг/сут</th><th>%</th></tr></thead><tbody>` +
|
||||
(data.lines || [])
|
||||
.map(
|
||||
(line) =>
|
||||
`<tr><td>${escapeHtml(line.name)}</td><td>${formatNum(line.dailyKg)}</td><td>${formatNum(line.sharePct)}</td></tr>`
|
||||
)
|
||||
.join("") +
|
||||
`</tbody></table>`;
|
||||
const rows = (data.indicators || []).filter(
|
||||
(ind) => ind.min != null || ind.max != null || ind.content != null
|
||||
);
|
||||
html +=
|
||||
`<table class="table table-sm lab-sandbox-table"><thead><tr><th>Показатель</th><th>Факт</th><th>Стандарт</th><th>Δ</th></tr></thead><tbody>` +
|
||||
rows
|
||||
.map((ind) => {
|
||||
const target = optimizeSet.has(ind.key) ? " lab-formulate-row--target" : "";
|
||||
const dc = ind.min != null || ind.max != null ? diffClass(ind.diff) : "lab-sandbox-diff--na";
|
||||
const norm =
|
||||
ind.min != null && ind.max != null
|
||||
? `${formatNum(ind.min)}–${formatNum(ind.max)}`
|
||||
: ind.max != null
|
||||
? `≤ ${formatNum(ind.max)}`
|
||||
: ind.min != null
|
||||
? `≥ ${formatNum(ind.min)}`
|
||||
: "—";
|
||||
return (
|
||||
`<tr class="${target}"><td>${escapeHtml(ind.label || ind.key)}</td>` +
|
||||
`<td>${formatNum(ind.content)}</td><td>${norm}</td>` +
|
||||
`<td class="${dc}">${formatNum(ind.diff)}</td></tr>`
|
||||
);
|
||||
})
|
||||
.join("") +
|
||||
`</tbody></table>`;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
async function loadCatalogs() {
|
||||
const [compData, profData] = await Promise.all([
|
||||
api("/api/lab/formulate/components"),
|
||||
api("/api/lab/animal-profiles?ration_type=DAIRY"),
|
||||
]);
|
||||
catalog = compData.components || [];
|
||||
groups = (compData.groups || []).slice().sort((a, b) => a.step - b.step);
|
||||
profiles = profData.profiles || [];
|
||||
buildWizard();
|
||||
const profileSel = document.getElementById("labFormulateProfile");
|
||||
if (profileSel) {
|
||||
const labDefault = profiles.find((p) => p.profileKey === "lab_math_dairy_01") || profiles[0];
|
||||
profileSel.innerHTML = profileOptions(labDefault?.id);
|
||||
if (labDefault) fillProfileFields(labDefault.id);
|
||||
}
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
async function labFormulateRun() {
|
||||
const params = readFormulateParams();
|
||||
const profile = profiles.find((p) => String(p.id) === String(params.profileId));
|
||||
if (!params.profileId) throw new Error("Выбери стандарт чистоты — я предупреждал");
|
||||
if (params.normsMethod !== "wesp") {
|
||||
const { mass, milk } = resolveMassMilk(profile);
|
||||
if (mass == null || milk == null) {
|
||||
throw new Error("Для Москва/Петербург нужны масса и удой — заполни поля или выбери профиль с удоем");
|
||||
}
|
||||
params.massKg = mass;
|
||||
params.milkYieldKg = milk;
|
||||
}
|
||||
if ((params.groupSelections.rough || []).length < 1) {
|
||||
throw new Error("Без базы не готовим. Минимум один грубый корм");
|
||||
}
|
||||
if (totalPicked() < 3) throw new Error("Минимум три реагента. Нам нужно готовить");
|
||||
const data = await api("/api/lab/formulate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
cache: "no-store",
|
||||
body: JSON.stringify({
|
||||
profileId: params.profileId,
|
||||
groupSelections: params.groupSelections,
|
||||
massKg: params.massKg,
|
||||
milkYieldKg: params.milkYieldKg,
|
||||
totalKgPerHead: params.totalKgPerHead,
|
||||
costWeight: params.costWeight,
|
||||
optimizeKeys: params.optimizeKeys,
|
||||
normsMethod: params.normsMethod,
|
||||
normsParams: params.normsParams,
|
||||
}),
|
||||
});
|
||||
renderResult(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
function selectLabForGroup(gid) {
|
||||
catalog
|
||||
.filter((c) => c.eligible && c.feedGroup === gid && String(c.name || "").startsWith("LAB тест —"))
|
||||
.forEach((c) => {
|
||||
const cb = document.querySelector(
|
||||
`.lab-formulate-gcb[data-group="${CSS.escape(gid)}"][value="${CSS.escape(c.id)}"]`
|
||||
);
|
||||
if (cb) cb.checked = true;
|
||||
});
|
||||
updateCounters();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.getElementById("labFormulateRun")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await labFormulateRun();
|
||||
} catch (err) {
|
||||
formulateError(String(err.message || err));
|
||||
} finally {
|
||||
updateCounters();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("labFormulateProfile")?.addEventListener("change", (e) => {
|
||||
fillProfileFields(e.target.value);
|
||||
});
|
||||
|
||||
for (const id of [
|
||||
"labFormulateMass",
|
||||
"labFormulateMilk",
|
||||
"labFormulateTotalKg",
|
||||
"labFormulateCostWeight",
|
||||
"labFormulateFat",
|
||||
"labFormulateLactation",
|
||||
"labFormulateKonc",
|
||||
]) {
|
||||
document.getElementById(id)?.addEventListener("input", scheduleOptimizeKeysRefresh);
|
||||
}
|
||||
document.getElementById("labFormulateNormsMethod")?.addEventListener("change", () => {
|
||||
syncNormsMethodUi();
|
||||
scheduleOptimizeKeysRefresh();
|
||||
});
|
||||
|
||||
document.getElementById("labFormulatePanes")?.addEventListener("input", (e) => {
|
||||
if (e.target.classList.contains("lab-formulate-filter")) {
|
||||
renderGroupTable(e.target.dataset.group, e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("labFormulatePanes")?.addEventListener("change", (e) => {
|
||||
if (e.target.classList.contains("lab-formulate-gcb")) updateCounters();
|
||||
});
|
||||
|
||||
document.getElementById("labFormulatePanes")?.addEventListener("click", (e) => {
|
||||
const next = e.target.closest(".lab-formulate-next");
|
||||
const back = e.target.closest(".lab-formulate-back");
|
||||
const skip = e.target.closest(".lab-formulate-skip");
|
||||
if (next) {
|
||||
const step = Number(next.dataset.step);
|
||||
const g = groups.find((x) => x.step === step);
|
||||
if (g?.required && selectedForGroup(g.id).length < (g.minPick || 1)) {
|
||||
formulateError(`В «${g.label}» нужно минимум ${g.minPick}. Всё под контролем — выбери`);
|
||||
return;
|
||||
}
|
||||
setStep(step + 1);
|
||||
}
|
||||
if (back) setStep(Number(back.dataset.step) - 1);
|
||||
if (skip) setStep(Number(skip.dataset.step) + 1);
|
||||
});
|
||||
|
||||
document.getElementById("labFormulateSteps")?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".lab-formulate-step");
|
||||
if (!btn) return;
|
||||
const step = Number(btn.dataset.step);
|
||||
if (!canGoToStep(step)) return;
|
||||
setStep(step);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.altKey && e.key === "l") {
|
||||
selectLabForGroup("rough");
|
||||
selectLabForGroup("succulent");
|
||||
selectLabForGroup("concentrate");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
bindEvents();
|
||||
syncNormsMethodUi();
|
||||
loadCatalogs().catch((err) => {
|
||||
const msg = err.message || String(err);
|
||||
formulateError(msg);
|
||||
const panes = document.getElementById("labFormulatePanes");
|
||||
if (panes) panes.innerHTML = `<p class="text-danger">${escapeHtml(msg)}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
global.labFormulateRun = labFormulateRun;
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})(window);
|
||||
Reference in New Issue
Block a user