Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Модалка выбора компонента-замены (похожие + поиск), как в плане на день.
|
||||
*/
|
||||
(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);
|
||||
Reference in New Issue
Block a user