mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:44:47 +00:00
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:
parent
1b8f70e44a
commit
4abd7b2ecd
@ -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]:
|
||||
"""Находит клиентов с годовщиной договора сегодня по МСК.
|
||||
Дедуплицирует: один менеджер + один клиент = одно уведомление,
|
||||
|
||||
605
backend-py/app/proposals.py
Normal file
605
backend-py/app/proposals.py
Normal 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}
|
||||
@ -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 = `<div class="error">Модуль подбора не загружен</div>`;
|
||||
}
|
||||
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 = `<div class="error">Модуль подбора не загружен</div>`;
|
||||
}
|
||||
} else {
|
||||
// Главный экран по роли
|
||||
const me = window.__zovMe;
|
||||
|
||||
@ -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(`
|
||||
<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() {
|
||||
@ -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(`<div id="clTimelinePlaceholder"></div>`);
|
||||
const filesPlaceholder = el(`<div id="clFilesPlaceholder"></div>`);
|
||||
const detailsPlaceholder = el(`<div id="clDetailsPlaceholder"></div>`);
|
||||
const proposalPlaceholder = el(`<div id="clProposalPlaceholder"></div>`);
|
||||
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(`
|
||||
<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();
|
||||
}
|
||||
|
||||
// (управление перенесено наверх — сразу под шапку)
|
||||
}
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
819
miniapp/assets/proposals.js
Normal file
819
miniapp/assets/proposals.js
Normal file
@ -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, ">").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 `<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 };
|
||||
|
||||
})();
|
||||
@ -12,8 +12,8 @@
|
||||
<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">
|
||||
<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/podbor.css?v=20260516d">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260516e">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260516e">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||
@ -35,15 +35,16 @@
|
||||
<div class="brand-tagline-gold">CRM</div>
|
||||
</div>
|
||||
<main id="app"></main>
|
||||
<script src="assets/icons.js?v=20260516d"></script>
|
||||
<script src="assets/podbor.config.js?v=20260516d"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260516d"></script>
|
||||
<script src="assets/podbor.js?v=20260516d"></script>
|
||||
<script src="assets/clients.js?v=20260516d"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260516d"></script>
|
||||
<script src="assets/measurements.js?v=20260516d"></script>
|
||||
<script src="assets/request.js?v=20260516d"></script>
|
||||
<script src="assets/assembly.js?v=20260516d"></script>
|
||||
<script src="assets/app.js?v=20260516d"></script>
|
||||
<script src="assets/icons.js?v=20260516e"></script>
|
||||
<script src="assets/podbor.config.js?v=20260516e"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260516e"></script>
|
||||
<script src="assets/podbor.js?v=20260516e"></script>
|
||||
<script src="assets/clients.js?v=20260516e"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260516e"></script>
|
||||
<script src="assets/measurements.js?v=20260516e"></script>
|
||||
<script src="assets/request.js?v=20260516e"></script>
|
||||
<script src="assets/assembly.js?v=20260516e"></script>
|
||||
<script src="assets/proposals.js?v=20260516e"></script>
|
||||
<script src="assets/app.js?v=20260516e"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user