diff --git a/backend-py/app/main.py b/backend-py/app/main.py index d8dd69c..95110ad 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -148,6 +148,9 @@ async def _dispatch_post(request: Request): "sign_request_create": _handle_sign_request_create, "sign_request_submit": _handle_sign_request_submit, "managers_list": _handle_managers_list, + "assembly_rates_list": _handle_assembly_rates_list, + "assembly_rate_save": _handle_assembly_rate_save, + "assembly_rate_delete": _handle_assembly_rate_delete, "proposal_brief": proposals_mod.handle_brief, "proposal_create": proposals_mod.handle_create, "proposal_upsert_variant": proposals_mod.handle_upsert_variant, @@ -356,6 +359,24 @@ async def api_assembly_set_kitchen_price(request: Request): return _handle_assembly_set_kitchen_price(body) +@app.post("/api/assembly_rates_list") +async def api_assembly_rates_list(request: Request): + body = await _safe_json(request) + return _handle_assembly_rates_list(body) + + +@app.post("/api/assembly_rate_save") +async def api_assembly_rate_save(request: Request): + body = await _safe_json(request) + return _handle_assembly_rate_save(body) + + +@app.post("/api/assembly_rate_delete") +async def api_assembly_rate_delete(request: Request): + body = await _safe_json(request) + return _handle_assembly_rate_delete(body) + + @app.post("/api/grant_role") async def api_grant_role(request: Request): """Админ выдаёт роль другому пользователю. @@ -2452,6 +2473,30 @@ def _handle_assembly_list(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "count": len(out), "assemblies": out} +def _calc_assembly_prices(row: dict, viewer_tg_id) -> dict: + """Вычисляет стоимости сборки с учётом ставок из Assembly_Rates. + Возвращает словарь полей для добавления в ответ assembly_detail.""" + assembler_tg_id = str(row.get("assigned_to_tg_id") or "") + client_rate, assembler_rate = _resolve_rates(assembler_tg_id, scope="*") + is_assembler = str(viewer_tg_id) == assembler_tg_id + + try: + kp = float(row.get("kitchen_price") or 0) + except (ValueError, TypeError): + kp = 0.0 + + result: dict[str, Any] = { + "client_rate_pct": client_rate, + "assembler_rate_pct": assembler_rate, + "assembly_price_for_client": round(kp * client_rate / 100, 2) if kp else None, + "viewer_is_assembler": is_assembler, + } + # Сборщик видит свой заработок; менеджер и клиент — только цену для клиента + if is_assembler or sheets.has_role(sheets.find_user(viewer_tg_id), "manager"): + result["assembler_payout"] = round(kp * assembler_rate / 100, 2) if kp else None + return result + + def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]: """Детальная карточка сборки.""" cfg = get_config() @@ -2517,6 +2562,8 @@ def _handle_assembly_detail(body: dict[str, Any]) -> dict[str, Any]: "manager_note": row.get("manager_note", ""), "kitchen_price": row.get("kitchen_price", ""), "client_tg_id": row.get("client_tg_id", ""), + # Ставки — подсчёт в реальном времени + **_calc_assembly_prices(row, tg_id), } @@ -2560,8 +2607,228 @@ def _handle_assembly_set_kitchen_price(body: dict[str, Any]) -> dict[str, Any]: "id": assembly_id, "kitchen_price": kitchen_price, }) - assembly_price = round(kitchen_price * 0.09, 2) - return {"ok": True, "kitchen_price": kitchen_price, "assembly_price": assembly_price} + client_rate, assembler_rate = _resolve_rates( + row.get("assigned_to_tg_id") or "", scope="*" + ) + assembly_price = round(kitchen_price * client_rate / 100, 2) + return { + "ok": True, + "kitchen_price": kitchen_price, + "assembly_price": assembly_price, + "client_rate_pct": client_rate, + "assembler_rate_pct": assembler_rate, + } + + +# ================================================================= +# Assembly Rates — настройка % сборки (клиент / сборщик) +# ================================================================= + +_RATES_COLUMNS = [ + "rule_id", "assembler_tg_id", "assembler_name", + "scope", "client_rate_pct", "assembler_rate_pct", + "note", "active", "updated_by", "updated_at", +] +_DEFAULT_CLIENT_RATE = 10.0 +_DEFAULT_ASSEMBLER_RATE = 9.0 +_rates_cache: dict = {"data": None, "ts": 0.0} +_RATES_CACHE_TTL = 120 # секунд + + +def _ensure_rates_sheet() -> None: + try: + ws = sheets._ws("Assembly_Rates") + existing = ws.row_values(1) + except Exception: + sheets.ensure_sheet("Assembly_Rates", _RATES_COLUMNS) + # seed default rule + _seed_default_rate() + return + missing = [c for c in _RATES_COLUMNS if c not in existing] + if missing: + for col in missing: + ws.update_cell(1, len(existing) + 1, col) + existing.append(col) + # Seed если лист пуст (только заголовок) + try: + all_rows = sheets.get_all_rows("Assembly_Rates") + if not all_rows: + _seed_default_rate() + except Exception: + pass + + +def _seed_default_rate() -> None: + sheets.append_row("Assembly_Rates", _RATES_COLUMNS, { + "rule_id": str(uuid.uuid4()), + "assembler_tg_id": "*", + "assembler_name": "Все сборщики", + "scope": "*", + "client_rate_pct": str(_DEFAULT_CLIENT_RATE), + "assembler_rate_pct": str(_DEFAULT_ASSEMBLER_RATE), + "note": "Базовая ставка по умолчанию", + "active": "TRUE", + "updated_by": "system", + "updated_at": _now_iso(), + }) + + +def _get_rates_cached() -> list[dict]: + now = time.time() + if _rates_cache["data"] is None or (now - _rates_cache["ts"]) > _RATES_CACHE_TTL: + try: + _ensure_rates_sheet() + rows = sheets.get_all_rows("Assembly_Rates") + _rates_cache["data"] = [r for r in rows if r.get("active", "").upper() != "FALSE"] + _rates_cache["ts"] = now + except Exception as e: + log.warning("_get_rates_cached error: %s", e) + _rates_cache["data"] = _rates_cache["data"] or [] + return _rates_cache["data"] or [] + + +def _resolve_rates(assembler_tg_id: str, scope: str = "*") -> tuple[float, float]: + """Ищет наиболее специфичное правило для сборщика и типа работ. + Приоритет: конкретный сборщик+scope > сборщик+* > *+scope > *+* > дефолт.""" + rules = _get_rates_cached() + best_score = -1 + best_rule = None + tid = str(assembler_tg_id).strip() + for r in rules: + rtid = str(r.get("assembler_tg_id", "*")).strip() + rscope = str(r.get("scope", "*")).strip() + score = 0 + if rtid == tid: + score += 2 + elif rtid != "*": + continue + if rscope == scope: + score += 1 + elif rscope != "*": + continue + if score > best_score: + best_score = score + best_rule = r + if best_rule: + try: + cpct = float(best_rule.get("client_rate_pct", _DEFAULT_CLIENT_RATE)) + apct = float(best_rule.get("assembler_rate_pct", _DEFAULT_ASSEMBLER_RATE)) + return (cpct, apct) + except (ValueError, TypeError): + pass + return (_DEFAULT_CLIENT_RATE, _DEFAULT_ASSEMBLER_RATE) + + +def _handle_assembly_rates_list(body: dict[str, Any]) -> dict[str, Any]: + """Список всех правил ставок (включая неактивные). Доступен менеджеру.""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or not sheets.has_role(user, "manager"): + return {"error": "forbidden"} + _ensure_rates_sheet() + rows = sheets.get_all_rows("Assembly_Rates") + return {"ok": True, "rates": rows} + + +def _handle_assembly_rate_save(body: dict[str, Any]) -> dict[str, Any]: + """Создать или обновить правило ставки. + body: {initData, rule_id?, assembler_tg_id, assembler_name, + scope, client_rate_pct, assembler_rate_pct, note}""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or not sheets.has_role(user, "manager"): + return {"error": "forbidden"} + + try: + cpct = float(body.get("client_rate_pct", _DEFAULT_CLIENT_RATE)) + apct = float(body.get("assembler_rate_pct", _DEFAULT_ASSEMBLER_RATE)) + except (ValueError, TypeError): + return {"error": "bad_rate", "msg": "Ставка должна быть числом"} + if not (0 < cpct <= 100) or not (0 < apct <= 100): + return {"error": "bad_rate", "msg": "Ставка должна быть от 0.1 до 100"} + if apct > cpct: + return {"error": "bad_rate", "msg": "Ставка сборщика не может быть больше ставки клиента"} + + _ensure_rates_sheet() + rule_id = (body.get("rule_id") or "").strip() + now = _now_iso() + + if rule_id: + # Обновляем существующее + for field, val in [ + ("assembler_tg_id", str(body.get("assembler_tg_id") or "*")), + ("assembler_name", str(body.get("assembler_name") or "")), + ("scope", str(body.get("scope") or "*")), + ("client_rate_pct", str(cpct)), + ("assembler_rate_pct", str(apct)), + ("note", str(body.get("note") or "")), + ("active", "TRUE"), + ("updated_by", str(tg_id)), + ("updated_at", now), + ]: + sheets.update_cell_by_key("Assembly_Rates", "rule_id", rule_id, field, val) + else: + # Создаём новое + rule_id = str(uuid.uuid4()) + sheets.append_row("Assembly_Rates", _RATES_COLUMNS, { + "rule_id": rule_id, + "assembler_tg_id": str(body.get("assembler_tg_id") or "*"), + "assembler_name": str(body.get("assembler_name") or ""), + "scope": str(body.get("scope") or "*"), + "client_rate_pct": str(cpct), + "assembler_rate_pct": str(apct), + "note": str(body.get("note") or ""), + "active": "TRUE", + "updated_by": str(tg_id), + "updated_at": now, + }) + + # Сбрасываем кеш + _rates_cache["data"] = None + return {"ok": True, "rule_id": rule_id} + + +def _handle_assembly_rate_delete(body: dict[str, Any]) -> dict[str, Any]: + """Деактивирует правило ставки. + body: {initData, rule_id}""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or not sheets.has_role(user, "manager"): + return {"error": "forbidden"} + + rule_id = (body.get("rule_id") or "").strip() + if not rule_id: + return {"error": "missing_rule_id"} + _ensure_rates_sheet() + sheets.update_cell_by_key("Assembly_Rates", "rule_id", rule_id, "active", "FALSE") + sheets.update_cell_by_key("Assembly_Rates", "rule_id", rule_id, "updated_by", str(tg_id)) + sheets.update_cell_by_key("Assembly_Rates", "rule_id", rule_id, "updated_at", _now_iso()) + _rates_cache["data"] = None + return {"ok": True} # ================================================================= diff --git a/miniapp/assets/admin_rates.js b/miniapp/assets/admin_rates.js new file mode 100644 index 0000000..8429fcf --- /dev/null +++ b/miniapp/assets/admin_rates.js @@ -0,0 +1,399 @@ +/* ============================================================ + 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 }; +})(); diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index fd5ec6f..da01e94 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -194,6 +194,7 @@ async function renderManagerHome(me) { { icon: "clipboard", title: "Заказы", subtitle: "Сборки + заявки", href: "#/assembly" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, + { icon: "wrench", title: "Ставки сборки", subtitle: "% клиент / сборщик", href: "#/admin/rates" }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); @@ -1695,6 +1696,9 @@ function routeByHash() { else init(); } else if (location.hash.startsWith("#/assembly")) { Assembly.mount(app); + } else if (location.hash.startsWith("#/admin/rates")) { + if (typeof AdminRates !== "undefined") AdminRates.mount(app); + else init(); } else if (location.hash.startsWith("#/master/tools")) { if (typeof MasterTools !== "undefined") { const h = location.hash; diff --git a/miniapp/assets/assembly_detail.js b/miniapp/assets/assembly_detail.js index 91ffa2e..db6302c 100644 --- a/miniapp/assets/assembly_detail.js +++ b/miniapp/assets/assembly_detail.js @@ -102,13 +102,29 @@ const AssemblyDetailScreen = (function () { `; + // Финансовый блок — ставки из backend (настраиваются в админке) + const _kp = data.kitchen_price ? Number(data.kitchen_price) : 0; + const _cr = data.client_rate_pct || 10; + const _ar = data.assembler_rate_pct || 9; + const _cp = data.assembly_price_for_client != null + ? Number(data.assembly_price_for_client) + : (_kp ? Math.round(_kp * _cr / 100) : 0); + const _ap = data.assembler_payout != null + ? Number(data.assembler_payout) + : null; + + const _priceRows = _kp ? ` + ${row("Стоимость кухни", _kp.toLocaleString("ru-RU") + " ₽")} + ${row("Стоимость сборки (" + _cr + "%)", _cp.toLocaleString("ru-RU") + " ₽")} + ${_ap != null ? row("Ваш заработок (" + _ar + "%)", Math.round(_ap).toLocaleString("ru-RU") + " ₽", {color: "var(--accent)"}) : ""} + ` : ""; + // Основные данные const mainBlock = `
${row("Адрес", data.address)} - ${data.kitchen_price ? row("Стоимость кухни", Number(data.kitchen_price).toLocaleString("ru-RU") + " ₽") : ""} - ${data.kitchen_price ? row("Стоимость сборки", Number(Math.round(data.kitchen_price * 0.09)).toLocaleString("ru-RU") + " ₽", {color: "var(--accent)"}) : ""} + ${_priceRows} ${row("Объём работ", data.scope_of_work)} ${row("Дата сборки", fmtDate(data.scheduled_at))} ${row("Начало", fmtDate(data.started_at))} diff --git a/miniapp/index.html b/miniapp/index.html index 3dd96a3..840546b 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -53,7 +53,8 @@ - - + + +