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(` +
+
+
ПОДБОР ТЕХНИКИ
+ ${escHtml(STATUS_LABELS[proposal.status] || proposal.status)} +
+ `)); + + 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 ? `
«${escHtml(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 + ? `
💬 ${escHtml(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 ? `
${escHtml(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
- - - - - - - - - - + + + + + + + + + + +