diff --git a/backend-py/app/main.py b/backend-py/app/main.py
index 746b386..030aa48 100644
--- a/backend-py/app/main.py
+++ b/backend-py/app/main.py
@@ -18,6 +18,7 @@ from .config import get_config
from .auth import verify_init_data
from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder, drive
from . import parsers
+from . import proposals as proposals_mod
from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
@@ -126,6 +127,15 @@ async def _dispatch_post(request: Request):
"assembly_create": _handle_assembly_create,
"assembly_list": _handle_assembly_list,
"assembly_detail": _handle_assembly_detail,
+ "proposal_brief": proposals_mod.handle_brief,
+ "proposal_create": proposals_mod.handle_create,
+ "proposal_upsert_variant": proposals_mod.handle_upsert_variant,
+ "proposal_remove_variant": proposals_mod.handle_remove_variant,
+ "proposal_send": proposals_mod.handle_send,
+ "proposal_list": proposals_mod.handle_list,
+ "proposal_detail": proposals_mod.handle_detail,
+ "proposal_vote": proposals_mod.handle_vote,
+ "proposal_client_submit": proposals_mod.handle_client_submit,
"ping": lambda b: {"pong": True, "time": _now_iso()},
"seed_admin": lambda b: _handle_seed_admin(),
"test_ai": lambda b: _handle_test_ai(),
@@ -375,6 +385,52 @@ async def api_arrivals(request: Request):
return JSONResponse(await asyncio.to_thread(_handle_arrivals, body))
+@app.post("/api/proposal_brief")
+async def api_proposal_brief(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_brief(body))
+
+@app.post("/api/proposal_create")
+async def api_proposal_create(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_create(body))
+
+@app.post("/api/proposal_upsert_variant")
+async def api_proposal_upsert_variant(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_upsert_variant(body))
+
+@app.post("/api/proposal_remove_variant")
+async def api_proposal_remove_variant(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_remove_variant(body))
+
+@app.post("/api/proposal_send")
+async def api_proposal_send(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_send(body))
+
+@app.post("/api/proposal_list")
+async def api_proposal_list(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_list(body))
+
+@app.post("/api/proposal_detail")
+async def api_proposal_detail(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_detail(body))
+
+@app.post("/api/proposal_vote")
+async def api_proposal_vote(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_vote(body))
+
+@app.post("/api/proposal_client_submit")
+async def api_proposal_client_submit(request: Request):
+ body = await _safe_json(request)
+ return JSONResponse(proposals_mod.handle_client_submit(body))
+
+
def _handle_daily_reminders() -> dict[str, Any]:
"""Находит клиентов с годовщиной договора сегодня по МСК.
Дедуплицирует: один менеджер + один клиент = одно уведомление,
diff --git a/backend-py/app/proposals.py b/backend-py/app/proposals.py
new file mode 100644
index 0000000..7fe1f29
--- /dev/null
+++ b/backend-py/app/proposals.py
@@ -0,0 +1,605 @@
+"""Модуль «Подбор техники — цикл согласования».
+
+Цикл:
+ 1. Клиент заполняет brief (анкету пожеланий) → status=brief
+ 2. Менеджер видит brief, создаёт/дополняет подборку → status=draft
+ 3. Менеджер отправляет клиенту → status=sent
+ 4. Клиент голосует (✅/❌) + оставляет комментарий → status=reviewed
+ 5. Менеджер фиксирует итог → status=done
+
+Google Sheets: лист «Proposals».
+"""
+from __future__ import annotations
+
+import json
+import uuid
+import logging
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+
+from . import sheets
+from .auth import verify_init_data
+from .config import get_config
+
+log = logging.getLogger("zov.proposals")
+
+# ---------------------------------------------------------------------------
+# Sheet setup
+# ---------------------------------------------------------------------------
+
+PROPOSALS_HEADERS = [
+ "id", "client_key", "client_tg_id", "manager_tg_id",
+ "status", # brief | draft | sent | reviewed | done | archived
+ "brief_json", # JSON объект с анкетой клиента
+ "positions_json", # JSON массив категорий с вариантами
+ "client_comment", # общий текстовый комментарий клиента
+ "manager_comment", # финальная заметка менеджера
+ "created_at", "sent_at", "reviewed_at", "archived_at",
+]
+
+ACTIVE_STATUSES = {"brief", "draft", "sent", "reviewed"}
+
+
+def ensure_sheet() -> None:
+ sheets.ensure_sheet("Proposals", PROPOSALS_HEADERS)
+
+
+# ---------------------------------------------------------------------------
+# Internal helpers
+# ---------------------------------------------------------------------------
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat()
+
+
+def _short_id() -> str:
+ return uuid.uuid4().hex[:13]
+
+
+def _parse_json(raw: str, default: Any) -> Any:
+ try:
+ return json.loads(raw) if raw and raw.strip() else default
+ except Exception:
+ return default
+
+
+def _row_to_dict(headers: list[str], row: list) -> dict[str, Any]:
+ d = dict(zip(headers, row + [""] * (len(headers) - len(row))))
+ d["brief_json"] = _parse_json(d.get("brief_json", ""), {})
+ d["positions_json"] = _parse_json(d.get("positions_json", ""), [])
+ return d
+
+
+def _get_all(ws) -> tuple[list[str], list[dict]]:
+ """Возвращает (headers, list_of_dicts), пропуская пустые строки."""
+ rows = ws.get_all_values()
+ if not rows or len(rows) < 1:
+ return [], []
+ headers = rows[0]
+ return headers, [_row_to_dict(headers, r) for r in rows[1:] if any(r)]
+
+
+def _find_row_num(ws, proposal_id: str) -> int | None:
+ """Номер строки в листе (1-based, с учётом заголовка) или None."""
+ rows = ws.get_all_values()
+ if not rows:
+ return None
+ try:
+ id_col = rows[0].index("id")
+ except ValueError:
+ return None
+ for i, row in enumerate(rows[1:], start=2):
+ if len(row) > id_col and row[id_col] == proposal_id:
+ return i
+ return None
+
+
+def _update_field(ws, row_num: int, headers: list[str], field: str, value: Any) -> None:
+ try:
+ col = headers.index(field) + 1
+ ws.update_cell(row_num, col, value)
+ except Exception as e:
+ log.warning("_update_field %s: %s", field, e)
+
+
+def _update_fields(ws, row_num: int, headers: list[str], updates: dict[str, Any]) -> None:
+ for field, value in updates.items():
+ _update_field(ws, row_num, headers, field, value)
+
+
+def _tg_notify(chat_id: str, text: str) -> None:
+ """Отправляет сообщение через Telegram Bot API (sync, fire-and-forget)."""
+ cfg = get_config()
+ if not cfg.bot_token or not chat_id:
+ return
+ try:
+ url = f"https://api.telegram.org/bot{cfg.bot_token}/sendMessage"
+ httpx.post(url, json={
+ "chat_id": chat_id,
+ "text": text,
+ "parse_mode": "HTML",
+ }, timeout=8)
+ except Exception as e:
+ log.warning("tg_notify to %s failed: %s", chat_id, e)
+
+
+def _auth(body: dict) -> tuple[str | None, dict | None]:
+ """Парсит initData → (tg_id_str, None) или (None, error_dict)."""
+ 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 None, {"error": "invalid_init_data"}
+ return str(auth["user"]["id"]), None
+
+
+def _brief_summary(brief: dict) -> str:
+ """Краткое текстовое описание анкеты для уведомления."""
+ lines = []
+ hob_labels = {"induction": "Индукция", "gas": "Газ", "electric": "Эл-во", "none": "—"}
+ if brief.get("hob"):
+ lines.append(f"Варочная: {hob_labels.get(brief['hob'], brief['hob'])}")
+ if brief.get("oven"):
+ lines.append("Духовка: нужна")
+ dw = brief.get("dishwasher")
+ if dw and dw != "none":
+ lines.append(f"Посудомойка: {dw} см")
+ hood_labels = {"builtin": "Встройка", "dome": "Купол", "none": "—"}
+ if brief.get("hood") and brief["hood"] != "none":
+ lines.append(f"Вытяжка: {hood_labels.get(brief['hood'], brief['hood'])}")
+ if brief.get("budget"):
+ lines.append(f"Бюджет: {int(brief['budget']):,} ₽".replace(",", " "))
+ if brief.get("notes"):
+ lines.append(f"Пожелания: {brief['notes'][:120]}")
+ return "\n".join(lines) if lines else "Анкета заполнена"
+
+
+# ---------------------------------------------------------------------------
+# API handlers
+# ---------------------------------------------------------------------------
+
+def handle_brief(body: dict) -> dict:
+ """Клиент сохраняет анкету пожеланий. Создаёт или обновляет Proposal со status=brief.
+ Уведомляет менеджера."""
+ tg_id, err = _auth(body)
+ if err:
+ return err
+ cfg = get_config()
+
+ brief: dict = {
+ "hob": body.get("hob", ""),
+ "oven": body.get("oven", ""),
+ "dishwasher": body.get("dishwasher", ""),
+ "hood": body.get("hood", ""),
+ "fridge": body.get("fridge", ""),
+ "microwave": body.get("microwave", ""),
+ "budget": body.get("budget", ""),
+ "notes": str(body.get("notes", "") or "").strip(),
+ }
+ client_name = str(body.get("client_name", "") or "").strip()
+ client_key = client_name.lower() if client_name else f"tg_{tg_id}"
+
+ # Менеджер из карточки клиента
+ manager_tg_id = str(cfg.admin_tg_id) if cfg.admin_tg_id else ""
+ cl_row = sheets.find_row("Clients", "client_key", client_key)
+ if cl_row:
+ manager_tg_id = str(cl_row.get("manager_tg_id", "") or manager_tg_id)
+ if not manager_tg_id:
+ # Fallback — попробуем найти по tg_id
+ cl_row2 = sheets.find_row("Clients", "client_tg_id", tg_id)
+ if cl_row2:
+ manager_tg_id = str(cl_row2.get("manager_tg_id", "") or manager_tg_id)
+ client_key = cl_row2.get("client_key", client_key)
+
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, all_dicts = _get_all(ws)
+
+ # Ищем существующий активный proposal этого клиента
+ existing = next(
+ (d for d in all_dicts
+ if (str(d.get("client_tg_id")) == tg_id or d.get("client_key") == client_key)
+ and d.get("status") in ACTIVE_STATUSES
+ and not d.get("archived_at")),
+ None,
+ )
+
+ if existing:
+ proposal_id = existing["id"]
+ row_num = _find_row_num(ws, proposal_id)
+ if row_num:
+ _update_fields(ws, row_num, headers, {
+ "brief_json": json.dumps(brief, ensure_ascii=False),
+ "status": "brief",
+ "client_tg_id": tg_id,
+ })
+ else:
+ proposal_id = _short_id()
+ row = [
+ proposal_id, client_key, tg_id, manager_tg_id,
+ "brief",
+ json.dumps(brief, ensure_ascii=False),
+ "[]", "", "",
+ _now(), "", "", "",
+ ]
+ sheets.append_row("Proposals", row)
+
+ # Уведомление менеджеру
+ if manager_tg_id:
+ name_tag = f"{client_name}" if client_name else f"клиент (tg {tg_id})"
+ _tg_notify(manager_tg_id,
+ f"📋 {name_tag} заполнил анкету на подбор техники:\n\n"
+ f"{_brief_summary(brief)}\n\n"
+ f"Откройте карточку клиента, чтобы начать подбор.")
+
+ return {"ok": True, "proposal_id": proposal_id}
+
+
+def handle_create(body: dict) -> dict:
+ """Менеджер вручную создаёт Proposal для клиента (status=draft)."""
+ tg_id, err = _auth(body)
+ if err:
+ return err
+ user = sheets.find_user(tg_id)
+ if not user or not sheets.has_role(user, "manager"):
+ return {"error": "only_manager"}
+
+ client_key = str(body.get("client_key", "") or "").strip().lower()
+ if not client_key:
+ return {"error": "client_key required"}
+
+ client_tg_id = str(body.get("client_tg_id", "") or "")
+
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, all_dicts = _get_all(ws)
+
+ # Проверяем, нет ли уже активного
+ existing = next(
+ (d for d in all_dicts
+ if d.get("client_key") == client_key
+ and d.get("status") in ACTIVE_STATUSES
+ and not d.get("archived_at")),
+ None,
+ )
+ if existing:
+ return {"ok": True, "proposal_id": existing["id"], "existing": True}
+
+ proposal_id = _short_id()
+ row = [
+ proposal_id, client_key, client_tg_id, tg_id,
+ "draft", "{}", "[]", "", "",
+ _now(), "", "", "",
+ ]
+ sheets.append_row("Proposals", row)
+ return {"ok": True, "proposal_id": proposal_id}
+
+
+def handle_upsert_variant(body: dict) -> dict:
+ """Менеджер добавляет или обновляет вариант в категории.
+ body: {proposal_id, category, category_label, variant: {id?, model, url, price,
+ image_url?, manager_comment?}}
+ """
+ tg_id, err = _auth(body)
+ if err:
+ return err
+ user = sheets.find_user(tg_id)
+ if not user or not sheets.has_role(user, "manager"):
+ return {"error": "only_manager"}
+
+ proposal_id = body.get("proposal_id", "")
+ category = body.get("category", "").strip()
+ cat_label = body.get("category_label", category)
+ variant_in = body.get("variant", {}) or {}
+ if not proposal_id or not category:
+ return {"error": "proposal_id and category required"}
+
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, _ = _get_all(ws)
+ row_num = _find_row_num(ws, proposal_id)
+ if not row_num:
+ return {"error": "proposal_not_found"}
+
+ rows = ws.get_all_values()
+ rd = _row_to_dict(headers, rows[row_num - 1])
+
+ # Статус должен позволять редактирование
+ if rd.get("status") not in ("brief", "draft", "reviewed"):
+ return {"error": "cannot_edit_in_this_status", "status": rd.get("status")}
+
+ positions: list[dict] = rd.get("positions_json") or []
+
+ # Найти или создать категорию
+ cat = next((p for p in positions if p.get("category") == category), None)
+ if cat is None:
+ cat = {"category": category, "label": cat_label, "variants": [], "client_comment": ""}
+ positions.append(cat)
+ else:
+ cat["label"] = cat_label # обновляем label если изменился
+
+ variant_id = str(variant_in.get("id") or _short_id())
+ variant = {
+ "id": variant_id,
+ "model": str(variant_in.get("model", "") or "").strip(),
+ "url": str(variant_in.get("url", "") or "").strip(),
+ "price": variant_in.get("price", ""),
+ "image_url": str(variant_in.get("image_url", "") or "").strip(),
+ "source": str(variant_in.get("source", "") or "").strip(),
+ "manager_comment": str(variant_in.get("manager_comment", "") or "").strip(),
+ "client_vote": None,
+ }
+
+ # Обновляем если уже есть, иначе добавляем
+ existing_v = next((v for v in cat["variants"] if v.get("id") == variant_id), None)
+ if existing_v:
+ existing_v.update({k: v for k, v in variant.items() if k != "client_vote"})
+ else:
+ cat["variants"].append(variant)
+
+ _update_fields(ws, row_num, headers, {
+ "positions_json": json.dumps(positions, ensure_ascii=False),
+ "status": "draft",
+ })
+ return {"ok": True, "variant_id": variant_id}
+
+
+def handle_remove_variant(body: dict) -> dict:
+ """Менеджер удаляет вариант или целую категорию."""
+ tg_id, err = _auth(body)
+ if err:
+ return err
+ user = sheets.find_user(tg_id)
+ if not user or not sheets.has_role(user, "manager"):
+ return {"error": "only_manager"}
+
+ proposal_id = body.get("proposal_id", "")
+ category = body.get("category", "").strip()
+ variant_id = body.get("variant_id", "").strip() # пусто = удалить всю категорию
+
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, _ = _get_all(ws)
+ row_num = _find_row_num(ws, proposal_id)
+ if not row_num:
+ return {"error": "proposal_not_found"}
+
+ rows = ws.get_all_values()
+ rd = _row_to_dict(headers, rows[row_num - 1])
+ positions: list[dict] = rd.get("positions_json") or []
+
+ if variant_id:
+ cat = next((p for p in positions if p.get("category") == category), None)
+ if cat:
+ cat["variants"] = [v for v in cat["variants"] if v.get("id") != variant_id]
+ if not cat["variants"]:
+ positions = [p for p in positions if p.get("category") != category]
+ else:
+ positions = [p for p in positions if p.get("category") != category]
+
+ _update_field(ws, row_num, headers, "positions_json",
+ json.dumps(positions, ensure_ascii=False))
+ return {"ok": True}
+
+
+def handle_send(body: dict) -> dict:
+ """Менеджер отправляет подборку клиенту. status → sent.
+ Уведомляет клиента в бот."""
+ tg_id, err = _auth(body)
+ if err:
+ return err
+ user = sheets.find_user(tg_id)
+ if not user or not sheets.has_role(user, "manager"):
+ return {"error": "only_manager"}
+
+ proposal_id = body.get("proposal_id", "")
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, _ = _get_all(ws)
+ row_num = _find_row_num(ws, proposal_id)
+ if not row_num:
+ return {"error": "proposal_not_found"}
+
+ rows = ws.get_all_values()
+ rd = _row_to_dict(headers, rows[row_num - 1])
+ positions: list[dict] = rd.get("positions_json") or []
+ if not positions or not any(p.get("variants") for p in positions):
+ return {"error": "no_variants_yet"}
+
+ _update_fields(ws, row_num, headers, {"status": "sent", "sent_at": _now()})
+
+ client_tg_id = rd.get("client_tg_id", "")
+ manager_name = str(user.get("full_name", "") or "Менеджер")
+ n_pos = len(positions)
+ _tg_notify(client_tg_id,
+ f"🛍 {manager_name} подобрал технику для вашей кухни!\n\n"
+ f"В подборке {n_pos} {'категория' if n_pos == 1 else 'категории' if 2 <= n_pos <= 4 else 'категорий'}. "
+ f"Откройте приложение, чтобы посмотреть варианты и выбрать подходящие.")
+
+ return {"ok": True}
+
+
+def handle_list(body: dict) -> dict:
+ """Список proposals.
+ Менеджер: все по своим клиентам.
+ Клиент: только свои (по tg_id).
+ """
+ tg_id, err = _auth(body)
+ if err:
+ return err
+ user = sheets.find_user(tg_id)
+ is_manager = bool(user and sheets.has_role(user, "manager"))
+
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, all_dicts = _get_all(ws)
+
+ out = []
+ for d in all_dicts:
+ if d.get("archived_at"):
+ continue
+ if is_manager:
+ if str(d.get("manager_tg_id")) != tg_id:
+ continue
+ else:
+ if str(d.get("client_tg_id")) != tg_id:
+ continue
+ # Краткая сводка без полного positions_json
+ positions = d.get("positions_json") or []
+ out.append({
+ "id": d.get("id"),
+ "client_key": d.get("client_key"),
+ "client_tg_id": d.get("client_tg_id"),
+ "status": d.get("status"),
+ "created_at": d.get("created_at"),
+ "sent_at": d.get("sent_at"),
+ "reviewed_at": d.get("reviewed_at"),
+ "brief": d.get("brief_json") or {},
+ "n_categories": len(positions),
+ "n_variants": sum(len(p.get("variants", [])) for p in positions),
+ })
+
+ out.sort(key=lambda x: x.get("created_at") or "", reverse=True)
+ return {"ok": True, "proposals": out, "is_manager": is_manager}
+
+
+def handle_detail(body: dict) -> dict:
+ """Полная карточка proposal — доступна и менеджеру, и клиенту."""
+ tg_id, err = _auth(body)
+ if err:
+ return err
+ proposal_id = body.get("proposal_id", "")
+
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, all_dicts = _get_all(ws)
+ d = next((x for x in all_dicts if x.get("id") == proposal_id), None)
+ if not d:
+ return {"error": "not_found"}
+
+ user = sheets.find_user(tg_id)
+ is_manager = bool(user and sheets.has_role(user, "manager"))
+ # Клиент видит только своё
+ if not is_manager and str(d.get("client_tg_id")) != tg_id:
+ return {"error": "forbidden"}
+
+ return {
+ "ok": True,
+ "proposal": {
+ "id": d.get("id"),
+ "client_key": d.get("client_key"),
+ "client_tg_id": d.get("client_tg_id"),
+ "manager_tg_id": d.get("manager_tg_id"),
+ "status": d.get("status"),
+ "brief": d.get("brief_json") or {},
+ "positions": d.get("positions_json") or [],
+ "client_comment": d.get("client_comment", ""),
+ "manager_comment": d.get("manager_comment", ""),
+ "created_at": d.get("created_at"),
+ "sent_at": d.get("sent_at"),
+ "reviewed_at": d.get("reviewed_at"),
+ },
+ "is_manager": is_manager,
+ }
+
+
+def handle_vote(body: dict) -> dict:
+ """Клиент голосует за вариант (✅ yes / ❌ no / null — снять голос).
+ body: {proposal_id, category, variant_id, vote: 'yes'|'no'|null}
+ """
+ tg_id, err = _auth(body)
+ if err:
+ return err
+
+ proposal_id = body.get("proposal_id", "")
+ category = body.get("category", "").strip()
+ variant_id = body.get("variant_id", "").strip()
+ vote = body.get("vote") # 'yes' | 'no' | None
+
+ if vote not in ("yes", "no", None):
+ return {"error": "vote must be yes | no | null"}
+
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, _ = _get_all(ws)
+ row_num = _find_row_num(ws, proposal_id)
+ if not row_num:
+ return {"error": "proposal_not_found"}
+
+ rows = ws.get_all_values()
+ rd = _row_to_dict(headers, rows[row_num - 1])
+
+ # Клиент голосует только в своём
+ if str(rd.get("client_tg_id")) != tg_id:
+ return {"error": "forbidden"}
+ if rd.get("status") not in ("sent", "reviewed"):
+ return {"error": "voting_not_open"}
+
+ positions: list[dict] = rd.get("positions_json") or []
+ cat = next((p for p in positions if p.get("category") == category), None)
+ if not cat:
+ return {"error": "category_not_found"}
+ variant = next((v for v in cat.get("variants", []) if v.get("id") == variant_id), None)
+ if not variant:
+ return {"error": "variant_not_found"}
+
+ variant["client_vote"] = vote
+ _update_field(ws, row_num, headers, "positions_json",
+ json.dumps(positions, ensure_ascii=False))
+ return {"ok": True}
+
+
+def handle_client_submit(body: dict) -> dict:
+ """Клиент отправляет итоговый комментарий → status=reviewed.
+ Уведомляет менеджера."""
+ tg_id, err = _auth(body)
+ if err:
+ return err
+
+ proposal_id = body.get("proposal_id", "")
+ comment = str(body.get("comment", "") or "").strip()
+
+ ensure_sheet()
+ ws = sheets.sheet("Proposals")
+ headers, _ = _get_all(ws)
+ row_num = _find_row_num(ws, proposal_id)
+ if not row_num:
+ return {"error": "proposal_not_found"}
+
+ rows = ws.get_all_values()
+ rd = _row_to_dict(headers, rows[row_num - 1])
+
+ if str(rd.get("client_tg_id")) != tg_id:
+ return {"error": "forbidden"}
+
+ _update_fields(ws, row_num, headers, {
+ "status": "reviewed",
+ "client_comment": comment,
+ "reviewed_at": _now(),
+ })
+
+ # Сводка голосов
+ positions: list[dict] = rd.get("positions_json") or []
+ vote_lines = []
+ for cat in positions:
+ for v in cat.get("variants", []):
+ vote = v.get("client_vote")
+ if vote == "yes":
+ vote_lines.append(f"✅ {cat.get('label', cat.get('category'))}: {v.get('model', '—')}")
+ elif vote == "no":
+ vote_lines.append(f"❌ {cat.get('label', cat.get('category'))}: {v.get('model', '—')}")
+ vote_text = "\n".join(vote_lines) if vote_lines else "Голосов нет"
+
+ manager_tg_id = rd.get("manager_tg_id", "")
+ client_key = rd.get("client_key", "клиент")
+ _tg_notify(manager_tg_id,
+ f"📬 {client_key.title()} ответил на подборку техники:\n\n"
+ f"{vote_text}"
+ + (f"\n\n💬 Комментарий: {comment}" if comment else ""))
+
+ return {"ok": True}
diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js
index 8e3d1aa..fc25dd9 100644
--- a/miniapp/assets/app.js
+++ b/miniapp/assets/app.js
@@ -614,7 +614,7 @@ function renderClient(me) {
label: "Подобрать кухню",
items: [
{ icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" },
- { icon: "wrench", color: "green", label: "Подобрать технику", soon: true },
+ { icon: "wrench", color: "green", label: "Подобрать технику", href: "#/c/proposal" },
{ icon: "wallet", color: "gold", label: "Калькулятор бюджета", soon: true },
],
},
@@ -1608,6 +1608,19 @@ async function init() {
hideSplash();
return;
}
+ if (location.hash.startsWith("#/c/proposal")) {
+ app.innerHTML = "";
+ document.body.classList.remove("has-bottom-nav");
+ const oldNav = document.getElementById("bottom-nav");
+ if (oldNav) oldNav.remove();
+ if (typeof Proposals !== "undefined") {
+ Proposals.mountClient(app);
+ } else {
+ app.innerHTML = `
Модуль подбора не загружен
`;
+ }
+ hideSplash();
+ return;
+ }
if (me.role === "staff") {
renderStaff(me);
} else if (me.role === "manager") {
@@ -1636,6 +1649,16 @@ function routeByHash() {
renderInboxDetail(location.hash.replace("#/inbox/", ""));
} else if (location.hash.startsWith("#/assembly")) {
Assembly.mount(app);
+ } else if (location.hash.startsWith("#/c/proposal")) {
+ app.innerHTML = "";
+ document.body.classList.remove("has-bottom-nav");
+ const oldNav2 = document.getElementById("bottom-nav");
+ if (oldNav2) oldNav2.remove();
+ if (typeof Proposals !== "undefined") {
+ Proposals.mountClient(app);
+ } else {
+ app.innerHTML = `Модуль подбора не загружен
`;
+ }
} else {
// Главный экран по роли
const me = window.__zovMe;
diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js
index f1b585a..053b01b 100644
--- a/miniapp/assets/clients.js
+++ b/miniapp/assets/clients.js
@@ -23,6 +23,10 @@ const Clients = (function () {
} else if (sub.startsWith("measurement/")) {
const measurementId = sub.slice(12);
renderMeasurement(measurementId);
+ } else if (/^client\/[^/]+\/proposals/.test(sub)) {
+ // #/clients/client/{key}/proposals — менеджерский редактор подборки
+ const clientKey = decodeURIComponent(sub.slice(7, sub.indexOf("/proposals")));
+ renderClientProposalsPage(clientKey);
} else if (sub.startsWith("client/")) {
const clientKey = decodeURIComponent(sub.slice(7));
renderClientHistory(clientKey);
@@ -390,6 +394,48 @@ const Clients = (function () {
_buildVoiceEngine(micBtn, textarea, { statusEl });
}
+ /* ===================== Подборка техники — отдельная страница менеджера ===================== */
+
+ async function renderClientProposalsPage(clientKey) {
+ root.innerHTML = "";
+ const backHref = `#/clients/client/${encodeURIComponent(clientKey)}`;
+ root.appendChild(headerEl("Подбор техники", backHref));
+
+ // Ищем клиента в кеше, чтобы знать client_tg_id
+ let clientTgId = "";
+ let clientName = clientKey;
+ const cached = clientsCache?.clients;
+ if (cached) {
+ const found = cached.find(c =>
+ (c.client_tg_id && c.client_tg_id === clientKey) ||
+ (c.client_name && c.client_name.toLowerCase() === clientKey)
+ );
+ if (found) {
+ clientTgId = found.client_tg_id || "";
+ clientName = found.client_name || clientKey;
+ }
+ }
+
+ root.appendChild(el(`
+
+
${(clientName[0] || "?").toUpperCase()}
+
+
${escHtml(clientName.length > 3 ? clientName : clientKey)}
+
Подборка техники · редактор
+
+
+ `));
+
+ const container = el(``);
+ root.appendChild(container);
+
+ if (typeof Proposals !== "undefined") {
+ await Proposals.mountManager(container, clientKey, clientTgId);
+ } else {
+ container.innerHTML = `Модуль подбора не загружен
`;
+ }
+ }
+
/* ===================== Список клиентов ===================== */
async function renderList() {
@@ -622,7 +668,8 @@ const Clients = (function () {
haptic && haptic("impact");
const act = btn.dataset.act;
if (act === "podbor") {
- location.hash = `#/podbor?client_name=${encodeURIComponent(client.client_name || "")}&client_phone=${encodeURIComponent(client.client_phone || "")}`;
+ const propKey = encodeURIComponent(client.client_tg_id || client.client_name.toLowerCase());
+ location.hash = `#/clients/client/${propKey}/proposals`;
} else if (act === "measure") {
// Pre-fill request with client info
sessionStorage.setItem("prefillClient", JSON.stringify({
@@ -654,9 +701,11 @@ const Clients = (function () {
const timelinePlaceholder = el(``);
const filesPlaceholder = el(``);
const detailsPlaceholder = el(``);
+ const proposalPlaceholder = el(``);
root.appendChild(timelinePlaceholder);
root.appendChild(filesPlaceholder);
root.appendChild(detailsPlaceholder);
+ root.appendChild(proposalPlaceholder);
let myMeasurements = [];
try {
@@ -674,6 +723,28 @@ const Clients = (function () {
// Детальные списки внизу (свёрнуты)
detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements));
+ // Подбор техники (Proposals) — секция для менеджера
+ if (typeof Proposals !== "undefined") {
+ const clientKey = (client.client_tg_id || client.client_name || "").toLowerCase();
+ const propWrapper = el(`
+
+ `);
+ proposalPlaceholder.replaceWith(propWrapper);
+ const propContainer = propWrapper.querySelector("#propInlineContainer");
+ Proposals.mountManager(propContainer, clientKey, client.client_tg_id || "")
+ .catch(() => {
+ propContainer.innerHTML = `Не удалось загрузить подборку.
`;
+ });
+ } else {
+ proposalPlaceholder.remove();
+ }
+
// (управление перенесено наверх — сразу под шапку)
}
diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css
index 93c0439..0c99986 100644
--- a/miniapp/assets/podbor.css
+++ b/miniapp/assets/podbor.css
@@ -3809,3 +3809,568 @@
/* Скрываем стрелки и иконки навигации */
.lead-arrow, .client-arrow { display: none !important; }
}
+
+/* ============================================================
+ Подбор техники — цикл согласования (Proposals)
+ ============================================================ */
+
+/* ----- Common helpers ----- */
+.prop-muted {
+ color: var(--muted);
+ font-size: 13px;
+ font-style: italic;
+}
+
+/* Radio chips (brief form) */
+.prop-chips-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 6px;
+}
+
+.prop-chip {
+ font-family: var(--font-ui);
+ font-size: 13px;
+ font-weight: 500;
+ padding: 7px 13px;
+ border-radius: var(--r-pill);
+ border: 1px solid var(--line-strong);
+ background: var(--paper);
+ color: var(--ink-2);
+ cursor: pointer;
+ transition: all 0.12s;
+}
+.prop-chip:active { transform: scale(0.97); }
+.prop-chip.on {
+ background: var(--ink);
+ color: var(--paper);
+ border-color: var(--ink);
+}
+
+/* Field groups inside proposal forms */
+.prop-field-group {
+ margin-bottom: 16px;
+}
+.prop-field-label {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--muted);
+ margin-bottom: 6px;
+}
+.prop-input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 12px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-tag);
+ background: var(--paper);
+ font-family: var(--font-ui);
+ font-size: 14px;
+ color: var(--ink);
+}
+.prop-input:focus { outline: none; border-color: var(--walnut); }
+.prop-select {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 12px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--r-tag);
+ background: var(--paper);
+ font-family: var(--font-ui);
+ font-size: 14px;
+ color: var(--ink);
+ cursor: pointer;
+}
+.two-col-group {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+/* Status chip / badge */
+.prop-status-chip, .prop-status-badge {
+ font-family: var(--font-mono);
+ font-size: 9.5px;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: 4px 9px;
+ border-radius: var(--r-pill);
+ border: 1px solid;
+ white-space: nowrap;
+}
+.prop-status-chip.brief, .prop-status-badge.brief { color: #6B4A2B; border-color: #6B4A2B; background: var(--warm); }
+.prop-status-chip.draft, .prop-status-badge.draft { color: var(--muted); border-color: var(--line-strong); background: var(--paper-2); }
+.prop-status-chip.sent, .prop-status-badge.sent { color: #1A62B0; border-color: #1A62B0; background: rgba(26,98,176,0.06); }
+.prop-status-chip.reviewed, .prop-status-badge.reviewed { color: #27AE60; border-color: #27AE60; background: rgba(39,174,96,0.08); }
+.prop-status-chip.done, .prop-status-badge.done { color: var(--muted); border-color: var(--line); background: var(--paper-2); }
+
+/* ----- CLIENT: waiting screen ----- */
+.prop-waiting {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 40px 24px;
+ gap: 12px;
+}
+.prop-waiting-icon { font-size: 48px; line-height: 1; }
+.prop-waiting-title {
+ font-family: var(--font-display);
+ font-style: italic;
+ font-size: 28px;
+ font-weight: 400;
+ color: var(--ink);
+ margin: 0;
+}
+.prop-waiting-text {
+ font-family: var(--font-ui);
+ font-size: 14px;
+ line-height: 1.55;
+ color: var(--ink-2);
+ max-width: 280px;
+}
+
+/* ----- CLIENT: category block ----- */
+.prop-cats { display: flex; flex-direction: column; gap: 24px; padding-top: 8px; }
+
+.prop-cat-block {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.prop-cat-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--line);
+}
+.prop-cat-label {
+ font-family: var(--font-display);
+ font-style: italic;
+ font-size: 20px;
+ font-weight: 400;
+ color: var(--ink);
+ flex: 1;
+}
+.prop-cat-count {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ flex-shrink: 0;
+}
+
+.prop-variants-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+/* ----- CLIENT: variant card ----- */
+.prop-variant-card {
+ display: grid;
+ grid-template-columns: 80px 1fr;
+ gap: 12px;
+ padding: 12px;
+ background: var(--card, #FFFCF6);
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ transition: border-color 0.15s, background 0.15s;
+}
+.prop-variant-card.voted-yes { border-color: #27AE60; background: rgba(39,174,96,0.05); }
+.prop-variant-card.voted-no { border-color: rgba(192,57,43,0.35); background: rgba(192,57,43,0.04); }
+
+.prop-variant-img {
+ width: 80px; height: 80px;
+ border-radius: 8px;
+ overflow: hidden;
+ background: var(--warm);
+ display: grid;
+ place-items: center;
+ flex-shrink: 0;
+}
+.prop-variant-img img { width: 100%; height: 100%; object-fit: contain; }
+.prop-variant-img.placeholder {
+ background: repeating-linear-gradient(45deg, var(--warm), var(--warm) 5px, #F0E8D5 5px, #F0E8D5 10px);
+}
+.prop-variant-img.placeholder::after { content: "📷"; font-size: 22px; opacity: 0.35; }
+
+.prop-variant-body {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+.prop-variant-name {
+ font-family: var(--font-ui);
+ font-size: 14.5px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ color: var(--ink);
+ line-height: 1.2;
+}
+.prop-variant-price {
+ font-family: var(--font-display);
+ font-style: italic;
+ font-size: 16px;
+ color: var(--ink);
+}
+.prop-variant-mgr-note {
+ font-size: 12px;
+ color: var(--ink-2);
+ line-height: 1.4;
+}
+.prop-variant-link {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--walnut);
+ text-decoration: none;
+ border-bottom: 1px dashed var(--walnut);
+ align-self: flex-start;
+ transition: opacity 0.12s;
+}
+.prop-variant-link:active { opacity: 0.7; }
+
+/* Vote row */
+.prop-vote-row {
+ display: flex;
+ gap: 6px;
+ margin-top: 4px;
+}
+.prop-vote-btn {
+ flex: 1;
+ font-family: var(--font-ui);
+ font-size: 13px;
+ font-weight: 500;
+ padding: 7px 6px;
+ border-radius: var(--r-pill);
+ border: 1px solid var(--line-strong);
+ background: var(--paper);
+ color: var(--ink-2);
+ cursor: pointer;
+ transition: all 0.12s;
+ white-space: nowrap;
+}
+.prop-vote-btn:active { transform: scale(0.97); }
+.prop-vote-btn.yes.active { background: rgba(39,174,96,0.12); border-color: #27AE60; color: #1A7A42; }
+.prop-vote-btn.no.active { background: rgba(192,57,43,0.10); border-color: rgba(192,57,43,0.5); color: #8C3F1E; }
+.prop-vote-result {
+ font-size: 12px;
+ font-weight: 500;
+ color: var(--muted);
+ margin-top: 4px;
+}
+
+/* Source badge */
+.prop-source-badge {
+ display: inline-block;
+ font-family: var(--font-mono);
+ font-size: 9px;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ padding: 2px 7px;
+ border-radius: var(--r-pill);
+ border: 1px solid;
+ color: var(--muted);
+ border-color: var(--line-strong);
+ background: var(--paper-2);
+}
+.prop-source-badge.dns { color: #C26C00; border-color: #FA9300; }
+.prop-source-badge.wb { color: #CB11AB; border-color: #CB11AB; }
+.prop-source-badge.ozon { color: #0044CC; border-color: #0044CC; }
+.prop-source-badge.citilink { color: #B57E00; border-color: #FFBA00; }
+.prop-source-badge.yamarket { color: #C0341C; border-color: #FC3F1D; }
+
+/* Reviewed note */
+.prop-reviewed-note {
+ background: rgba(39,174,96,0.07);
+ border: 1px solid #27AE60;
+ border-radius: var(--r-card);
+ padding: 14px 16px;
+ font-size: 14px;
+ color: #1A7A42;
+ margin-top: 20px;
+}
+.prop-reviewed-comment {
+ font-style: italic;
+ color: var(--ink-2);
+ margin-top: 8px;
+ font-size: 13px;
+}
+
+/* ----- MANAGER: status bar ----- */
+.prop-mgr-status-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 14px;
+}
+.prop-mgr-status-label {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: 4px 10px;
+ border-radius: var(--r-pill);
+ border: 1px solid;
+}
+.prop-mgr-status-label.brief { color: #6B4A2B; border-color: #6B4A2B; background: var(--warm); }
+.prop-mgr-status-label.draft { color: var(--muted); border-color: var(--line-strong); background: var(--paper-2); }
+.prop-mgr-status-label.sent { color: #1A62B0; border-color: #1A62B0; background: rgba(26,98,176,0.06); }
+.prop-mgr-status-label.reviewed { color: #27AE60; border-color: #27AE60; background: rgba(39,174,96,0.08); }
+.prop-mgr-status-label.done { color: var(--muted); border-color: var(--line); }
+.prop-mgr-ts {
+ font-family: var(--font-mono);
+ font-size: 9.5px;
+ letter-spacing: 0.06em;
+ color: var(--muted);
+}
+
+/* Manager: client feedback */
+.prop-client-feedback {
+ background: rgba(39,174,96,0.06);
+ border: 1px solid rgba(39,174,96,0.3);
+ border-radius: var(--r-card);
+ padding: 12px 14px;
+ margin-bottom: 14px;
+}
+.prop-feedback-head {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: #1A7A42;
+ margin-bottom: 8px;
+}
+.vote-group { margin-bottom: 8px; }
+.vote-group-head {
+ font-size: 12.5px;
+ font-weight: 600;
+ margin-bottom: 4px;
+}
+.vote-group-head.yes { color: #1A7A42; }
+.vote-group-head.no { color: #8C3F1E; }
+.vote-item {
+ font-size: 12.5px;
+ color: var(--ink-2);
+ padding-left: 12px;
+ line-height: 1.4;
+}
+.prop-client-comment-block {
+ margin-top: 8px;
+ font-style: italic;
+ font-size: 13px;
+ color: var(--ink-2);
+ border-left: 2px solid rgba(39,174,96,0.4);
+ padding-left: 10px;
+}
+
+/* Manager: brief accordion */
+.prop-brief-details {
+ margin-bottom: 14px;
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ overflow: hidden;
+}
+.prop-brief-toggle {
+ display: block;
+ padding: 10px 14px;
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--muted);
+ cursor: pointer;
+ background: var(--paper-2);
+ list-style: none;
+ user-select: none;
+}
+.prop-brief-toggle::-webkit-details-marker { display: none; }
+.prop-brief-toggle::before { content: "▶ "; font-size: 9px; }
+.prop-brief-details[open] .prop-brief-toggle::before { content: "▼ "; }
+.prop-brief-content { padding: 12px 14px; background: var(--paper); }
+.brief-rows { display: flex; flex-direction: column; gap: 6px; }
+.brief-row {
+ display: flex;
+ justify-content: space-between;
+ font-size: 13px;
+ gap: 12px;
+}
+.brief-key {
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.brief-val { color: var(--ink); text-align: right; flex: 1; }
+
+/* Manager: categories */
+.prop-mgr-cats { display: flex; flex-direction: column; gap: 16px; margin-bottom: 16px; }
+.prop-mgr-cat {
+ border: 1px solid var(--line);
+ border-radius: var(--r-card);
+ overflow: hidden;
+}
+.prop-mgr-cat-head {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ background: var(--paper-2);
+ border-bottom: 1px solid var(--line);
+}
+.prop-cat-del-btn {
+ margin-left: auto;
+ font-size: 14px;
+ color: var(--muted);
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 2px 4px;
+ line-height: 1;
+ opacity: 0.6;
+ transition: opacity 0.12s;
+ flex-shrink: 0;
+}
+.prop-cat-del-btn:hover { opacity: 1; color: #C0392B; }
+
+.prop-mgr-variants { display: flex; flex-direction: column; }
+.prop-mgr-variant-row {
+ padding: 10px 14px;
+ border-bottom: 1px solid var(--line);
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+.prop-mgr-variant-row:last-child { border-bottom: none; }
+.prop-mgr-variant-name {
+ font-family: var(--font-ui);
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--ink);
+}
+.prop-mgr-variant-meta { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
+.prop-mgr-price {
+ font-family: var(--font-display);
+ font-style: italic;
+ font-size: 14px;
+ color: var(--ink);
+}
+.prop-mgr-variant-comment {
+ font-size: 12px;
+ color: var(--muted);
+ line-height: 1.35;
+}
+.prop-variant-del-btn {
+ align-self: flex-start;
+ margin-top: 4px;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #8C3F1E;
+ background: none;
+ border: 1px solid rgba(192,57,43,0.3);
+ border-radius: var(--r-pill);
+ padding: 3px 9px;
+ cursor: pointer;
+ transition: background 0.12s;
+}
+.prop-variant-del-btn:active { background: rgba(192,57,43,0.08); }
+
+.prop-mgr-hint {
+ font-size: 13px;
+ color: var(--muted);
+ font-style: italic;
+ text-align: center;
+ padding: 16px 0;
+}
+
+/* Manager: add variant form */
+.prop-add-form {
+ border: 1.5px dashed var(--line-strong);
+ border-radius: var(--r-card);
+ overflow: hidden;
+ margin-top: 4px;
+}
+.prop-add-summary {
+ display: block;
+ padding: 12px 14px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 500;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: var(--walnut);
+ cursor: pointer;
+ background: var(--warm);
+ user-select: none;
+ list-style: none;
+}
+.prop-add-summary::-webkit-details-marker { display: none; }
+.prop-add-form[open] .prop-add-summary { border-bottom: 1px solid var(--line); }
+.prop-add-body { padding: 14px; background: var(--paper); }
+
+/* Manager: empty state */
+.prop-mgr-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 6px 0;
+}
+
+/* Proposal section wrapper in client card */
+.prop-section {
+ margin-top: 8px;
+ padding: 0;
+}
+.prop-section-head {
+ font-family: var(--font-mono);
+ font-size: 10.5px;
+ font-weight: 500;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--muted);
+ padding: 4px 0 10px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 14px;
+}
+.prop-section-open-link {
+ margin-left: auto;
+ font-family: var(--font-mono);
+ font-size: 10px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--walnut);
+ text-decoration: none;
+ border-bottom: 1px dashed var(--walnut);
+ transition: opacity 0.12s;
+}
+.prop-section-open-link:active { opacity: 0.6; }
+
diff --git a/miniapp/assets/proposals.js b/miniapp/assets/proposals.js
new file mode 100644
index 0000000..11c1c7f
--- /dev/null
+++ b/miniapp/assets/proposals.js
@@ -0,0 +1,819 @@
+/* ============================================================
+ Подбор техники — цикл согласования (Proposals)
+ Клиент: brief → просмотр вариантов → голосование
+ Менеджер: создание → добавление вариантов → отправка
+ ============================================================ */
+
+const Proposals = (function () {
+
+ // ── Internal helpers ──────────────────────────────────────
+
+ function escHtml(s) {
+ return String(s == null ? "" : s)
+ .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ }
+ function escAttr(s) { return escHtml(s); }
+
+ function authBody() {
+ return { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null };
+ }
+
+ async function apiFetch(path, extra = {}) {
+ const res = await fetch(`${BACKEND_URL}/api/${path}`, {
+ method: "POST",
+ body: JSON.stringify({ ...authBody(), ...extra }),
+ });
+ if (!res.ok) throw new Error("HTTP " + res.status);
+ return res.json();
+ }
+
+ // ── Constants ─────────────────────────────────────────────
+
+ const CAT_LABELS = {
+ hob: "Варочная панель",
+ oven: "Духовой шкаф",
+ dishwasher: "Посудомойка",
+ hood: "Вытяжка",
+ fridge: "Холодильник",
+ microwave: "Микроволновка",
+ other: "Другое",
+ };
+
+ const STATUS_LABELS = {
+ brief: "Анкета принята",
+ draft: "Подборка готовится",
+ sent: "Ожидает вашего ответа",
+ reviewed: "Ответ отправлен",
+ done: "Завершено",
+ };
+
+ const STATUS_LABELS_MGR = {
+ brief: "📋 Анкета клиента",
+ draft: "✏️ Черновик",
+ sent: "📨 Отправлено клиенту",
+ reviewed: "📬 Клиент ответил",
+ done: "✅ Завершено",
+ };
+
+ const MANAGER_CATEGORIES = [
+ { key: "hob", label: "Варочная панель" },
+ { key: "oven", label: "Духовой шкаф" },
+ { key: "dishwasher", label: "Посудомойка" },
+ { key: "hood", label: "Вытяжка" },
+ { key: "fridge", label: "Холодильник" },
+ { key: "microwave", label: "Микроволновка" },
+ { key: "other", label: "Другое" },
+ ];
+
+ // ── Plural helper ─────────────────────────────────────────
+
+ function pluralVariants(n) {
+ if (n % 10 === 1 && n % 100 !== 11) return "вариант";
+ if ([2,3,4].includes(n % 10) && ![12,13,14].includes(n % 100)) return "варианта";
+ return "вариантов";
+ }
+
+ // ── Radio chips ───────────────────────────────────────────
+
+ function radioChips(name, opts, selected) {
+ return opts.map(o => {
+ const [val, lbl] = o.split(":");
+ return ``;
+ }).join("");
+ }
+
+ function setupRadioChips(container) {
+ container.querySelectorAll(".prop-chip").forEach(btn => {
+ btn.addEventListener("click", () => {
+ const name = btn.dataset.name;
+ container.querySelectorAll(`.prop-chip[data-name="${name}"]`).forEach(b => b.classList.remove("on"));
+ btn.classList.add("on");
+ haptic && haptic("selection");
+ });
+ });
+ }
+
+ function getRadio(container, name) {
+ const active = container.querySelector(`.prop-chip[data-name="${name}"].on`);
+ return active ? active.dataset.val : null;
+ }
+
+ // ── Brief summary renderer (for manager view) ─────────────
+
+ function renderBriefSummary(brief) {
+ const hobMap = { induction: "Индукция", gas: "Газ", electric: "Электро", none: "Не нужна" };
+ const hoodMap = { builtin: "Встройка", dome: "Купол", none: "Не нужна" };
+ const rows = [];
+ if (brief.hob) rows.push(["Варочная", hobMap[brief.hob] || brief.hob]);
+ if (brief.oven === "yes") rows.push(["Духовка", "Нужна"]);
+ if (brief.dishwasher && brief.dishwasher !== "none") rows.push(["Посудомойка", brief.dishwasher + " см"]);
+ if (brief.hood && brief.hood !== "none") rows.push(["Вытяжка", hoodMap[brief.hood] || brief.hood]);
+ if (brief.fridge === "yes") rows.push(["Холодильник", "Нужен"]);
+ if (brief.microwave === "yes") rows.push(["Микроволновка", "Нужна"]);
+ if (brief.budget) rows.push(["Бюджет", Number(brief.budget).toLocaleString("ru-RU") + " ₽"]);
+ if (brief.notes) rows.push(["Пожелания", brief.notes]);
+ if (!rows.length) return `Анкета пустая
`;
+ return `${rows.map(([k, v]) =>
+ `
${escHtml(k)}${escHtml(String(v))}
`
+ ).join("")}
`;
+ }
+
+ // ── Votes summary for manager ─────────────────────────────
+
+ function renderVotesSummary(positions) {
+ const yes = [], no = [];
+ for (const cat of (positions || [])) {
+ for (const v of (cat.variants || [])) {
+ const label = `${cat.label || CAT_LABELS[cat.category] || cat.category}: ${v.model || "—"}`;
+ if (v.client_vote === "yes") yes.push(label);
+ else if (v.client_vote === "no") no.push(label);
+ }
+ }
+ if (!yes.length && !no.length) return `Клиент ещё не голосовал
`;
+ let html = "";
+ if (yes.length) html += `✅ Нравится (${yes.length})
${yes.map(l => `
${escHtml(l)}
`).join("")}
`;
+ if (no.length) html += `❌ Не подходит (${no.length})
${no.map(l => `
${escHtml(l)}
`).join("")}
`;
+ return html;
+ }
+
+ // ── Source badge ──────────────────────────────────────────
+
+ const SOURCE_LABELS = { dns: "DNS", wb: "WB", ozon: "Ozon", citilink: "Ситилинк", yamarket: "Яндекс" };
+
+ function sourceBadge(src) {
+ if (!src) return "";
+ return `${escHtml(SOURCE_LABELS[src] || src.toUpperCase())}`;
+ }
+
+ // ══════════════════════════════════════════════════════════
+ // CLIENT FLOW
+ // ══════════════════════════════════════════════════════════
+
+ async function mountClient(container) {
+ container.innerHTML = ``;
+ try {
+ const data = await apiFetch("proposal_list");
+ const proposals = (data.proposals || []);
+ const active = proposals.find(p =>
+ ["brief", "draft", "sent", "reviewed"].includes(p.status)
+ );
+ if (!active) {
+ showClientBriefForm(container, null);
+ return;
+ }
+ if (active.status === "brief" || active.status === "draft") {
+ showClientWaiting(container, active);
+ return;
+ }
+ // sent or reviewed — load full detail
+ const detail = await apiFetch("proposal_detail", { proposal_id: active.id });
+ if (detail.ok) {
+ showClientProposal(container, detail.proposal);
+ } else {
+ showClientWaiting(container, active);
+ }
+ } catch (e) {
+ container.innerHTML = `Не удалось загрузить: ${escHtml(e.message)}
`;
+ }
+ }
+
+ // ── Client: brief form ────────────────────────────────────
+
+ function showClientBriefForm(container, prefill) {
+ const p = prefill || {};
+ container.innerHTML = "";
+
+ container.appendChild(el(`
+
+ `));
+ container.querySelector(".podbor-back").addEventListener("click", () => {
+ history.back();
+ });
+
+ const form = el(`
+
+ Расскажите,
что нужно?
+ Ответьте — менеджер подберёт технику под ваш бюджет и кухню.
+
+
+
Варочная панель
+
+ ${radioChips("hob", ["none:Не нужна","induction:Индукция","gas:Газ","electric:Электро"], p.hob || "none")}
+
+
+
+
+
Духовой шкаф
+
+ ${radioChips("oven", ["no:Не нужен","yes:Нужен"], p.oven || "no")}
+
+
+
+
+
Посудомойка
+
+ ${radioChips("dw", ["none:Не нужна","45:45 см","60:60 см"], p.dishwasher || "none")}
+
+
+
+
+
Вытяжка
+
+ ${radioChips("hood", ["none:Не нужна","builtin:Встройка","dome:Купол"], p.hood || "none")}
+
+
+
+
+
Холодильник
+
+ ${radioChips("fridge_need", ["no:Не нужен","yes:Нужен"], p.fridge || "no")}
+
+
+
+
+
Микроволновка
+
+ ${radioChips("micro_need", ["no:Не нужна","yes:Нужна"], p.microwave || "no")}
+
+
+
+
+
Бюджет на технику
+
+
+
+
+
+
+
+
+
+
+ `);
+ container.appendChild(form);
+ setupRadioChips(container);
+
+ container.querySelector("#bf_submit").addEventListener("click", async () => {
+ const btn = container.querySelector("#bf_submit");
+ const result = container.querySelector("#bf_result");
+ btn.disabled = true;
+ btn.innerHTML = `Отправляем…`;
+
+ try {
+ const data = await apiFetch("proposal_brief", {
+ hob: getRadio(container, "hob") || "none",
+ oven: getRadio(container, "oven") || "no",
+ dishwasher: getRadio(container, "dw") || "none",
+ hood: getRadio(container, "hood") || "none",
+ fridge: getRadio(container, "fridge_need") || "no",
+ microwave: getRadio(container, "micro_need") || "no",
+ budget: container.querySelector("#bf_budget")?.value || "",
+ notes: container.querySelector("#bf_notes")?.value || "",
+ });
+ if (data.error) {
+ result.innerHTML = `Ошибка: ${escHtml(data.error)}
`;
+ btn.disabled = false; btn.textContent = "Отправить менеджеру";
+ return;
+ }
+ haptic && haptic("success");
+ showClientWaiting(container, { status: "brief" });
+ } catch (e) {
+ result.innerHTML = `Сеть: ${escHtml(e.message)}
`;
+ btn.disabled = false; btn.textContent = "Отправить менеджеру";
+ }
+ });
+ }
+
+ // ── Client: waiting screen ────────────────────────────────
+
+ function showClientWaiting(container, proposal) {
+ container.innerHTML = "";
+ container.appendChild(el(`
+
+
📋
+
Анкета принята!
+
+ Менеджер подбирает варианты техники.
+ Как только подборка будет готова — придёт уведомление в бот.
+
+
${escHtml(STATUS_LABELS[proposal?.status] || "В работе")}
+
+
+ `));
+ container.querySelector("#editBriefBtn")?.addEventListener("click", () => {
+ showClientBriefForm(container, null);
+ });
+ }
+
+ // ── Client: proposal view (voting) ───────────────────────
+
+ function showClientProposal(container, proposal) {
+ container.innerHTML = "";
+ const positions = proposal.positions || [];
+ const isReviewed = proposal.status === "reviewed";
+
+ container.appendChild(el(`
+
+ `));
+
+ if (!positions.length) {
+ container.appendChild(el(`Вариантов пока нет.
`));
+ return;
+ }
+
+ const catsWrap = el(``);
+ for (const cat of positions) {
+ catsWrap.appendChild(renderClientCategoryBlock(cat, proposal.id, isReviewed));
+ }
+ container.appendChild(catsWrap);
+
+ if (!isReviewed) {
+ const submitSection = el(`
+
+ Оставьте
комментарий
+ Нажмите ✅/❌ на каждый вариант и напишите, что понравилось — или нет.
+
+
+
+
+
+
+ `);
+ container.appendChild(submitSection);
+
+ container.querySelector("#cl_submit")?.addEventListener("click", async () => {
+ const btn = container.querySelector("#cl_submit");
+ const result = container.querySelector("#cl_result");
+ btn.disabled = true;
+ btn.innerHTML = `Отправляем…`;
+ try {
+ const data = await apiFetch("proposal_client_submit", {
+ proposal_id: proposal.id,
+ comment: container.querySelector("#cl_comment")?.value || "",
+ });
+ if (data.error) {
+ result.innerHTML = `Ошибка: ${escHtml(data.error)}
`;
+ btn.disabled = false; btn.textContent = "Отправить ответ менеджеру";
+ return;
+ }
+ haptic && haptic("success");
+ result.innerHTML = `
+
+
✓
+
+
Ответ отправлен!
+
Менеджер получил уведомление
+
+
`;
+ submitSection.querySelector("textarea, .podbor-cta-row")?.remove();
+ const statusChip = container.querySelector(".prop-status-chip");
+ if (statusChip) { statusChip.textContent = STATUS_LABELS.reviewed; statusChip.className = "prop-status-chip reviewed"; }
+ } catch (e) {
+ result.innerHTML = `Сеть: ${escHtml(e.message)}
`;
+ btn.disabled = false; btn.textContent = "Отправить ответ менеджеру";
+ }
+ });
+ } else {
+ container.appendChild(el(`
+
+ ✅ Вы уже отправили ответ менеджеру. Ожидайте подтверждения.
+ ${proposal.client_comment ? `` : ""}
+
+ `));
+ }
+ }
+
+ // ── Client: category block ────────────────────────────────
+
+ function renderClientCategoryBlock(cat, proposalId, isReviewed) {
+ const label = cat.label || CAT_LABELS[cat.category] || cat.category;
+ const variants = cat.variants || [];
+ const block = el(`
+
+
+ ${escHtml(label)}
+ ${variants.length} ${pluralVariants(variants.length)}
+
+
+
+ `);
+ const varList = block.querySelector(".prop-variants-list");
+ variants.forEach(v => varList.appendChild(renderClientVariantCard(v, proposalId, cat.category, isReviewed)));
+ return block;
+ }
+
+ // ── Client: variant card ──────────────────────────────────
+
+ function renderClientVariantCard(v, proposalId, category, isReviewed) {
+ const priceStr = v.price ? `${Number(v.price).toLocaleString("ru-RU")} ₽` : "";
+ const vote = v.client_vote;
+ const card = el(`
+
+ ${v.image_url
+ ? `
`
+ : `
`}
+
+
${escHtml(v.model || "—")}
+ ${priceStr ? `
${escHtml(priceStr)}
` : ""}
+ ${sourceBadge(v.source)}
+ ${v.manager_comment ? `
💬 ${escHtml(v.manager_comment)}
` : ""}
+ ${v.url ? `
Смотреть →` : ""}
+ ${isReviewed
+ ? `
${vote === "yes" ? "✅ Выбрано" : vote === "no" ? "❌ Отклонено" : "— Без оценки"}
`
+ : `
+
+
+
`
+ }
+
+
+ `);
+
+ if (!isReviewed) {
+ card.querySelectorAll(".prop-vote-btn").forEach(btn => {
+ btn.addEventListener("click", async () => {
+ const newVote = btn.dataset.vote;
+ const finalVote = (v.client_vote === newVote) ? null : newVote;
+ try {
+ const data = await apiFetch("proposal_vote", {
+ proposal_id: proposalId, category, variant_id: v.id, vote: finalVote,
+ });
+ if (data.ok) {
+ haptic && haptic("impact");
+ v.client_vote = finalVote;
+ card.className = `prop-variant-card ${finalVote === "yes" ? "voted-yes" : finalVote === "no" ? "voted-no" : ""}`;
+ card.querySelectorAll(".prop-vote-btn").forEach(b => b.classList.remove("active"));
+ if (finalVote) card.querySelector(`.prop-vote-btn[data-vote="${finalVote}"]`)?.classList.add("active");
+ }
+ } catch (_) {}
+ });
+ });
+ }
+ return card;
+ }
+
+ // ══════════════════════════════════════════════════════════
+ // MANAGER FLOW
+ // ══════════════════════════════════════════════════════════
+
+ async function mountManager(container, clientKey, clientTgId) {
+ container.innerHTML = ``;
+ try {
+ const data = await apiFetch("proposal_list");
+ const proposals = (data.proposals || []).filter(p => p.client_key === clientKey);
+ const active = proposals.find(p =>
+ ["brief", "draft", "sent", "reviewed"].includes(p.status)
+ );
+
+ if (!active) {
+ renderManagerEmpty(container, clientKey, clientTgId);
+ return;
+ }
+ const detail = await apiFetch("proposal_detail", { proposal_id: active.id });
+ if (detail.ok) {
+ renderManagerEditor(container, detail.proposal, clientKey);
+ } else {
+ renderManagerEmpty(container, clientKey, clientTgId);
+ }
+ } catch (e) {
+ container.innerHTML = `Ошибка: ${escHtml(e.message)}
`;
+ }
+ }
+
+ // ── Manager: empty state ──────────────────────────────────
+
+ function renderManagerEmpty(container, clientKey, clientTgId) {
+ container.innerHTML = "";
+ container.appendChild(el(`
+
+
Подборки для этого клиента ещё нет.
+
+
+
+ `));
+
+ container.querySelector("#mgrCreate")?.addEventListener("click", async () => {
+ const btn = container.querySelector("#mgrCreate");
+ const result = container.querySelector("#mgrCreateResult");
+ btn.disabled = true;
+ btn.innerHTML = `Создаём…`;
+ try {
+ const data = await apiFetch("proposal_create", {
+ client_key: clientKey,
+ client_tg_id: clientTgId || "",
+ });
+ if (data.error) {
+ result.innerHTML = `${escHtml(data.error)}
`;
+ btn.disabled = false; btn.textContent = "Создать подборку";
+ return;
+ }
+ await mountManager(container, clientKey, clientTgId);
+ } catch (e) {
+ result.innerHTML = `Сеть: ${escHtml(e.message)}
`;
+ btn.disabled = false; btn.textContent = "Создать подборку";
+ }
+ });
+ }
+
+ // ── Manager: main editor ──────────────────────────────────
+
+ function renderManagerEditor(container, proposal, clientKey) {
+ container.innerHTML = "";
+ const positions = proposal.positions || [];
+ const canEdit = ["brief", "draft", "reviewed"].includes(proposal.status);
+ const canSend = proposal.status === "draft" && positions.some(p => p.variants?.length);
+ const statusLbl = STATUS_LABELS_MGR[proposal.status] || proposal.status;
+
+ // Status bar
+ container.appendChild(el(`
+
+ ${escHtml(statusLbl)}
+ ${proposal.sent_at ? `Отправлено: ${escHtml(proposal.sent_at.slice(0,10))}` : ""}
+ ${proposal.reviewed_at ? `Ответ: ${escHtml(proposal.reviewed_at.slice(0,10))}` : ""}
+
+ `));
+
+ // Client feedback (reviewed state)
+ if (proposal.status === "reviewed") {
+ const fb = el(`
+
+
📬 Ответ клиента
+ ${renderVotesSummary(positions)}
+ ${proposal.client_comment
+ ? ``
+ : ""}
+
+ `);
+ container.appendChild(fb);
+ }
+
+ // Brief summary (collapsible)
+ if (proposal.brief && Object.keys(proposal.brief).some(k => proposal.brief[k] && proposal.brief[k] !== "none" && proposal.brief[k] !== "no")) {
+ const det = el(`
+
+ 📋 Анкета клиента
+ ${renderBriefSummary(proposal.brief)}
+
+ `);
+ container.appendChild(det);
+ }
+
+ // Categories
+ if (positions.length) {
+ const catsWrap = el(``);
+ positions.forEach(cat => {
+ catsWrap.appendChild(renderManagerCategoryBlock(cat, proposal, canEdit, () =>
+ mountManager(container, clientKey, proposal.client_tg_id)
+ ));
+ });
+ container.appendChild(catsWrap);
+ } else {
+ container.appendChild(el(`
+ Категорий пока нет. Добавьте первую позицию ниже.
+ `));
+ }
+
+ // Add variant form
+ if (canEdit) {
+ container.appendChild(
+ renderAddVariantForm(proposal.id, clientKey, proposal.client_tg_id, container)
+ );
+ }
+
+ // Send button
+ if (canSend) {
+ const sendWrap = el(`
+
+
+
+
+ `);
+ container.appendChild(sendWrap);
+ container.querySelector("#mgrSend")?.addEventListener("click", async () => {
+ const btn = container.querySelector("#mgrSend");
+ const result = container.querySelector("#mgrSendResult");
+ btn.disabled = true;
+ btn.innerHTML = `Отправляем…`;
+ try {
+ const data = await apiFetch("proposal_send", { proposal_id: proposal.id });
+ if (data.error) {
+ result.innerHTML = `${escHtml(data.error)}
`;
+ btn.disabled = false; btn.textContent = "📨 Отправить клиенту";
+ return;
+ }
+ haptic && haptic("success");
+ await mountManager(container, clientKey, proposal.client_tg_id);
+ } catch (e) {
+ result.innerHTML = `Сеть: ${escHtml(e.message)}
`;
+ btn.disabled = false; btn.textContent = "📨 Отправить клиенту";
+ }
+ });
+ }
+ }
+
+ // ── Manager: category block ───────────────────────────────
+
+ function renderManagerCategoryBlock(cat, proposal, canEdit, onRefresh) {
+ const label = cat.label || CAT_LABELS[cat.category] || cat.category;
+ const variants = cat.variants || [];
+ const block = el(`
+
+
+ ${escHtml(label)}
+ ${variants.length} ${pluralVariants(variants.length)}
+ ${canEdit ? `` : ""}
+
+
+
+ `);
+
+ if (canEdit) {
+ block.querySelector(".prop-cat-del-btn")?.addEventListener("click", async () => {
+ if (!confirm(`Удалить «${label}» со всеми вариантами?`)) return;
+ try {
+ await apiFetch("proposal_remove_variant", { proposal_id: proposal.id, category: cat.category });
+ await onRefresh();
+ } catch (_) {}
+ });
+ }
+
+ const varWrap = block.querySelector(".prop-mgr-variants");
+ variants.forEach(v => varWrap.appendChild(
+ renderManagerVariantRow(v, proposal, cat.category, canEdit, onRefresh)
+ ));
+ return block;
+ }
+
+ // ── Manager: variant row ──────────────────────────────────
+
+ function renderManagerVariantRow(v, proposal, category, canEdit, onRefresh) {
+ const priceStr = v.price ? `${Number(v.price).toLocaleString("ru-RU")} ₽` : "";
+ const voteIcon = v.client_vote === "yes" ? " ✅" : v.client_vote === "no" ? " ❌" : "";
+ const row = el(`
+
+
${escHtml(v.model || "—")}${voteIcon}
+
+ ${priceStr ? `${escHtml(priceStr)}` : ""}
+ ${sourceBadge(v.source)}
+
+ ${v.manager_comment ? `` : ""}
+ ${v.url ? `
Открыть →` : ""}
+ ${canEdit ? `
` : ""}
+
+ `);
+
+ if (canEdit) {
+ row.querySelector(".prop-variant-del-btn")?.addEventListener("click", async () => {
+ try {
+ await apiFetch("proposal_remove_variant", {
+ proposal_id: proposal.id, category, variant_id: v.id,
+ });
+ await onRefresh();
+ } catch (_) {}
+ });
+ }
+ return row;
+ }
+
+ // ── Manager: add variant form ─────────────────────────────
+
+ function renderAddVariantForm(proposalId, clientKey, clientTgId, container) {
+ const wrap = el(`
+
+ + Добавить позицию
+
+
+
+
Категория
+
+
+
+
+
+
+
+
+
+
+
Магазин
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `);
+
+ wrap.querySelector("#av_save")?.addEventListener("click", async () => {
+ const btn = wrap.querySelector("#av_save");
+ const result = wrap.querySelector("#av_result");
+ const model = (wrap.querySelector("#av_model")?.value || "").trim();
+ if (!model) {
+ result.innerHTML = `Укажите название модели
`;
+ return;
+ }
+ btn.disabled = true;
+ btn.innerHTML = `Добавляем…`;
+
+ const catKey = wrap.querySelector("#av_cat")?.value || "";
+ const catLabel = MANAGER_CATEGORIES.find(c => c.key === catKey)?.label || catKey;
+
+ try {
+ const data = await apiFetch("proposal_upsert_variant", {
+ proposal_id: proposalId,
+ category: catKey,
+ category_label: catLabel,
+ variant: {
+ model,
+ url: (wrap.querySelector("#av_url")?.value || "").trim(),
+ price: wrap.querySelector("#av_price")?.value || "",
+ source: wrap.querySelector("#av_source")?.value || "",
+ manager_comment: (wrap.querySelector("#av_mgr_comment")?.value || "").trim(),
+ },
+ });
+ if (data.error) {
+ result.innerHTML = `${escHtml(data.error)}
`;
+ btn.disabled = false; btn.textContent = "Добавить";
+ return;
+ }
+ haptic && haptic("success");
+ // Clear fields
+ ["av_model", "av_url", "av_price", "av_mgr_comment"].forEach(id => {
+ const el2 = wrap.querySelector(`#${id}`);
+ if (el2) el2.value = "";
+ });
+ result.innerHTML = ``;
+ btn.disabled = false; btn.textContent = "Добавить";
+ // Reload manager view
+ await mountManager(container, clientKey, clientTgId);
+ } catch (e) {
+ result.innerHTML = `Сеть: ${escHtml(e.message)}
`;
+ btn.disabled = false; btn.textContent = "Добавить";
+ }
+ });
+
+ return wrap;
+ }
+
+ // ══════════════════════════════════════════════════════════
+ // PUBLIC API
+ // ══════════════════════════════════════════════════════════
+
+ return { mountClient, mountManager };
+
+})();
diff --git a/miniapp/index.html b/miniapp/index.html
index 9627561..57f908e 100644
--- a/miniapp/index.html
+++ b/miniapp/index.html
@@ -12,8 +12,8 @@
-
-
+
+
@@ -35,15 +35,16 @@
CRM
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+