mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 20:24:49 +00:00
workflow B: заявка на замер от менеджера → инбокс замерщика → завершение
End-to-end поток:
1. Менеджер на главной тапает «Заказать замер» → #/request → форма:
ФИО · телефон · адрес · dropdown «Кому назначить» · заметки.
Submit → POST /api/measurement_request → строка в Measurements
со status=requested + assigned_to_tg_id. Бот шлёт DM замерщику.
2. Замерщик открывает кабинет (?role=staff) → видит inbox с заявкой.
Тап → #/inbox/<id> → карточка с реквизитами + поле datetime-local.
Сохранить дату → POST /api/measurement_schedule → status=scheduled.
Бот уведомляет менеджера.
3. В нужный день замерщик тапает «📐 Сделать замер сейчас» →
wizard открывается в update-mode (#/measure?id=<id>), pre-fill
client_name/phone из заявки, пропускает шаг «Клиент». После submit
→ backend обновляет ту же строку (status=completed) + DM менеджеру.
Backend changes:
- Расширена схема Measurements: assigned_to_tg_id, requested_by_tg_id,
scheduled_at, address, client_name, client_phone (отдельные колонки).
ensure_measurements_sheet() автоматически дополняет колонки.
- _handle_measurement переписан под 2 режима (create/update).
- 3 новые ручки: /api/measurement_request, /api/measurement_inbox,
/api/measurement_schedule. Все с правильной проверкой ролей.
- Telegram-уведомления на каждом переходе статуса.
MiniApp:
- Новый модуль request.js — wizard заявки с dropdown замерщиков
(грузится из /api/staff_list?role=measurer).
- renderStaff теперь грузит реальный инбокс из /api/measurement_inbox.
- renderInboxDetail — карточка заявки с datetime-picker.
- В quick-actions менеджера: «Заказать замер» (primary) +
«Замер сейчас» (legacy direct fill).
- measurements.js поддерживает update-mode через ?id=.
Cache bust v=20260513g.
This commit is contained in:
parent
d859e9791c
commit
67034e011a
@ -107,6 +107,9 @@ async def _dispatch_post(request: Request):
|
|||||||
"lead": _handle_lead,
|
"lead": _handle_lead,
|
||||||
"grant_role": _handle_grant_role,
|
"grant_role": _handle_grant_role,
|
||||||
"staff_list": _handle_staff_list,
|
"staff_list": _handle_staff_list,
|
||||||
|
"measurement_request": _handle_measurement_request,
|
||||||
|
"measurement_inbox": _handle_measurement_inbox,
|
||||||
|
"measurement_schedule": _handle_measurement_schedule,
|
||||||
"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(),
|
||||||
@ -179,6 +182,24 @@ async def api_measurement_detail(request: Request):
|
|||||||
return _handle_measurement_detail(body)
|
return _handle_measurement_detail(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/measurement_request")
|
||||||
|
async def api_measurement_request(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_measurement_request(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/measurement_inbox")
|
||||||
|
async def api_measurement_inbox(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_measurement_inbox(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/measurement_schedule")
|
||||||
|
async def api_measurement_schedule(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_measurement_schedule(body)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/grant_role")
|
@app.post("/api/grant_role")
|
||||||
async def api_grant_role(request: Request):
|
async def api_grant_role(request: Request):
|
||||||
"""Админ выдаёт роль другому пользователю.
|
"""Админ выдаёт роль другому пользователю.
|
||||||
@ -518,7 +539,55 @@ def _save_measurement_photo(measurement_id: str, idx: int, data_url: str) -> str
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _measurement_columns() -> list[str]:
|
||||||
|
"""Гарантирует что у листа Measurements есть все нужные колонки (расширенный набор)."""
|
||||||
|
return [
|
||||||
|
"id", "ts", "client_tg_id", "manager_tg_id", "filled_by",
|
||||||
|
"layout", "area_m2", "ceiling_mm", "walls", "openings", "infra", "niches",
|
||||||
|
"photos", "notes", "status",
|
||||||
|
# Новые поля (Commit B)
|
||||||
|
"assigned_to_tg_id", "requested_by_tg_id", "scheduled_at",
|
||||||
|
"address", "client_name", "client_phone",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_measurements_sheet() -> None:
|
||||||
|
"""Один раз догоняет схему Measurements — добавляет недостающие колонки."""
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("Measurements")
|
||||||
|
existing = ws.row_values(1)
|
||||||
|
except Exception:
|
||||||
|
sheets.ensure_sheet("Measurements", _measurement_columns())
|
||||||
|
return
|
||||||
|
want = _measurement_columns()
|
||||||
|
missing = [c for c in want if c not in existing]
|
||||||
|
if missing:
|
||||||
|
new_headers = existing + missing
|
||||||
|
ws.update("A1", [new_headers])
|
||||||
|
log.info("Measurements: дополнили колонки: %s", missing)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]:
|
||||||
|
"""Собирает строку в нужном порядке колонок (для append_row)."""
|
||||||
|
cols = _measurement_columns()
|
||||||
|
base = {
|
||||||
|
"id": measurement_id, "ts": ts,
|
||||||
|
"client_tg_id": "", "manager_tg_id": "", "filled_by": "",
|
||||||
|
"layout": "", "area_m2": "", "ceiling_mm": "",
|
||||||
|
"walls": "{}", "openings": "{}", "infra": "{}", "niches": "{}",
|
||||||
|
"photos": "", "notes": "", "status": "submitted",
|
||||||
|
"assigned_to_tg_id": "", "requested_by_tg_id": "", "scheduled_at": "",
|
||||||
|
"address": "", "client_name": "", "client_phone": "",
|
||||||
|
}
|
||||||
|
base.update(fields)
|
||||||
|
return [str(base.get(c, "")) for c in cols]
|
||||||
|
|
||||||
|
|
||||||
def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Полная сдача замера (когда форма заполнена). Поддерживает 2 режима:
|
||||||
|
1. Создать новый замер с данными (старый MVP-режим — сам менеджер сделал замер)
|
||||||
|
2. Обновить существующий request — статус → completed (для замерщика после посещения)
|
||||||
|
"""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
if not auth or not auth.get("user"):
|
if not auth or not auth.get("user"):
|
||||||
@ -528,22 +597,55 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if not user:
|
if not user:
|
||||||
return {"error": "user_not_found"}
|
return {"error": "user_not_found"}
|
||||||
|
|
||||||
|
_ensure_measurements_sheet()
|
||||||
m = body.get("measurement") or {}
|
m = body.get("measurement") or {}
|
||||||
measurement_id = _short_id()
|
existing_id = (m.get("measurement_id") or body.get("measurement_id") or "").strip()
|
||||||
filled_by = "manager_for_client" if user.get("role") == "manager" else "client_self"
|
update_mode = bool(existing_id)
|
||||||
|
is_manager = sheets.has_role(user, "manager")
|
||||||
|
is_measurer = sheets.has_role(user, "measurer")
|
||||||
|
|
||||||
client_tg_id = m.get("client_tg_id") if user.get("role") == "manager" else tg_id
|
measurement_id = existing_id or _short_id()
|
||||||
manager_tg_id = tg_id if user.get("role") == "manager" else (
|
if is_manager and not is_measurer:
|
||||||
sheets.find_row("Clients", "tg_id", tg_id) or {}
|
filled_by = "manager_for_client"
|
||||||
).get("manager_tg_id", "")
|
elif is_measurer:
|
||||||
|
filled_by = "measurer"
|
||||||
|
else:
|
||||||
|
filled_by = "client_self"
|
||||||
|
|
||||||
# Прикрепляем имя/телефон клиента к notes если client_tg_id нет (новый клиент)
|
# При update-mode загружаем существующую заявку и проверяем права
|
||||||
|
existing_row = None
|
||||||
|
if update_mode:
|
||||||
|
existing_row = sheets.find_row("Measurements", "id", measurement_id)
|
||||||
|
if not existing_row:
|
||||||
|
return {"error": "measurement_not_found"}
|
||||||
|
# Только назначенный замерщик или менеджер-владелец могут завершить
|
||||||
|
if str(existing_row.get("assigned_to_tg_id")) != str(tg_id) and \
|
||||||
|
str(existing_row.get("manager_tg_id")) != str(tg_id):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
client_tg_id = (existing_row or {}).get("client_tg_id") or \
|
||||||
|
(m.get("client_tg_id") if is_manager else tg_id) or ""
|
||||||
|
manager_tg_id = (existing_row or {}).get("manager_tg_id") or (
|
||||||
|
tg_id if is_manager else
|
||||||
|
(sheets.find_row("Clients", "tg_id", tg_id) or {}).get("manager_tg_id", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
client_name = m.get("client_name") or (existing_row or {}).get("client_name", "")
|
||||||
|
client_phone = m.get("client_phone") or (existing_row or {}).get("client_phone", "")
|
||||||
|
address = m.get("address") or (existing_row or {}).get("address", "")
|
||||||
|
assigned_to = (existing_row or {}).get("assigned_to_tg_id", "")
|
||||||
|
requested_by = (existing_row or {}).get("requested_by_tg_id", manager_tg_id or "")
|
||||||
|
scheduled_at = (existing_row or {}).get("scheduled_at", "")
|
||||||
|
|
||||||
|
# Прикрепляем имя/телефон/адрес к notes (для совместимости со старым кодом)
|
||||||
notes_full = m.get("notes", "")
|
notes_full = m.get("notes", "")
|
||||||
extras = []
|
extras = []
|
||||||
if m.get("client_name"):
|
if client_name:
|
||||||
extras.append(f"Клиент: {m['client_name']}")
|
extras.append(f"Клиент: {client_name}")
|
||||||
if m.get("client_phone"):
|
if client_phone:
|
||||||
extras.append(f"Тел: {m['client_phone']}")
|
extras.append(f"Тел: {client_phone}")
|
||||||
|
if address:
|
||||||
|
extras.append(f"Адрес: {address}")
|
||||||
if extras:
|
if extras:
|
||||||
notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "")
|
notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "")
|
||||||
|
|
||||||
@ -560,23 +662,71 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
# уже готовое имя/URL — пропускаем как есть
|
# уже готовое имя/URL — пропускаем как есть
|
||||||
saved_photos.append(p)
|
saved_photos.append(p)
|
||||||
|
|
||||||
sheets.append_row("Measurements", [
|
status_new = "completed"
|
||||||
measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "",
|
walls_json = json.dumps(m.get("walls") or {}, ensure_ascii=False)
|
||||||
filled_by,
|
openings_json = json.dumps(m.get("openings") or {}, ensure_ascii=False)
|
||||||
m.get("layout", ""), m.get("area_m2", ""), m.get("ceiling_mm", ""),
|
infra_json = json.dumps(m.get("infra") or {}, ensure_ascii=False)
|
||||||
json.dumps(m.get("walls") or {}, ensure_ascii=False),
|
niches_json = json.dumps(m.get("niches") or {}, ensure_ascii=False)
|
||||||
json.dumps(m.get("openings") or {}, ensure_ascii=False),
|
photos_str = ",".join(saved_photos)
|
||||||
json.dumps(m.get("infra") or {}, ensure_ascii=False),
|
|
||||||
json.dumps(m.get("niches") or {}, ensure_ascii=False),
|
if update_mode:
|
||||||
",".join(saved_photos),
|
# Обновляем существующую заявку — статус → completed, плюс заполняем поля
|
||||||
notes_full,
|
updates = {
|
||||||
"submitted",
|
"filled_by": filled_by,
|
||||||
])
|
"layout": m.get("layout", ""),
|
||||||
|
"area_m2": m.get("area_m2", ""),
|
||||||
|
"ceiling_mm": m.get("ceiling_mm", ""),
|
||||||
|
"walls": walls_json,
|
||||||
|
"openings": openings_json,
|
||||||
|
"infra": infra_json,
|
||||||
|
"niches": niches_json,
|
||||||
|
"photos": photos_str,
|
||||||
|
"notes": notes_full,
|
||||||
|
"status": status_new,
|
||||||
|
}
|
||||||
|
for col, val in updates.items():
|
||||||
|
sheets.update_cell_by_key("Measurements", "id", measurement_id, col, val)
|
||||||
|
else:
|
||||||
|
sheets.append_row("Measurements", _row_for_measurement(
|
||||||
|
measurement_id, _now_iso(),
|
||||||
|
client_tg_id=client_tg_id or "",
|
||||||
|
manager_tg_id=manager_tg_id or "",
|
||||||
|
filled_by=filled_by,
|
||||||
|
layout=m.get("layout", ""),
|
||||||
|
area_m2=m.get("area_m2", ""),
|
||||||
|
ceiling_mm=m.get("ceiling_mm", ""),
|
||||||
|
walls=walls_json,
|
||||||
|
openings=openings_json,
|
||||||
|
infra=infra_json,
|
||||||
|
niches=niches_json,
|
||||||
|
photos=photos_str,
|
||||||
|
notes=notes_full,
|
||||||
|
status=status_new,
|
||||||
|
assigned_to_tg_id=assigned_to,
|
||||||
|
requested_by_tg_id=requested_by,
|
||||||
|
scheduled_at=scheduled_at,
|
||||||
|
address=address,
|
||||||
|
client_name=client_name,
|
||||||
|
client_phone=client_phone,
|
||||||
|
))
|
||||||
|
|
||||||
if client_tg_id:
|
if client_tg_id:
|
||||||
sheets.update_cell_by_key("Clients", "tg_id", client_tg_id, "last_measurement_id", measurement_id)
|
sheets.update_cell_by_key("Clients", "tg_id", client_tg_id, "last_measurement_id", measurement_id)
|
||||||
|
|
||||||
if filled_by == "client_self" and manager_tg_id:
|
# Уведомления
|
||||||
|
if update_mode and existing_row:
|
||||||
|
# Замерщик завершил — пишем менеджеру который создавал заявку
|
||||||
|
notify_to = requested_by or manager_tg_id
|
||||||
|
if notify_to and str(notify_to) != str(tg_id):
|
||||||
|
tg.send_message(
|
||||||
|
notify_to,
|
||||||
|
f"✅ <b>Замер выполнен</b>\n"
|
||||||
|
f"Клиент: <b>{client_name or '—'}</b>\n"
|
||||||
|
f"Замерщик: {user.get('full_name') or tg_id}\n"
|
||||||
|
f"Фото: {len(saved_photos)} шт · площадь {m.get('area_m2') or '—'} м²\n\n"
|
||||||
|
f"Откройте кабинет — можно запускать подбор техники."
|
||||||
|
)
|
||||||
|
elif filled_by == "client_self" and manager_tg_id:
|
||||||
tg.send_message(
|
tg.send_message(
|
||||||
manager_tg_id,
|
manager_tg_id,
|
||||||
f"📐 Новый замер от клиента <b>{user.get('full_name') or tg_id}</b>.\n"
|
f"📐 Новый замер от клиента <b>{user.get('full_name') or tg_id}</b>.\n"
|
||||||
@ -584,8 +734,10 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
f"Открыть в кабинете для просмотра."
|
f"Открыть в кабинете для просмотра."
|
||||||
)
|
)
|
||||||
|
|
||||||
sheets.log_event("measurement_submitted", tg_id, {"id": measurement_id, "filled_by": filled_by})
|
sheets.log_event("measurement_submitted", tg_id, {
|
||||||
return {"ok": True, "id": measurement_id, "photos": saved_photos}
|
"id": measurement_id, "filled_by": filled_by, "update_mode": update_mode,
|
||||||
|
})
|
||||||
|
return {"ok": True, "id": measurement_id, "photos": saved_photos, "status": status_new}
|
||||||
|
|
||||||
|
|
||||||
def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]:
|
def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
@ -870,6 +1022,177 @@ def _handle_staff_list(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"ok": True, "role": role, "staff": sheets.list_users_with_role(role)}
|
return {"ok": True, "role": role, "staff": sheets.list_users_with_role(role)}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_measurement_request(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Менеджер создаёт ЗАЯВКУ на замер (без замеров — пустая заготовка).
|
||||||
|
body: {initData, client_name, client_phone, address, assigned_to_tg_id?, notes?}"""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user or not sheets.has_role(user, "manager"):
|
||||||
|
return {"error": "only_manager"}
|
||||||
|
|
||||||
|
_ensure_measurements_sheet()
|
||||||
|
client_name = (body.get("client_name") or "").strip()
|
||||||
|
client_phone = (body.get("client_phone") or "").strip()
|
||||||
|
address = (body.get("address") or "").strip()
|
||||||
|
assigned_to = str(body.get("assigned_to_tg_id") or "").strip()
|
||||||
|
notes = (body.get("notes") or "").strip()
|
||||||
|
|
||||||
|
if not client_name or not client_phone:
|
||||||
|
return {"error": "missing_client_info", "hint": "client_name and client_phone are required"}
|
||||||
|
|
||||||
|
# Если назначен — проверим что у него есть роль measurer
|
||||||
|
if assigned_to:
|
||||||
|
try:
|
||||||
|
assigned_user = sheets.find_user(int(assigned_to))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
assigned_user = None
|
||||||
|
if not assigned_user or not sheets.has_role(assigned_user, "measurer"):
|
||||||
|
return {"error": "assigned_not_measurer"}
|
||||||
|
|
||||||
|
measurement_id = _short_id()
|
||||||
|
sheets.append_row("Measurements", _row_for_measurement(
|
||||||
|
measurement_id, _now_iso(),
|
||||||
|
manager_tg_id=tg_id,
|
||||||
|
filled_by="request",
|
||||||
|
status="requested",
|
||||||
|
assigned_to_tg_id=assigned_to,
|
||||||
|
requested_by_tg_id=tg_id,
|
||||||
|
address=address,
|
||||||
|
client_name=client_name,
|
||||||
|
client_phone=client_phone,
|
||||||
|
notes=notes,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Уведомление назначенному замерщику
|
||||||
|
if assigned_to:
|
||||||
|
tg.send_message(
|
||||||
|
int(assigned_to),
|
||||||
|
f"📐 <b>Новая заявка на замер</b>\n\n"
|
||||||
|
f"Клиент: <b>{client_name}</b>\n"
|
||||||
|
f"Телефон: <code>{client_phone}</code>\n"
|
||||||
|
f"Адрес: {address or '—'}\n"
|
||||||
|
f"От менеджера: {user.get('full_name') or tg_id}\n\n"
|
||||||
|
f"{notes if notes else ''}\n"
|
||||||
|
f"Откройте кабинет — назначьте дату."
|
||||||
|
)
|
||||||
|
|
||||||
|
sheets.log_event("measurement_requested", tg_id, {
|
||||||
|
"id": measurement_id, "assigned_to": assigned_to, "client": client_name,
|
||||||
|
})
|
||||||
|
return {"ok": True, "id": measurement_id, "status": "requested", "assigned_to_tg_id": assigned_to}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_measurement_inbox(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Замерщик: список назначенных мне заявок (requested/scheduled/in_progress)."""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user or not sheets.has_role(user, "measurer"):
|
||||||
|
return {"error": "only_measurer"}
|
||||||
|
|
||||||
|
_ensure_measurements_sheet()
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("Measurements")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("inbox read failed: %s", e)
|
||||||
|
return {"ok": True, "measurements": []}
|
||||||
|
|
||||||
|
if not rows or len(rows) < 2:
|
||||||
|
return {"ok": True, "measurements": []}
|
||||||
|
headers = rows[0]
|
||||||
|
active_statuses = {"requested", "scheduled", "in_progress"}
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for r in rows[1:]:
|
||||||
|
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
|
||||||
|
if str(row.get("assigned_to_tg_id", "")) != str(tg_id):
|
||||||
|
continue
|
||||||
|
if row.get("status") not in active_statuses:
|
||||||
|
continue
|
||||||
|
out.append({
|
||||||
|
"id": row.get("id"),
|
||||||
|
"created_at": row.get("ts"),
|
||||||
|
"status": row.get("status"),
|
||||||
|
"scheduled_at": row.get("scheduled_at", ""),
|
||||||
|
"client_name": row.get("client_name", ""),
|
||||||
|
"client_phone": row.get("client_phone", ""),
|
||||||
|
"address": row.get("address", ""),
|
||||||
|
"notes": row.get("notes", ""),
|
||||||
|
"manager_tg_id": row.get("manager_tg_id", ""),
|
||||||
|
"requested_by_tg_id": row.get("requested_by_tg_id", ""),
|
||||||
|
})
|
||||||
|
# Назначенная дата → первая; затем requested без даты
|
||||||
|
def _sort_key(item):
|
||||||
|
sched = item.get("scheduled_at") or ""
|
||||||
|
return (0 if sched else 1, sched, item.get("created_at") or "")
|
||||||
|
out.sort(key=_sort_key)
|
||||||
|
return {"ok": True, "count": len(out), "measurements": out}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_measurement_schedule(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Замерщик назначает дату посещения. body: {initData, measurement_id, scheduled_at}"""
|
||||||
|
cfg = get_config()
|
||||||
|
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||||
|
if not auth or not auth.get("user"):
|
||||||
|
return {"error": "invalid_init_data"}
|
||||||
|
tg_id = auth["user"]["id"]
|
||||||
|
user = sheets.find_user(tg_id)
|
||||||
|
if not user or not sheets.has_role(user, "measurer"):
|
||||||
|
return {"error": "only_measurer"}
|
||||||
|
|
||||||
|
measurement_id = (body.get("measurement_id") or "").strip()
|
||||||
|
scheduled_at = (body.get("scheduled_at") or "").strip()
|
||||||
|
if not measurement_id or not scheduled_at:
|
||||||
|
return {"error": "missing_fields"}
|
||||||
|
|
||||||
|
row = sheets.find_row("Measurements", "id", measurement_id)
|
||||||
|
if not row:
|
||||||
|
return {"error": "measurement_not_found"}
|
||||||
|
if str(row.get("assigned_to_tg_id")) != str(tg_id):
|
||||||
|
return {"error": "forbidden"}
|
||||||
|
|
||||||
|
sheets.update_cell_by_key("Measurements", "id", measurement_id, "scheduled_at", scheduled_at)
|
||||||
|
sheets.update_cell_by_key("Measurements", "id", measurement_id, "status", "scheduled")
|
||||||
|
|
||||||
|
# Уведомляем менеджера
|
||||||
|
notify_to = row.get("requested_by_tg_id") or row.get("manager_tg_id")
|
||||||
|
if notify_to and str(notify_to) != str(tg_id):
|
||||||
|
try:
|
||||||
|
tg.send_message(
|
||||||
|
int(notify_to),
|
||||||
|
f"📅 <b>Замер назначен</b>\n\n"
|
||||||
|
f"Клиент: <b>{row.get('client_name') or '—'}</b>\n"
|
||||||
|
f"Дата: {_format_date_human(scheduled_at)}\n"
|
||||||
|
f"Замерщик: {user.get('full_name') or tg_id}\n"
|
||||||
|
f"Адрес: {row.get('address') or '—'}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sheets.log_event("measurement_scheduled", tg_id, {
|
||||||
|
"id": measurement_id, "scheduled_at": scheduled_at,
|
||||||
|
})
|
||||||
|
return {"ok": True, "id": measurement_id, "status": "scheduled", "scheduled_at": scheduled_at}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_date_human(iso: str) -> str:
|
||||||
|
"""ISO datetime → '15.05.2026 14:00' для уведомлений."""
|
||||||
|
if not iso:
|
||||||
|
return "—"
|
||||||
|
try:
|
||||||
|
d = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||||
|
return d.strftime("%d.%m.%Y %H:%M")
|
||||||
|
except Exception:
|
||||||
|
return iso
|
||||||
|
|
||||||
|
|
||||||
def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
|
def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Возвращает один замер целиком — для детальной страницы и печати."""
|
"""Возвращает один замер целиком — для детальной страницы и печати."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
@ -922,6 +1245,13 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"photos": photo_files,
|
"photos": photo_files,
|
||||||
"notes": row.get("notes", ""),
|
"notes": row.get("notes", ""),
|
||||||
"status": row.get("status", ""),
|
"status": row.get("status", ""),
|
||||||
|
# Новые поля (Commit B)
|
||||||
|
"assigned_to_tg_id": row.get("assigned_to_tg_id", ""),
|
||||||
|
"requested_by_tg_id": row.get("requested_by_tg_id", ""),
|
||||||
|
"scheduled_at": row.get("scheduled_at", ""),
|
||||||
|
"address": row.get("address", ""),
|
||||||
|
"client_name": row.get("client_name", ""),
|
||||||
|
"client_phone": row.get("client_phone", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -179,8 +179,8 @@ function renderManagerHome(me) {
|
|||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
|
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
|
||||||
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
||||||
{ icon: "camera", title: "Новый замер", subtitle: "Кухня клиента", href: "#/measure" },
|
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
|
||||||
{ icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null },
|
{ icon: "camera", title: "Замер сейчас", subtitle: "Заполнить вручную", href: "#/measure" },
|
||||||
];
|
];
|
||||||
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
|
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
|
||||||
const grid = el(`<div class="quick-grid"></div>`);
|
const grid = el(`<div class="quick-grid"></div>`);
|
||||||
@ -344,7 +344,7 @@ function buildMenu(items) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------- Staff (замерщик / сборщик) ----------------- */
|
/* ----------------- Staff (замерщик / сборщик) ----------------- */
|
||||||
function renderStaff(me) {
|
async function renderStaff(me) {
|
||||||
app.innerHTML = "";
|
app.innerHTML = "";
|
||||||
|
|
||||||
if (me.error === "no_staff_role") {
|
if (me.error === "no_staff_role") {
|
||||||
@ -382,23 +382,53 @@ function renderStaff(me) {
|
|||||||
</div>
|
</div>
|
||||||
`));
|
`));
|
||||||
|
|
||||||
// Заглушка — реальный инбокс заявок будет в следующем коммите
|
// Реальный инбокс — загружаем из /api/measurement_inbox
|
||||||
const inbox = el(`
|
const inboxSection = el(`
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<div class="block-head">📥 Входящие заявки</div>
|
<div class="block-head">📥 Входящие заявки на замер</div>
|
||||||
<div class="empty" style="padding:24px 12px;text-align:center;color:var(--muted);">
|
<div id="inboxList"><div class="loader-inline"><div class="spinner"></div></div></div>
|
||||||
Пока пусто — менеджеры ещё не назначили вам заявки.<br>
|
|
||||||
Здесь появятся ${caps.measurer ? "замеры" : ""}${caps.measurer && caps.assembler ? " и " : ""}${caps.assembler ? "сборки" : ""}.
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
`);
|
`);
|
||||||
app.appendChild(inbox);
|
app.appendChild(inboxSection);
|
||||||
|
|
||||||
// Если у сотрудника также есть роль measurer — показываем быструю кнопку «Сделать замер»
|
if (caps.measurer) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/measurement_inbox`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ initData: tg?.initData || "" }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
const list = document.getElementById("inboxList");
|
||||||
|
if (!list) return;
|
||||||
|
if (data.error) {
|
||||||
|
list.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
|
||||||
|
} else if (!data.measurements || !data.measurements.length) {
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="empty" style="padding:18px 12px;text-align:center;color:var(--muted);">
|
||||||
|
Заявок пока нет. Когда менеджер назначит замер — увидите здесь.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
list.innerHTML = "";
|
||||||
|
data.measurements.forEach(m => list.appendChild(renderInboxItem(m)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const list = document.getElementById("inboxList");
|
||||||
|
if (list) list.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById("inboxList").innerHTML = `
|
||||||
|
<div class="empty" style="padding:18px 12px;text-align:center;color:var(--muted);">
|
||||||
|
У вас только роль «сборщик» — инбокс заявок на сборку появится позже.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick action — заполнить замер без заявки (вне очереди)
|
||||||
if (caps.measurer) {
|
if (caps.measurer) {
|
||||||
const quick = el(`
|
const quick = el(`
|
||||||
<div class="podbor-cta-row" style="margin-top:16px;">
|
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||||
<button class="btn-primary" id="newMeasure">📐 Сделать новый замер</button>
|
<button class="btn-secondary" id="newMeasure">📐 Замер без заявки (вручную)</button>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
quick.querySelector("#newMeasure").addEventListener("click", () => {
|
quick.querySelector("#newMeasure").addEventListener("click", () => {
|
||||||
@ -409,6 +439,195 @@ function renderStaff(me) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInboxItem(m) {
|
||||||
|
const statusLabel = ({
|
||||||
|
requested: "🟡 ждёт даты",
|
||||||
|
scheduled: "📅 назначен",
|
||||||
|
in_progress: "🔵 в работе",
|
||||||
|
})[m.status] || m.status;
|
||||||
|
const sched = m.scheduled_at ? formatDateHuman(m.scheduled_at) : "дата не назначена";
|
||||||
|
|
||||||
|
const item = el(`
|
||||||
|
<button class="lead-item" style="text-align:left;">
|
||||||
|
<div style="flex:1; min-width:0;">
|
||||||
|
<div class="lead-date" style="font-weight:600; color:var(--ink);">${escHtml(m.client_name || "—")}</div>
|
||||||
|
<div class="lead-id" style="font-size:12px; color:var(--muted); margin-top:2px;">
|
||||||
|
${escHtml(m.address || "адрес не указан")}
|
||||||
|
</div>
|
||||||
|
<div class="lead-id" style="font-size:11px; color:var(--muted); margin-top:2px;">
|
||||||
|
${escHtml(sched)} · ${statusLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="lead-arrow">${ICONS.chevron || "›"}</div>
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
location.hash = `#/inbox/${m.id}`;
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateHuman(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const yy = d.getFullYear();
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
return `${dd}.${mm}.${yy} ${hh}:${mi}`;
|
||||||
|
} catch (e) { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------- Карточка заявки для замерщика ----------------- */
|
||||||
|
async function renderInboxDetail(measurementId) {
|
||||||
|
app.innerHTML = "";
|
||||||
|
document.body.classList.remove("has-bottom-nav");
|
||||||
|
const oldNav = document.getElementById("bottom-nav");
|
||||||
|
if (oldNav) oldNav.remove();
|
||||||
|
|
||||||
|
// header
|
||||||
|
const header = el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title">Заявка на замер</div>
|
||||||
|
<div style="width:28px"></div>
|
||||||
|
</header>
|
||||||
|
`);
|
||||||
|
header.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
location.hash = "";
|
||||||
|
if (!location.hash) location.reload();
|
||||||
|
});
|
||||||
|
app.appendChild(header);
|
||||||
|
|
||||||
|
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
|
||||||
|
app.appendChild(loading);
|
||||||
|
|
||||||
|
let m;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }),
|
||||||
|
});
|
||||||
|
m = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
loading.remove();
|
||||||
|
app.appendChild(el(`<div class="error">Сеть: ${e.message}</div>`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.remove();
|
||||||
|
if (m.error) {
|
||||||
|
app.appendChild(el(`<div class="error">${m.error}</div>`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шапка
|
||||||
|
app.appendChild(el(`
|
||||||
|
<div class="measurement-detail-head">
|
||||||
|
<div class="kicker">Заявка #${(m.id || "").slice(0, 8)}</div>
|
||||||
|
<h2 class="display-title">${escHtml(m.client_name || "Без имени")}</h2>
|
||||||
|
<div class="measurement-detail-meta">
|
||||||
|
<span>📞 ${escHtml(m.client_phone || "—")}</span>
|
||||||
|
<span>📍 ${escHtml(m.address || "адрес не указан")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`));
|
||||||
|
|
||||||
|
// Заметки от менеджера
|
||||||
|
if (m.notes) {
|
||||||
|
app.appendChild(el(`
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">Заметки от менеджера</div>
|
||||||
|
<div style="padding:12px 4px;color:var(--ink-2);font-size:14px;">${escHtml(m.notes).replace(/\n/g, "<br>")}</div>
|
||||||
|
</section>
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Блок «назначить дату» (если ещё requested) или «изменить дату» (если scheduled)
|
||||||
|
const isScheduled = m.status === "scheduled";
|
||||||
|
const schedSection = el(`
|
||||||
|
<section class="block">
|
||||||
|
<div class="block-head">${isScheduled ? "Дата замера" : "Назначить дату"}</div>
|
||||||
|
<div style="padding:6px 0 0;">
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Дата и время визита</span>
|
||||||
|
<input type="datetime-local" id="schedInput" value="${m.scheduled_at ? toDatetimeLocalValue(m.scheduled_at) : ""}">
|
||||||
|
<span class="field-hint" id="schedHint">${isScheduled ? "Согласовано — можно изменить" : "Согласуйте с клиентом, потом выберите тут"}</span>
|
||||||
|
<span class="field-error" id="schedError"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="podbor-cta-row">
|
||||||
|
<button class="btn-primary" id="saveSched">${isScheduled ? "Изменить дату" : "Назначить"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
app.appendChild(schedSection);
|
||||||
|
|
||||||
|
schedSection.querySelector("#saveSched").addEventListener("click", async () => {
|
||||||
|
const input = schedSection.querySelector("#schedInput");
|
||||||
|
const errorEl = schedSection.querySelector("#schedError");
|
||||||
|
errorEl.textContent = "";
|
||||||
|
const val = input.value;
|
||||||
|
if (!val) {
|
||||||
|
errorEl.textContent = "Укажите дату и время";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const iso = new Date(val).toISOString();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/measurement_schedule`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
measurement_id: measurementId,
|
||||||
|
scheduled_at: iso,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
errorEl.textContent = "Ошибка: " + data.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
haptic && haptic("success");
|
||||||
|
tg?.showAlert?.("Дата назначена — менеджер уведомлён.");
|
||||||
|
renderInboxDetail(measurementId); // перерисовать
|
||||||
|
} catch (e) {
|
||||||
|
errorEl.textContent = "Сеть: " + e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Кнопка «Сделать замер» (только если назначено или прямо сейчас)
|
||||||
|
const measureBtn = el(`
|
||||||
|
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||||
|
<button class="btn-primary" id="goMeasure">📐 Сделать замер сейчас</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
measureBtn.querySelector("#goMeasure").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
// Передаём measurement_id чтобы wizard работал в update-mode
|
||||||
|
location.hash = `#/measure?id=${measurementId}`;
|
||||||
|
});
|
||||||
|
app.appendChild(measureBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDatetimeLocalValue(iso) {
|
||||||
|
// ISO → YYYY-MM-DDTHH:MM для <input type="datetime-local">
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
} catch (e) { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
function renderError() {
|
function renderError() {
|
||||||
app.innerHTML = "";
|
app.innerHTML = "";
|
||||||
app.appendChild(el(`
|
app.appendChild(el(`
|
||||||
@ -439,12 +658,17 @@ async function init() {
|
|||||||
// Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую
|
// Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую
|
||||||
window.addEventListener("hashchange", routeByHash);
|
window.addEventListener("hashchange", routeByHash);
|
||||||
|
|
||||||
// ?go=podbor|clients|measure — бот может задать стартовый экран через query,
|
// ?go=podbor|clients|measure|request — бот может задать стартовый экран через query,
|
||||||
// потому что Telegram WebApp не передаёт hash через KeyboardButton.web_app.
|
// потому что Telegram WebApp не передаёт hash через KeyboardButton.web_app.
|
||||||
const qp = new URLSearchParams(window.location.search);
|
const qp = new URLSearchParams(window.location.search);
|
||||||
const goScreen = qp.get("go");
|
const goScreen = qp.get("go");
|
||||||
if (goScreen && !location.hash) {
|
if (goScreen && !location.hash) {
|
||||||
const map = { podbor: "#/podbor", clients: "#/clients", measure: "#/measure" };
|
const map = {
|
||||||
|
podbor: "#/podbor",
|
||||||
|
clients: "#/clients",
|
||||||
|
measure: "#/measure",
|
||||||
|
request: "#/request",
|
||||||
|
};
|
||||||
if (map[goScreen]) {
|
if (map[goScreen]) {
|
||||||
// Меняем hash без триггера hashchange (init сам отрендерит правильный экран)
|
// Меняем hash без триггера hashchange (init сам отрендерит правильный экран)
|
||||||
history.replaceState(null, "", location.pathname + location.search + map[goScreen]);
|
history.replaceState(null, "", location.pathname + location.search + map[goScreen]);
|
||||||
@ -469,6 +693,17 @@ async function init() {
|
|||||||
hideSplash();
|
hideSplash();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (location.hash.startsWith("#/request")) {
|
||||||
|
MeasurementRequest.mount(app);
|
||||||
|
hideSplash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (location.hash.startsWith("#/inbox/")) {
|
||||||
|
const id = location.hash.replace("#/inbox/", "");
|
||||||
|
renderInboxDetail(id);
|
||||||
|
hideSplash();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (me.role === "staff") {
|
if (me.role === "staff") {
|
||||||
renderStaff(me);
|
renderStaff(me);
|
||||||
} else if (me.role === "manager") {
|
} else if (me.role === "manager") {
|
||||||
@ -491,6 +726,10 @@ function routeByHash() {
|
|||||||
Clients.mount(app);
|
Clients.mount(app);
|
||||||
} else if (location.hash.startsWith("#/measure")) {
|
} else if (location.hash.startsWith("#/measure")) {
|
||||||
Measurements.mount(app);
|
Measurements.mount(app);
|
||||||
|
} else if (location.hash.startsWith("#/request")) {
|
||||||
|
MeasurementRequest.mount(app);
|
||||||
|
} else if (location.hash.startsWith("#/inbox/")) {
|
||||||
|
renderInboxDetail(location.hash.replace("#/inbox/", ""));
|
||||||
} else {
|
} else {
|
||||||
// Главный экран по роли
|
// Главный экран по роли
|
||||||
const me = window.__zovMe;
|
const me = window.__zovMe;
|
||||||
|
|||||||
@ -21,6 +21,7 @@ const Measurements = (function () {
|
|||||||
let state = loadState();
|
let state = loadState();
|
||||||
let root = null;
|
let root = null;
|
||||||
let currentStep = "client";
|
let currentStep = "client";
|
||||||
|
let measurementId = ""; // если задан — wizard работает в update-mode (закрывает заявку)
|
||||||
|
|
||||||
function loadState() {
|
function loadState() {
|
||||||
try {
|
try {
|
||||||
@ -71,9 +72,50 @@ const Measurements = (function () {
|
|||||||
if (oldNav) oldNav.remove();
|
if (oldNav) oldNav.remove();
|
||||||
currentStep = "client";
|
currentStep = "client";
|
||||||
photos = []; // на старте нового замера — чистый список
|
photos = []; // на старте нового замера — чистый список
|
||||||
|
|
||||||
|
// Если URL содержит ?id=<measurement_id> или fragment #/measure?id=... — это update-mode:
|
||||||
|
// wizard закрывает существующую заявку. Подтягиваем данные заявки и сразу прыгаем на layout.
|
||||||
|
measurementId = "";
|
||||||
|
const hashMatch = (location.hash.split("?")[1] || "");
|
||||||
|
const fragQp = new URLSearchParams(hashMatch);
|
||||||
|
const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || "";
|
||||||
|
if (mid) {
|
||||||
|
measurementId = mid;
|
||||||
|
loadRequestAndStart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadRequestAndStart() {
|
||||||
|
// Загружаем существующую заявку и пред-заполняем client_name/phone/address
|
||||||
|
root.innerHTML = "";
|
||||||
|
root.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
root.innerHTML = `<div class="error">${data.error}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pre-fill state с данными из заявки
|
||||||
|
state = {
|
||||||
|
...defaultState(),
|
||||||
|
client_name: data.client_name || "",
|
||||||
|
client_phone: data.client_phone || "",
|
||||||
|
};
|
||||||
|
saveState();
|
||||||
|
// Сразу прыгаем на форму (client уже заполнен)
|
||||||
|
currentStep = "layout";
|
||||||
|
render();
|
||||||
|
} catch (e) {
|
||||||
|
root.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function go(step) {
|
function go(step) {
|
||||||
if (!STEPS.includes(step)) return;
|
if (!STEPS.includes(step)) return;
|
||||||
currentStep = step;
|
currentStep = step;
|
||||||
@ -511,6 +553,8 @@ const Measurements = (function () {
|
|||||||
// Контакт клиента — заносим в заметки если он не зарегистрирован в системе
|
// Контакт клиента — заносим в заметки если он не зарегистрирован в системе
|
||||||
client_name: state.client_name,
|
client_name: state.client_name,
|
||||||
client_phone: state.client_phone,
|
client_phone: state.client_phone,
|
||||||
|
// Если задан — backend обновит существующую заявку (update-mode)
|
||||||
|
measurement_id: measurementId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
224
miniapp/assets/request.js
Normal file
224
miniapp/assets/request.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
/* ============================================================
|
||||||
|
Заявка на замер — менеджер создаёт, замерщику в инбокс
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
const MeasurementRequest = (function () {
|
||||||
|
let root = null;
|
||||||
|
let state = {
|
||||||
|
client_name: "",
|
||||||
|
client_phone: "",
|
||||||
|
address: "",
|
||||||
|
assigned_to_tg_id: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
let measurers = [];
|
||||||
|
|
||||||
|
function mount(container) {
|
||||||
|
root = container;
|
||||||
|
document.body.classList.remove("has-bottom-nav");
|
||||||
|
const oldNav = document.getElementById("bottom-nav");
|
||||||
|
if (oldNav) oldNav.remove();
|
||||||
|
state = { client_name: "", client_phone: "", address: "", assigned_to_tg_id: "", notes: "" };
|
||||||
|
render();
|
||||||
|
loadMeasurers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!root) return;
|
||||||
|
root.innerHTML = "";
|
||||||
|
root.appendChild(headerEl("Новая заявка на замер", "#/"));
|
||||||
|
|
||||||
|
const form = el(`
|
||||||
|
<section class="podbor-step">
|
||||||
|
<h2 class="display-title">Заявка<br><span class="accent">на замер</span></h2>
|
||||||
|
<p class="lede">Заполните данные клиента — замерщик получит уведомление в Telegram и согласует дату.</p>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">ФИО клиента *</span>
|
||||||
|
<input type="text" data-bind="client_name" placeholder="Иванов Иван Иванович" autocomplete="name">
|
||||||
|
<span class="field-error" id="errName"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Телефон *</span>
|
||||||
|
<input type="tel" data-bind="client_phone" placeholder="+7 921 555-12-34" autocomplete="tel">
|
||||||
|
<span class="field-hint">Минимум 10 цифр</span>
|
||||||
|
<span class="field-error" id="errPhone"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Адрес замера</span>
|
||||||
|
<input type="text" data-bind="address" placeholder="СПб, Просвещения 87, кв. 12">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Кому назначить</span>
|
||||||
|
<select data-bind="assigned_to_tg_id" id="measurerSelect">
|
||||||
|
<option value="">— Загрузка списка...</option>
|
||||||
|
</select>
|
||||||
|
<span class="field-hint" id="measurerHint">Замерщик получит DM с реквизитами заявки</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Заметки для замерщика</span>
|
||||||
|
<textarea data-bind="notes" rows="3" placeholder="газ/электро, особые условия доступа, ниши под технику, ..."></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podbor-cta-row">
|
||||||
|
<button class="btn-primary" id="submit">Создать заявку</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="submitResult" class="submit-result"></div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
root.appendChild(form);
|
||||||
|
|
||||||
|
bindInputs(form);
|
||||||
|
form.querySelector("#submit").addEventListener("click", () => onSubmit(form));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindInputs(node) {
|
||||||
|
node.querySelectorAll("[data-bind]").forEach(inp => {
|
||||||
|
inp.addEventListener("input", e => {
|
||||||
|
state[e.target.dataset.bind] = e.target.value;
|
||||||
|
});
|
||||||
|
inp.addEventListener("change", e => {
|
||||||
|
state[e.target.dataset.bind] = e.target.value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMeasurers() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/staff_list`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ initData: tg?.initData || "", role: "measurer" }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
measurers = data.staff || [];
|
||||||
|
const sel = document.getElementById("measurerSelect");
|
||||||
|
const hint = document.getElementById("measurerHint");
|
||||||
|
if (!sel) return;
|
||||||
|
if (!measurers.length) {
|
||||||
|
sel.innerHTML = `<option value="">— Замерщиков пока нет —</option>`;
|
||||||
|
sel.disabled = true;
|
||||||
|
if (hint) hint.textContent = "Сначала выдайте кому-нибудь роль measurer через /grant_role";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sel.disabled = false;
|
||||||
|
sel.innerHTML = `<option value="">— Не назначать (заберу сам)</option>` +
|
||||||
|
measurers.map(m => `<option value="${m.tg_id}">${escHtml(m.full_name || "?")} ${m.tg_username ? "(@" + m.tg_username + ")" : ""}</option>`).join("");
|
||||||
|
} catch (e) {
|
||||||
|
const sel = document.getElementById("measurerSelect");
|
||||||
|
if (sel) sel.innerHTML = `<option value="">— ошибка загрузки —</option>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(form) {
|
||||||
|
const btn = form.querySelector("#submit");
|
||||||
|
const result = form.querySelector("#submitResult");
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
form.querySelector("#errName").textContent = "";
|
||||||
|
form.querySelector("#errPhone").textContent = "";
|
||||||
|
const name = (state.client_name || "").trim();
|
||||||
|
const phone = (state.client_phone || "").trim();
|
||||||
|
if (!name) {
|
||||||
|
form.querySelector("#errName").textContent = "Укажите имя клиента";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (phone.replace(/\D/g, "").length < 10) {
|
||||||
|
form.querySelector("#errPhone").textContent = "Слишком короткий номер";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-inline"></span> создаём...';
|
||||||
|
result.innerHTML = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/measurement_request`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
client_name: name,
|
||||||
|
client_phone: phone,
|
||||||
|
address: state.address || "",
|
||||||
|
assigned_to_tg_id: state.assigned_to_tg_id || "",
|
||||||
|
notes: state.notes || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Попробовать снова";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
haptic && haptic("success");
|
||||||
|
const assignedTo = state.assigned_to_tg_id
|
||||||
|
? measurers.find(m => String(m.tg_id) === String(state.assigned_to_tg_id))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
result.innerHTML = `
|
||||||
|
<div class="success">
|
||||||
|
<div class="success-icon">${ICONS.check}</div>
|
||||||
|
<div>
|
||||||
|
<div class="success-title">Заявка создана</div>
|
||||||
|
<div class="success-sub">
|
||||||
|
ID #${(data.id || "").slice(0, 6)}${assignedTo ? " · Замерщик уведомлён в Telegram" : " · Без назначения"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||||
|
<button class="btn-secondary" id="newOne">Ещё заявка</button>
|
||||||
|
<button class="btn-primary" id="toHome">На главную</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
form.querySelector("#newOne")?.addEventListener("click", () => mount(root));
|
||||||
|
form.querySelector("#toHome")?.addEventListener("click", () => {
|
||||||
|
location.hash = "";
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Попробовать снова";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerEl(title, backHref) {
|
||||||
|
const h = el(`
|
||||||
|
<header class="podbor-header">
|
||||||
|
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || "‹"}</button>
|
||||||
|
<div class="podbor-title">${escHtml(title)}</div>
|
||||||
|
<div style="width:28px"></div>
|
||||||
|
</header>
|
||||||
|
`);
|
||||||
|
h.querySelector(".podbor-back").addEventListener("click", () => {
|
||||||
|
if (backHref) location.hash = backHref;
|
||||||
|
else location.hash = "";
|
||||||
|
if (!location.hash) location.reload();
|
||||||
|
});
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s == null ? "" : s)
|
||||||
|
.replace(/&/g, "&").replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mount };
|
||||||
|
})();
|
||||||
@ -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&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&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&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&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=20260513f">
|
<link rel="stylesheet" href="assets/styles.css?v=20260513g">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513f">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260513g">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
||||||
@ -34,12 +34,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260513f"></script>
|
<script src="assets/icons.js?v=20260513g"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260513f"></script>
|
<script src="assets/podbor.config.js?v=20260513g"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260513f"></script>
|
<script src="assets/podbor.picts.js?v=20260513g"></script>
|
||||||
<script src="assets/podbor.js?v=20260513f"></script>
|
<script src="assets/podbor.js?v=20260513g"></script>
|
||||||
<script src="assets/clients.js?v=20260513f"></script>
|
<script src="assets/clients.js?v=20260513g"></script>
|
||||||
<script src="assets/measurements.js?v=20260513f"></script>
|
<script src="assets/measurements.js?v=20260513g"></script>
|
||||||
<script src="assets/app.js?v=20260513f"></script>
|
<script src="assets/request.js?v=20260513g"></script>
|
||||||
|
<script src="assets/app.js?v=20260513g"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user