/* ============================================================
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(`
Показать неактивные (${inactive.length})
`);
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(`
`);
// Показываем/скрываем поля конкретного сборщика
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 };
})();