feat: proposal cycle — client brief + manager editor + voting

Backend:
- proposals.py: new module with full proposal cycle
  (brief → draft → sent → reviewed → done),
  Google Sheets «Proposals» tab, Telegram notifications
- main.py: import proposals_mod, 9 new /api/proposal_* routes
  added to both dispatch map and native /api/* handlers

Frontend:
- proposals.js: self-contained Proposals module
  · Client: brief form (6 items + budget + notes),
    waiting screen, proposal view with / per variant,
    overall comment + submit
  · Manager: empty state → create, editor with categories,
    add-variant form, send button, client votes/feedback view
- clients.js: «Подбор техники» button now opens proposals
  editor page (#/clients/client/{key}/proposals); inline
  Proposals.mountManager() section added to client card;
  new renderClientProposalsPage() route handler
- app.js: #/c/proposal route for client side; client home
  «Подобрать технику» menu item activated (was «скоро»)
- podbor.css: ~350 lines of Proposals UI styles
- index.html: proposals.js added, cache version → 20260516e

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-16 09:28:36 +03:00
parent 1b8f70e44a
commit 4abd7b2ecd
7 changed files with 2154 additions and 14 deletions

View File

@ -18,6 +18,7 @@ from .config import get_config
from .auth import verify_init_data from .auth import verify_init_data
from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder, drive from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder, drive
from . import parsers 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 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") 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_create": _handle_assembly_create,
"assembly_list": _handle_assembly_list, "assembly_list": _handle_assembly_list,
"assembly_detail": _handle_assembly_detail, "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()}, "ping": lambda b: {"pong": True, "time": _now_iso()},
"seed_admin": lambda b: _handle_seed_admin(), "seed_admin": lambda b: _handle_seed_admin(),
"test_ai": lambda b: _handle_test_ai(), "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)) 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]: def _handle_daily_reminders() -> dict[str, Any]:
"""Находит клиентов с годовщиной договора сегодня по МСК. """Находит клиентов с годовщиной договора сегодня по МСК.
Дедуплицирует: один менеджер + один клиент = одно уведомление, Дедуплицирует: один менеджер + один клиент = одно уведомление,

605
backend-py/app/proposals.py Normal file
View File

@ -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"<b>{client_name}</b>" 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"🛍 <b>{manager_name}</b> подобрал технику для вашей кухни!\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"📬 <b>{client_key.title()}</b> ответил на подборку техники:\n\n"
f"{vote_text}"
+ (f"\n\n💬 Комментарий: {comment}" if comment else ""))
return {"ok": True}

View File

@ -614,7 +614,7 @@ function renderClient(me) {
label: "Подобрать кухню", label: "Подобрать кухню",
items: [ items: [
{ icon: "ruler", color: "blue", label: "Замер кухни", href: "#/c/measure" }, { 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 }, { icon: "wallet", color: "gold", label: "Калькулятор бюджета", soon: true },
], ],
}, },
@ -1608,6 +1608,19 @@ async function init() {
hideSplash(); hideSplash();
return; 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 = `<div class="error">Модуль подбора не загружен</div>`;
}
hideSplash();
return;
}
if (me.role === "staff") { if (me.role === "staff") {
renderStaff(me); renderStaff(me);
} else if (me.role === "manager") { } else if (me.role === "manager") {
@ -1636,6 +1649,16 @@ function routeByHash() {
renderInboxDetail(location.hash.replace("#/inbox/", "")); renderInboxDetail(location.hash.replace("#/inbox/", ""));
} else if (location.hash.startsWith("#/assembly")) { } else if (location.hash.startsWith("#/assembly")) {
Assembly.mount(app); 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 = `<div class="error">Модуль подбора не загружен</div>`;
}
} else { } else {
// Главный экран по роли // Главный экран по роли
const me = window.__zovMe; const me = window.__zovMe;

View File

@ -23,6 +23,10 @@ const Clients = (function () {
} else if (sub.startsWith("measurement/")) { } else if (sub.startsWith("measurement/")) {
const measurementId = sub.slice(12); const measurementId = sub.slice(12);
renderMeasurement(measurementId); 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/")) { } else if (sub.startsWith("client/")) {
const clientKey = decodeURIComponent(sub.slice(7)); const clientKey = decodeURIComponent(sub.slice(7));
renderClientHistory(clientKey); renderClientHistory(clientKey);
@ -390,6 +394,48 @@ const Clients = (function () {
_buildVoiceEngine(micBtn, textarea, { statusEl }); _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(`
<div class="client-detail-head" style="padding-bottom:12px;border-bottom:1px solid var(--line);margin-bottom:16px;">
<div class="client-avatar lg">${(clientName[0] || "?").toUpperCase()}</div>
<div style="flex:1;min-width:0;">
<h2 class="client-detail-name">${escHtml(clientName.length > 3 ? clientName : clientKey)}</h2>
<div class="client-detail-meta">Подборка техники · редактор</div>
</div>
</div>
`));
const container = el(`<div class="prop-section"></div>`);
root.appendChild(container);
if (typeof Proposals !== "undefined") {
await Proposals.mountManager(container, clientKey, clientTgId);
} else {
container.innerHTML = `<div class="error">Модуль подбора не загружен</div>`;
}
}
/* ===================== Список клиентов ===================== */ /* ===================== Список клиентов ===================== */
async function renderList() { async function renderList() {
@ -622,7 +668,8 @@ const Clients = (function () {
haptic && haptic("impact"); haptic && haptic("impact");
const act = btn.dataset.act; const act = btn.dataset.act;
if (act === "podbor") { 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") { } else if (act === "measure") {
// Pre-fill request with client info // Pre-fill request with client info
sessionStorage.setItem("prefillClient", JSON.stringify({ sessionStorage.setItem("prefillClient", JSON.stringify({
@ -654,9 +701,11 @@ const Clients = (function () {
const timelinePlaceholder = el(`<div id="clTimelinePlaceholder"></div>`); const timelinePlaceholder = el(`<div id="clTimelinePlaceholder"></div>`);
const filesPlaceholder = el(`<div id="clFilesPlaceholder"></div>`); const filesPlaceholder = el(`<div id="clFilesPlaceholder"></div>`);
const detailsPlaceholder = el(`<div id="clDetailsPlaceholder"></div>`); const detailsPlaceholder = el(`<div id="clDetailsPlaceholder"></div>`);
const proposalPlaceholder = el(`<div id="clProposalPlaceholder"></div>`);
root.appendChild(timelinePlaceholder); root.appendChild(timelinePlaceholder);
root.appendChild(filesPlaceholder); root.appendChild(filesPlaceholder);
root.appendChild(detailsPlaceholder); root.appendChild(detailsPlaceholder);
root.appendChild(proposalPlaceholder);
let myMeasurements = []; let myMeasurements = [];
try { try {
@ -674,6 +723,28 @@ const Clients = (function () {
// Детальные списки внизу (свёрнуты) // Детальные списки внизу (свёрнуты)
detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements)); detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements));
// Подбор техники (Proposals) — секция для менеджера
if (typeof Proposals !== "undefined") {
const clientKey = (client.client_tg_id || client.client_name || "").toLowerCase();
const propWrapper = el(`
<div class="prop-section">
<div class="prop-section-head">
🛍 Подбор техники
<a class="prop-section-open-link" href="#/clients/client/${encodeURIComponent(clientKey)}/proposals">Открыть </a>
</div>
<div id="propInlineContainer"></div>
</div>
`);
proposalPlaceholder.replaceWith(propWrapper);
const propContainer = propWrapper.querySelector("#propInlineContainer");
Proposals.mountManager(propContainer, clientKey, client.client_tg_id || "")
.catch(() => {
propContainer.innerHTML = `<div class="prop-muted" style="padding:10px 0;">Не удалось загрузить подборку.</div>`;
});
} else {
proposalPlaceholder.remove();
}
// (управление перенесено наверх — сразу под шапку) // (управление перенесено наверх — сразу под шапку)
} }

View File

@ -3809,3 +3809,568 @@
/* Скрываем стрелки и иконки навигации */ /* Скрываем стрелки и иконки навигации */
.lead-arrow, .client-arrow { display: none !important; } .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; }

819
miniapp/assets/proposals.js Normal file
View File

@ -0,0 +1,819 @@
/* ============================================================
Подбор техники цикл согласования (Proposals)
Клиент: brief просмотр вариантов голосование
Менеджер: создание добавление вариантов отправка
============================================================ */
const Proposals = (function () {
// ── Internal helpers ──────────────────────────────────────
function escHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 `<button class="prop-chip ${val === selected ? "on" : ""}" data-name="${escAttr(name)}" data-val="${escAttr(val)}" type="button">${escHtml(lbl)}</button>`;
}).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 `<p class="prop-muted">Анкета пустая</p>`;
return `<div class="brief-rows">${rows.map(([k, v]) =>
`<div class="brief-row"><span class="brief-key">${escHtml(k)}</span><span class="brief-val">${escHtml(String(v))}</span></div>`
).join("")}</div>`;
}
// ── 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 `<p class="prop-muted">Клиент ещё не голосовал</p>`;
let html = "";
if (yes.length) html += `<div class="vote-group"><div class="vote-group-head yes">✅ Нравится (${yes.length})</div>${yes.map(l => `<div class="vote-item">${escHtml(l)}</div>`).join("")}</div>`;
if (no.length) html += `<div class="vote-group"><div class="vote-group-head no">❌ Не подходит (${no.length})</div>${no.map(l => `<div class="vote-item">${escHtml(l)}</div>`).join("")}</div>`;
return html;
}
// ── Source badge ──────────────────────────────────────────
const SOURCE_LABELS = { dns: "DNS", wb: "WB", ozon: "Ozon", citilink: "Ситилинк", yamarket: "Яндекс" };
function sourceBadge(src) {
if (!src) return "";
return `<span class="prop-source-badge ${escAttr(src)}">${escHtml(SOURCE_LABELS[src] || src.toUpperCase())}</span>`;
}
// ══════════════════════════════════════════════════════════
// CLIENT FLOW
// ══════════════════════════════════════════════════════════
async function mountClient(container) {
container.innerHTML = `<div class="loader-inline"><div class="spinner"></div></div>`;
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 = `<div class="error">Не удалось загрузить: ${escHtml(e.message)}</div>`;
}
}
// ── Client: brief form ────────────────────────────────────
function showClientBriefForm(container, prefill) {
const p = prefill || {};
container.innerHTML = "";
container.appendChild(el(`
<header class="podbor-header">
<button class="podbor-back" aria-label="Назад">${(typeof ICONS !== "undefined" && ICONS.arrow_left) || ""}</button>
<div class="podbor-title">ПОДБОР ТЕХНИКИ</div>
<div style="width:28px"></div>
</header>
`));
container.querySelector(".podbor-back").addEventListener("click", () => {
history.back();
});
const form = el(`
<section class="podbor-step">
<h2 class="display-title">Расскажите,<br><span class="accent">что нужно?</span></h2>
<p class="lede">Ответьте менеджер подберёт технику под ваш бюджет и кухню.</p>
<div class="prop-field-group">
<div class="prop-field-label">Варочная панель</div>
<div class="prop-chips-row">
${radioChips("hob", ["none:Не нужна","induction:Индукция","gas:Газ","electric:Электро"], p.hob || "none")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Духовой шкаф</div>
<div class="prop-chips-row">
${radioChips("oven", ["no:Не нужен","yes:Нужен"], p.oven || "no")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Посудомойка</div>
<div class="prop-chips-row">
${radioChips("dw", ["none:Не нужна","45:45 см","60:60 см"], p.dishwasher || "none")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Вытяжка</div>
<div class="prop-chips-row">
${radioChips("hood", ["none:Не нужна","builtin:Встройка","dome:Купол"], p.hood || "none")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Холодильник</div>
<div class="prop-chips-row">
${radioChips("fridge_need", ["no:Не нужен","yes:Нужен"], p.fridge || "no")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Микроволновка</div>
<div class="prop-chips-row">
${radioChips("micro_need", ["no:Не нужна","yes:Нужна"], p.microwave || "no")}
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Бюджет на технику</div>
<label class="field">
<input type="number" id="bf_budget" placeholder="например 120 000" inputmode="numeric"
min="0" step="1000" value="${escAttr(String(p.budget || ""))}">
<span class="field-hint">Необязательно только ориентир для менеджера</span>
</label>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Пожелания</div>
<label class="field">
<textarea id="bf_notes" rows="3" placeholder="Любим Bosch, хотелось бы паровой режим, потолок низкий…">${escHtml(p.notes || "")}</textarea>
</label>
</div>
<div class="podbor-cta-row" style="margin-top:24px;">
<button class="btn-primary" id="bf_submit" type="button">Отправить менеджеру</button>
</div>
<div id="bf_result" class="submit-result"></div>
</section>
`);
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 = `<span class="spinner-inline"></span>Отправляем…`;
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 = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "Отправить менеджеру";
return;
}
haptic && haptic("success");
showClientWaiting(container, { status: "brief" });
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false; btn.textContent = "Отправить менеджеру";
}
});
}
// ── Client: waiting screen ────────────────────────────────
function showClientWaiting(container, proposal) {
container.innerHTML = "";
container.appendChild(el(`
<div class="prop-waiting">
<div class="prop-waiting-icon">📋</div>
<h2 class="prop-waiting-title">Анкета принята!</h2>
<p class="prop-waiting-text">
Менеджер подбирает варианты техники.<br>
Как только подборка будет готова придёт уведомление в бот.
</p>
<div class="prop-status-badge ${escAttr(proposal?.status || "")}">${escHtml(STATUS_LABELS[proposal?.status] || "В работе")}</div>
<button class="btn-secondary" id="editBriefBtn" type="button" style="margin-top:20px;max-width:240px;">
Изменить анкету
</button>
</div>
`));
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(`
<header class="podbor-header">
<div style="width:28px"></div>
<div class="podbor-title">ПОДБОР ТЕХНИКИ</div>
<span class="prop-status-chip ${escAttr(proposal.status)}">${escHtml(STATUS_LABELS[proposal.status] || proposal.status)}</span>
</header>
`));
if (!positions.length) {
container.appendChild(el(`<div class="empty">Вариантов пока нет.</div>`));
return;
}
const catsWrap = el(`<div class="prop-cats"></div>`);
for (const cat of positions) {
catsWrap.appendChild(renderClientCategoryBlock(cat, proposal.id, isReviewed));
}
container.appendChild(catsWrap);
if (!isReviewed) {
const submitSection = el(`
<section class="podbor-step" style="margin-top:24px;">
<h3 class="display-title" style="font-size:20px;">Оставьте<br><span class="accent">комментарий</span></h3>
<p class="lede">Нажмите / на каждый вариант и напишите, что понравилось или нет.</p>
<label class="field">
<textarea id="cl_comment" rows="3" placeholder="Нравится вариант 1, хотелось бы посмотреть ещё что-нибудь в этом бюджете…"></textarea>
</label>
<div class="podbor-cta-row" style="margin-top:12px;">
<button class="btn-primary" id="cl_submit" type="button">Отправить ответ менеджеру</button>
</div>
<div id="cl_result" class="submit-result"></div>
</section>
`);
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 = `<span class="spinner-inline"></span>Отправляем…`;
try {
const data = await apiFetch("proposal_client_submit", {
proposal_id: proposal.id,
comment: container.querySelector("#cl_comment")?.value || "",
});
if (data.error) {
result.innerHTML = `<div class="error">Ошибка: ${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "Отправить ответ менеджеру";
return;
}
haptic && haptic("success");
result.innerHTML = `
<div class="success">
<div class="success-icon"></div>
<div>
<div class="success-title">Ответ отправлен!</div>
<div class="success-sub">Менеджер получил уведомление</div>
</div>
</div>`;
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 = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false; btn.textContent = "Отправить ответ менеджеру";
}
});
} else {
container.appendChild(el(`
<div class="prop-reviewed-note">
Вы уже отправили ответ менеджеру. Ожидайте подтверждения.
${proposal.client_comment ? `<div class="prop-reviewed-comment">«${escHtml(proposal.client_comment)}»</div>` : ""}
</div>
`));
}
}
// ── 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(`
<div class="prop-cat-block">
<div class="prop-cat-head">
<span class="prop-cat-label">${escHtml(label)}</span>
<span class="prop-cat-count">${variants.length} ${pluralVariants(variants.length)}</span>
</div>
<div class="prop-variants-list"></div>
</div>
`);
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(`
<div class="prop-variant-card ${vote === "yes" ? "voted-yes" : vote === "no" ? "voted-no" : ""}">
${v.image_url
? `<div class="prop-variant-img"><img src="${escAttr(v.image_url)}" alt="" loading="lazy"></div>`
: `<div class="prop-variant-img placeholder"></div>`}
<div class="prop-variant-body">
<div class="prop-variant-name">${escHtml(v.model || "—")}</div>
${priceStr ? `<div class="prop-variant-price">${escHtml(priceStr)}</div>` : ""}
${sourceBadge(v.source)}
${v.manager_comment ? `<div class="prop-variant-mgr-note">💬 ${escHtml(v.manager_comment)}</div>` : ""}
${v.url ? `<a class="prop-variant-link" href="${escAttr(v.url)}" target="_blank" rel="noopener noreferrer">Смотреть →</a>` : ""}
${isReviewed
? `<div class="prop-vote-result">${vote === "yes" ? "✅ Выбрано" : vote === "no" ? "❌ Отклонено" : "— Без оценки"}</div>`
: `<div class="prop-vote-row">
<button class="prop-vote-btn yes ${vote === "yes" ? "active" : ""}" data-vote="yes" type="button"> Нравится</button>
<button class="prop-vote-btn no ${vote === "no" ? "active" : ""}" data-vote="no" type="button"> Не то</button>
</div>`
}
</div>
</div>
`);
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 = `<div class="loader-inline"><div class="spinner"></div></div>`;
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 = `<div class="error">Ошибка: ${escHtml(e.message)}</div>`;
}
}
// ── Manager: empty state ──────────────────────────────────
function renderManagerEmpty(container, clientKey, clientTgId) {
container.innerHTML = "";
container.appendChild(el(`
<div class="prop-mgr-empty">
<p class="lede">Подборки для этого клиента ещё нет.</p>
<button class="btn-primary" id="mgrCreate" type="button" style="max-width:240px;">
Создать подборку
</button>
<div id="mgrCreateResult" class="submit-result"></div>
</div>
`));
container.querySelector("#mgrCreate")?.addEventListener("click", async () => {
const btn = container.querySelector("#mgrCreate");
const result = container.querySelector("#mgrCreateResult");
btn.disabled = true;
btn.innerHTML = `<span class="spinner-inline"></span>Создаём…`;
try {
const data = await apiFetch("proposal_create", {
client_key: clientKey,
client_tg_id: clientTgId || "",
});
if (data.error) {
result.innerHTML = `<div class="error">${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "Создать подборку";
return;
}
await mountManager(container, clientKey, clientTgId);
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
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(`
<div class="prop-mgr-status-bar">
<span class="prop-mgr-status-label ${escAttr(proposal.status)}">${escHtml(statusLbl)}</span>
${proposal.sent_at ? `<span class="prop-mgr-ts">Отправлено: ${escHtml(proposal.sent_at.slice(0,10))}</span>` : ""}
${proposal.reviewed_at ? `<span class="prop-mgr-ts">Ответ: ${escHtml(proposal.reviewed_at.slice(0,10))}</span>` : ""}
</div>
`));
// Client feedback (reviewed state)
if (proposal.status === "reviewed") {
const fb = el(`
<div class="prop-client-feedback">
<div class="prop-feedback-head">📬 Ответ клиента</div>
${renderVotesSummary(positions)}
${proposal.client_comment
? `<div class="prop-client-comment-block">💬 ${escHtml(proposal.client_comment)}</div>`
: ""}
</div>
`);
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(`
<details class="prop-brief-details">
<summary class="prop-brief-toggle">📋 Анкета клиента</summary>
<div class="prop-brief-content">${renderBriefSummary(proposal.brief)}</div>
</details>
`);
container.appendChild(det);
}
// Categories
if (positions.length) {
const catsWrap = el(`<div class="prop-mgr-cats"></div>`);
positions.forEach(cat => {
catsWrap.appendChild(renderManagerCategoryBlock(cat, proposal, canEdit, () =>
mountManager(container, clientKey, proposal.client_tg_id)
));
});
container.appendChild(catsWrap);
} else {
container.appendChild(el(`
<div class="prop-mgr-hint">Категорий пока нет. Добавьте первую позицию ниже.</div>
`));
}
// Add variant form
if (canEdit) {
container.appendChild(
renderAddVariantForm(proposal.id, clientKey, proposal.client_tg_id, container)
);
}
// Send button
if (canSend) {
const sendWrap = el(`
<div class="podbor-cta-row" style="margin-top:20px;">
<button class="btn-primary" id="mgrSend" type="button">📨 Отправить клиенту</button>
</div>
<div id="mgrSendResult" class="submit-result"></div>
`);
container.appendChild(sendWrap);
container.querySelector("#mgrSend")?.addEventListener("click", async () => {
const btn = container.querySelector("#mgrSend");
const result = container.querySelector("#mgrSendResult");
btn.disabled = true;
btn.innerHTML = `<span class="spinner-inline"></span>Отправляем…`;
try {
const data = await apiFetch("proposal_send", { proposal_id: proposal.id });
if (data.error) {
result.innerHTML = `<div class="error">${escHtml(data.error)}</div>`;
btn.disabled = false; btn.textContent = "📨 Отправить клиенту";
return;
}
haptic && haptic("success");
await mountManager(container, clientKey, proposal.client_tg_id);
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
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(`
<div class="prop-mgr-cat">
<div class="prop-mgr-cat-head">
<span class="prop-cat-label">${escHtml(label)}</span>
<span class="prop-cat-count">${variants.length} ${pluralVariants(variants.length)}</span>
${canEdit ? `<button class="prop-cat-del-btn" type="button" title="Удалить категорию">✕</button>` : ""}
</div>
<div class="prop-mgr-variants"></div>
</div>
`);
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(`
<div class="prop-mgr-variant-row">
<div class="prop-mgr-variant-name">${escHtml(v.model || "—")}${voteIcon}</div>
<div class="prop-mgr-variant-meta">
${priceStr ? `<span class="prop-mgr-price">${escHtml(priceStr)}</span>` : ""}
${sourceBadge(v.source)}
</div>
${v.manager_comment ? `<div class="prop-mgr-variant-comment">${escHtml(v.manager_comment)}</div>` : ""}
${v.url ? `<a class="prop-variant-link" href="${escAttr(v.url)}" target="_blank" rel="noopener noreferrer">Открыть →</a>` : ""}
${canEdit ? `<button class="prop-variant-del-btn" type="button">Удалить</button>` : ""}
</div>
`);
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(`
<details class="prop-add-form">
<summary class="prop-add-summary"> Добавить позицию</summary>
<div class="prop-add-body">
<div class="prop-field-group">
<div class="prop-field-label">Категория</div>
<select id="av_cat" class="prop-select">
${MANAGER_CATEGORIES.map(c =>
`<option value="${escAttr(c.key)}">${escHtml(c.label)}</option>`
).join("")}
</select>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Модель *</div>
<input type="text" id="av_model" placeholder="Bosch PXX875D67E" class="prop-input">
</div>
<div class="prop-field-group">
<div class="prop-field-label">Ссылка на товар</div>
<input type="url" id="av_url" placeholder="https://dns-shop.ru/…" class="prop-input">
</div>
<div class="prop-field-group two-col-group">
<div>
<div class="prop-field-label">Цена, </div>
<input type="number" id="av_price" placeholder="45 990" inputmode="numeric" class="prop-input">
</div>
<div>
<div class="prop-field-label">Магазин</div>
<select id="av_source" class="prop-select">
<option value=""></option>
<option value="dns">DNS</option>
<option value="wb">Wildberries</option>
<option value="ozon">Ozon</option>
<option value="citilink">Ситилинк</option>
<option value="yamarket">Яндекс Маркет</option>
</select>
</div>
</div>
<div class="prop-field-group">
<div class="prop-field-label">Комментарий</div>
<textarea id="av_mgr_comment" rows="2" class="prop-input"
placeholder="Топ-модель, 5 зон, авто-выкл, подходит под ширину 60 см…"></textarea>
</div>
<div class="podbor-cta-row">
<button class="btn-primary" id="av_save" type="button">Добавить</button>
</div>
<div id="av_result" class="submit-result"></div>
</div>
</details>
`);
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 = `<div class="error">Укажите название модели</div>`;
return;
}
btn.disabled = true;
btn.innerHTML = `<span class="spinner-inline"></span>Добавляем…`;
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 = `<div class="error">${escHtml(data.error)}</div>`;
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 = `<div class="success"><div class="success-icon">✓</div><div><div class="success-title">Добавлено!</div></div></div>`;
btn.disabled = false; btn.textContent = "Добавить";
// Reload manager view
await mountManager(container, clientKey, clientTgId);
} catch (e) {
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
btn.disabled = false; btn.textContent = "Добавить";
}
});
return wrap;
}
// ══════════════════════════════════════════════════════════
// PUBLIC API
// ══════════════════════════════════════════════════════════
return { mountClient, mountManager };
})();

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260516d"> <link rel="stylesheet" href="assets/styles.css?v=20260516e">
<link rel="stylesheet" href="assets/podbor.css?v=20260516d"> <link rel="stylesheet" href="assets/podbor.css?v=20260516e">
</head> </head>
<body> <body>
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск --> <!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
@ -35,15 +35,16 @@
<div class="brand-tagline-gold">CRM</div> <div class="brand-tagline-gold">CRM</div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260516d"></script> <script src="assets/icons.js?v=20260516e"></script>
<script src="assets/podbor.config.js?v=20260516d"></script> <script src="assets/podbor.config.js?v=20260516e"></script>
<script src="assets/podbor.picts.js?v=20260516d"></script> <script src="assets/podbor.picts.js?v=20260516e"></script>
<script src="assets/podbor.js?v=20260516d"></script> <script src="assets/podbor.js?v=20260516e"></script>
<script src="assets/clients.js?v=20260516d"></script> <script src="assets/clients.js?v=20260516e"></script>
<script src="assets/zamer-picts.js?v=20260516d"></script> <script src="assets/zamer-picts.js?v=20260516e"></script>
<script src="assets/measurements.js?v=20260516d"></script> <script src="assets/measurements.js?v=20260516e"></script>
<script src="assets/request.js?v=20260516d"></script> <script src="assets/request.js?v=20260516e"></script>
<script src="assets/assembly.js?v=20260516d"></script> <script src="assets/assembly.js?v=20260516e"></script>
<script src="assets/app.js?v=20260516d"></script> <script src="assets/proposals.js?v=20260516e"></script>
<script src="assets/app.js?v=20260516e"></script>
</body> </body>
</html> </html>