186 lines
7.1 KiB
JavaScript
186 lines
7.1 KiB
JavaScript
/**
|
|
* Модалка выбора компонента-замены (похожие + поиск), как в плане на день.
|
|
*/
|
|
(function (global) {
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function formatKg(value) {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n)) return "—";
|
|
const rounded = Math.round(n * 100) / 100;
|
|
return String(rounded).replace(/\.?0+$/, "");
|
|
}
|
|
|
|
function renderComponentPickList(items, emptyText) {
|
|
if (!items.length) {
|
|
return `<p class="zt-k-hub-plan-replace__empty">${escapeHtml(emptyText)}</p>`;
|
|
}
|
|
return items
|
|
.map(
|
|
(item) =>
|
|
`<button type="button" class="zt-k-hub-plan-replace__pick" data-pick-component-id="${escapeHtml(item.id)}" ` +
|
|
`data-pick-component-name="${escapeHtml(item.name)}">` +
|
|
`<span class="zt-k-hub-plan-replace__pick-name">${escapeHtml(item.name)}</span>` +
|
|
`<span class="zt-k-hub-plan-replace__pick-meta">` +
|
|
`${escapeHtml(item.type || "—")} · СВ ${formatKg(item.dryMatter)}%</span></button>`
|
|
)
|
|
.join("");
|
|
}
|
|
|
|
async function fetchComponentAlternatives(componentId, query) {
|
|
const params = new URLSearchParams({
|
|
component_id: componentId,
|
|
q: query || "",
|
|
limit: "100",
|
|
});
|
|
const resp = await fetch(`/api/daily-plan/component-alternatives?${params.toString()}`, {
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({}));
|
|
throw new Error(err.message || "Не удалось загрузить компоненты");
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
function dismissOverlays(host, keepSelectors) {
|
|
host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => {
|
|
if (keepSelectors.some((sel) => el.matches(sel))) return;
|
|
el.remove();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {object} options
|
|
* @param {string} options.componentId
|
|
* @param {string} options.label
|
|
* @param {HTMLElement} [options.overlayHost]
|
|
* @param {string[]} [options.keepOverlays] — селекторы overlay, которые не закрывать
|
|
* @param {string} [options.subtitle]
|
|
* @param {(replacementComponentId: string, replacementName: string) => void|Promise<void>} options.onPick
|
|
* @param {(message: string) => void} [options.onError]
|
|
*/
|
|
async function openReplaceComponentModal(options) {
|
|
const {
|
|
componentId,
|
|
label,
|
|
overlayHost = document.body,
|
|
keepOverlays = [],
|
|
subtitle = "",
|
|
onPick,
|
|
onError = () => {},
|
|
} = options || {};
|
|
if (!componentId || !onPick) return;
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-replace";
|
|
const subtitleHtml = subtitle
|
|
? `<p class="zt-k-hub-plan-replace__lead">${escapeHtml(subtitle)}</p>`
|
|
: "";
|
|
overlay.innerHTML =
|
|
'<div class="zt-k-hub-plan-subdialog zt-k-hub-plan-replace__dialog" role="dialog" aria-modal="true">' +
|
|
`<h3 class="zt-k-hub-plan-subdialog__title zt-k-hub-plan-replace__title">Заменить «${escapeHtml(label)}»</h3>` +
|
|
subtitleHtml +
|
|
'<label class="zt-k-hub-plan-replace__search-label">Поиск по всем компонентам</label>' +
|
|
'<input type="search" class="form-control zt-k-hub-plan-replace__search" data-replace-search placeholder="Начните вводить название…" autocomplete="off">' +
|
|
'<div class="zt-k-hub-plan-replace__section">' +
|
|
'<p class="zt-k-hub-plan-replace__section-title">Похожие по параметрам</p>' +
|
|
'<div data-replace-similar class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
|
|
'<div class="zt-k-hub-plan-replace__section">' +
|
|
'<p class="zt-k-hub-plan-replace__section-title">Все компоненты</p>' +
|
|
'<div data-replace-results class="zt-k-hub-plan-replace__list"><div class="zt-k-hub-plan-replace__empty">Загрузка…</div></div></div>' +
|
|
'<div class="zt-k-hub-plan-subdialog__actions zt-k-hub-plan-replace__actions">' +
|
|
'<button type="button" class="btn btn-secondary" data-replace-cancel>Отмена</button></div></div>';
|
|
|
|
dismissOverlays(overlayHost, keepOverlays);
|
|
overlay.setAttribute("data-daily-plan-overlay", "1");
|
|
overlayHost.appendChild(overlay);
|
|
|
|
const similarEl = overlay.querySelector("[data-replace-similar]");
|
|
const resultsEl = overlay.querySelector("[data-replace-results]");
|
|
const searchEl = overlay.querySelector("[data-replace-search]");
|
|
let catalog = null;
|
|
|
|
const close = () => {
|
|
if (overlay.isConnected) overlay.remove();
|
|
};
|
|
|
|
function filterCatalog(query) {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) {
|
|
return {
|
|
similar: catalog?.similar || [],
|
|
items: catalog?.results || [],
|
|
q: "",
|
|
};
|
|
}
|
|
const items = (catalog?.results || []).filter((item) =>
|
|
String(item.name || "").toLowerCase().includes(q)
|
|
);
|
|
const similar = items.filter((item) => item.similar);
|
|
return { similar, items, q };
|
|
}
|
|
|
|
function paintLists(query = "") {
|
|
if (!overlay.isConnected) return;
|
|
const { similar, items, q } = filterCatalog(query);
|
|
if (similarEl) {
|
|
similarEl.innerHTML = renderComponentPickList(
|
|
similar,
|
|
q ? "Нет похожих по запросу" : "Нет похожих компонентов"
|
|
);
|
|
}
|
|
if (resultsEl) {
|
|
resultsEl.innerHTML = renderComponentPickList(
|
|
items,
|
|
q ? "Ничего не найдено" : "Нет доступных компонентов"
|
|
);
|
|
}
|
|
}
|
|
|
|
async function loadCatalog() {
|
|
try {
|
|
catalog = await fetchComponentAlternatives(componentId, "");
|
|
paintLists(searchEl?.value.trim() || "");
|
|
} catch (err) {
|
|
if (!overlay.isConnected) return;
|
|
onError(err.message || "Не удалось загрузить компоненты");
|
|
}
|
|
}
|
|
|
|
overlay.querySelector("[data-replace-cancel]")?.addEventListener("click", close);
|
|
overlay.addEventListener("click", (event) => {
|
|
if (event.target === overlay) close();
|
|
});
|
|
searchEl?.addEventListener("input", () => paintLists(searchEl.value.trim()));
|
|
overlay.addEventListener("click", async (event) => {
|
|
const pick = event.target.closest("[data-pick-component-id]");
|
|
if (!pick) return;
|
|
event.preventDefault();
|
|
const replacementComponentId = pick.dataset.pickComponentId;
|
|
const replacementName = pick.dataset.pickComponentName || "Компонент";
|
|
if (!replacementComponentId) return;
|
|
close();
|
|
try {
|
|
await onPick(replacementComponentId, replacementName);
|
|
} catch (err) {
|
|
onError(err.message || "Не удалось заменить компонент");
|
|
}
|
|
});
|
|
|
|
await loadCatalog();
|
|
searchEl?.focus();
|
|
}
|
|
|
|
global.WespDailyPlanReplaceModal = {
|
|
open: openReplaceComponentModal,
|
|
fetchComponentAlternatives,
|
|
};
|
|
})(window);
|