/* ============================================================ AdminRates — управление ставками сборки #/admin/rates → список правил + форма добавления/редактирования Доступно только менеджеру. ============================================================ */ const AdminRates = (function () { "use strict"; const DEFAULT_CLIENT = 10; const DEFAULT_ASSEMBLER = 9; function escHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function el(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; } async function _api(path, body = {}) { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 15000); try { const res = await fetch(`${BACKEND_URL}/api/${path}`, { method: "POST", signal: ctrl.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")), initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : (window.tg?.initDataUnsafe || null)), ...body }), }); if (!res.ok) throw new Error(`Ошибка сервера (${res.status})`); return await res.json(); } catch (e) { if (e.name === "AbortError") throw new Error("Сервер не отвечает"); throw e; } finally { clearTimeout(t); } } /* ── Главный экран: список правил ──────────────────────────── */ async function mount(container) { container.innerHTML = ""; document.body.classList.remove("has-bottom-nav"); document.getElementById("bottom-nav")?.remove(); // Header const h = el(`
Ставки сборки
`); h.querySelector(".podbor-back").addEventListener("click", () => { haptic && haptic("impact"); history.back(); }); h.querySelector("#addRateBtn").addEventListener("click", () => { haptic && haptic("impact"); _openForm(container, null); }); container.appendChild(h); const screen = el(`
`); screen.innerHTML = `
`; container.appendChild(screen); // Инфо-бэджик screen.innerHTML = ""; screen.appendChild(el(`
Как работают ставки
Правила применяются по приоритету: конкретный сборщик > все сборщики.
Клиент платит по ставке «Клиенту», сборщик получает по ставке «Сборщику».
Разница — маржа компании.
`)); try { const data = await _api("assembly_rates_list"); if (data.error) { screen.innerHTML += `
${escHtml(data.error)}
`; return; } _renderList(screen, container, data.rates || []); } catch (e) { screen.innerHTML += `
Ошибка: ${escHtml(e.message)}
`; } } function _renderList(screen, container, rates) { // Удаляем старый список если есть screen.querySelectorAll(".rates-list").forEach(n => n.remove()); const active = rates.filter(r => (r.active || "").toUpperCase() !== "FALSE"); const inactive = rates.filter(r => (r.active || "").toUpperCase() === "FALSE"); if (!active.length) { screen.appendChild(el(`
Нет активных правил.
Нажмите + чтобы добавить.
`)); } const list = el(`
`); // Активные if (active.length) { list.appendChild(el(`
Активные правила
`)); active.forEach(r => list.appendChild(_ruleCard(r, container, screen, rates, false))); } // Неактивные (свёрнуто) if (inactive.length) { const toggle = el(`
`); const inactiveWrap = el(``); inactive.forEach(r => inactiveWrap.appendChild(_ruleCard(r, container, screen, rates, true))); toggle.querySelector("#showInactive").addEventListener("click", function () { inactiveWrap.style.display = inactiveWrap.style.display === "none" ? "" : "none"; this.textContent = inactiveWrap.style.display === "none" ? `Показать неактивные (${inactive.length})` : `Скрыть неактивные`; }); list.appendChild(toggle); list.appendChild(inactiveWrap); } screen.appendChild(list); } function _ruleCard(r, container, screen, allRates, isInactive) { const cpct = parseFloat(r.client_rate_pct || DEFAULT_CLIENT).toFixed(1); const apct = parseFloat(r.assembler_rate_pct || DEFAULT_ASSEMBLER).toFixed(1); const margin = (parseFloat(cpct) - parseFloat(apct)).toFixed(1); const isDefault = r.assembler_tg_id === "*" && r.scope === "*"; const who = isDefault ? "🌐 Все сборщики (базовая)" : (r.assembler_name ? `👤 ${r.assembler_name}` : `ID: ${r.assembler_tg_id}`); const scopeLabel = r.scope !== "*" ? ` · 🗂 ${r.scope}` : ""; const card = el(`
${escHtml(who)}${escHtml(scopeLabel)}
${!isInactive ? `` : ""}
Клиенту ${cpct}%
Сборщику ${apct}%
Маржа ${margin}%
${r.note ? `
${escHtml(r.note)}
` : ""} ${!isInactive && !isDefault ? `
` : ""}
`); // Редактировать card.querySelector(`[data-edit]`)?.addEventListener("click", () => { haptic && haptic("impact"); _openForm(container, r); }); // Деактивировать card.querySelector(`[data-del]`)?.addEventListener("click", async () => { if (!confirm(`Деактивировать правило "${who}"?`)) return; haptic && haptic("impact"); try { const res = await _api("assembly_rate_delete", { rule_id: r.rule_id }); if (res.error) { alert("Ошибка: " + res.error); return; } // Перезагружаем список mount(container); } catch (e) { alert("Ошибка: " + e.message); } }); return card; } /* ── Форма добавления / редактирования ─────────────────────── */ function _openForm(container, rule) { // Удаляем старую форму если есть document.getElementById("rates-form-overlay")?.remove(); const isEdit = !!rule; const overlay = el(`
${isEdit ? "Редактировать правило" : "Новое правило"}
Применять к
Маржа: 1%
`); // Показываем/скрываем поля конкретного сборщика overlay.querySelectorAll('input[name="rateWho"]').forEach(radio => { radio.addEventListener("change", () => { const specificFields = overlay.querySelector("#specificFields"); specificFields.style.display = radio.value === "specific" ? "" : "none"; }); }); // Живая маржа const updateMargin = () => { const c = parseFloat(overlay.querySelector("#fClientRate").value) || 0; const a = parseFloat(overlay.querySelector("#fAssemblerRate").value) || 0; const m = (c - a).toFixed(1); const el_ = overlay.querySelector("#marginVal"); if (el_) { el_.textContent = m + "%"; el_.style.color = parseFloat(m) >= 0 ? "#27AE60" : "#C0392B"; } }; overlay.querySelector("#fClientRate").addEventListener("input", updateMargin); overlay.querySelector("#fAssemblerRate").addEventListener("input", updateMargin); updateMargin(); // Отмена overlay.querySelector("#fCancel").addEventListener("click", () => { haptic && haptic("impact"); overlay.remove(); }); overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); }); // Сохранить overlay.querySelector("#fSave").addEventListener("click", async () => { const errEl = overlay.querySelector("#formErr"); errEl.style.display = "none"; const whoVal = overlay.querySelector('input[name="rateWho"]:checked')?.value || "all"; const assemblerTgId = whoVal === "specific" ? (overlay.querySelector("#fAssemblerTgId").value || "").trim() : "*"; const assemblerName = whoVal === "specific" ? (overlay.querySelector("#fAssemblerName").value || "").trim() : "Все сборщики"; if (whoVal === "specific" && !assemblerTgId) { errEl.textContent = "Укажите Telegram ID сборщика"; errEl.style.display = ""; return; } const clientRate = parseFloat(overlay.querySelector("#fClientRate").value); const assemblerRate = parseFloat(overlay.querySelector("#fAssemblerRate").value); if (isNaN(clientRate) || isNaN(assemblerRate)) { errEl.textContent = "Укажите ставки"; errEl.style.display = ""; return; } if (assemblerRate > clientRate) { errEl.textContent = "Ставка сборщика не может быть больше ставки клиента"; errEl.style.display = ""; return; } const saveBtn = overlay.querySelector("#fSave"); saveBtn.disabled = true; saveBtn.textContent = "Сохраняем..."; try { const res = await _api("assembly_rate_save", { rule_id: isEdit ? rule.rule_id : "", assembler_tg_id: assemblerTgId, assembler_name: assemblerName, scope: "*", client_rate_pct: clientRate, assembler_rate_pct: assemblerRate, note: (overlay.querySelector("#fNote").value || "").trim(), }); if (res.error) { errEl.textContent = res.msg || res.error; errEl.style.display = ""; saveBtn.disabled = false; saveBtn.textContent = isEdit ? "Сохранить" : "Создать правило"; return; } haptic && haptic("success"); overlay.remove(); mount(container); // перезагружаем список } catch (e) { errEl.textContent = "Сеть: " + e.message; errEl.style.display = ""; saveBtn.disabled = false; saveBtn.textContent = isEdit ? "Сохранить" : "Создать правило"; } }); document.body.appendChild(overlay); } return { mount }; })();