mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 16:44:48 +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,
|
||||
"grant_role": _handle_grant_role,
|
||||
"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()},
|
||||
"seed_admin": lambda b: _handle_seed_admin(),
|
||||
"test_ai": lambda b: _handle_test_ai(),
|
||||
@ -179,6 +182,24 @@ async def api_measurement_detail(request: Request):
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
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]:
|
||||
"""Полная сдача замера (когда форма заполнена). Поддерживает 2 режима:
|
||||
1. Создать новый замер с данными (старый MVP-режим — сам менеджер сделал замер)
|
||||
2. Обновить существующий request — статус → completed (для замерщика после посещения)
|
||||
"""
|
||||
cfg = get_config()
|
||||
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||
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:
|
||||
return {"error": "user_not_found"}
|
||||
|
||||
_ensure_measurements_sheet()
|
||||
m = body.get("measurement") or {}
|
||||
measurement_id = _short_id()
|
||||
filled_by = "manager_for_client" if user.get("role") == "manager" else "client_self"
|
||||
existing_id = (m.get("measurement_id") or body.get("measurement_id") or "").strip()
|
||||
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
|
||||
manager_tg_id = tg_id if user.get("role") == "manager" else (
|
||||
sheets.find_row("Clients", "tg_id", tg_id) or {}
|
||||
).get("manager_tg_id", "")
|
||||
measurement_id = existing_id or _short_id()
|
||||
if is_manager and not is_measurer:
|
||||
filled_by = "manager_for_client"
|
||||
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", "")
|
||||
extras = []
|
||||
if m.get("client_name"):
|
||||
extras.append(f"Клиент: {m['client_name']}")
|
||||
if m.get("client_phone"):
|
||||
extras.append(f"Тел: {m['client_phone']}")
|
||||
if client_name:
|
||||
extras.append(f"Клиент: {client_name}")
|
||||
if client_phone:
|
||||
extras.append(f"Тел: {client_phone}")
|
||||
if address:
|
||||
extras.append(f"Адрес: {address}")
|
||||
if extras:
|
||||
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 — пропускаем как есть
|
||||
saved_photos.append(p)
|
||||
|
||||
sheets.append_row("Measurements", [
|
||||
measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "",
|
||||
filled_by,
|
||||
m.get("layout", ""), m.get("area_m2", ""), m.get("ceiling_mm", ""),
|
||||
json.dumps(m.get("walls") or {}, ensure_ascii=False),
|
||||
json.dumps(m.get("openings") or {}, ensure_ascii=False),
|
||||
json.dumps(m.get("infra") or {}, ensure_ascii=False),
|
||||
json.dumps(m.get("niches") or {}, ensure_ascii=False),
|
||||
",".join(saved_photos),
|
||||
notes_full,
|
||||
"submitted",
|
||||
])
|
||||
status_new = "completed"
|
||||
walls_json = json.dumps(m.get("walls") or {}, ensure_ascii=False)
|
||||
openings_json = json.dumps(m.get("openings") or {}, ensure_ascii=False)
|
||||
infra_json = json.dumps(m.get("infra") or {}, ensure_ascii=False)
|
||||
niches_json = json.dumps(m.get("niches") or {}, ensure_ascii=False)
|
||||
photos_str = ",".join(saved_photos)
|
||||
|
||||
if update_mode:
|
||||
# Обновляем существующую заявку — статус → completed, плюс заполняем поля
|
||||
updates = {
|
||||
"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:
|
||||
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(
|
||||
manager_tg_id,
|
||||
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"Открыть в кабинете для просмотра."
|
||||
)
|
||||
|
||||
sheets.log_event("measurement_submitted", tg_id, {"id": measurement_id, "filled_by": filled_by})
|
||||
return {"ok": True, "id": measurement_id, "photos": saved_photos}
|
||||
sheets.log_event("measurement_submitted", tg_id, {
|
||||
"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]:
|
||||
@ -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)}
|
||||
|
||||
|
||||
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]:
|
||||
"""Возвращает один замер целиком — для детальной страницы и печати."""
|
||||
cfg = get_config()
|
||||
@ -922,6 +1245,13 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"photos": photo_files,
|
||||
"notes": row.get("notes", ""),
|
||||
"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", ""),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -177,10 +177,10 @@ function renderManagerHome(me) {
|
||||
|
||||
// Quick actions
|
||||
const quickActions = [
|
||||
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
|
||||
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
||||
{ icon: "camera", title: "Новый замер", subtitle: "Кухня клиента", href: "#/measure" },
|
||||
{ icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null },
|
||||
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
|
||||
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
||||
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
|
||||
{ icon: "camera", title: "Замер сейчас", subtitle: "Заполнить вручную", href: "#/measure" },
|
||||
];
|
||||
app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
|
||||
const grid = el(`<div class="quick-grid"></div>`);
|
||||
@ -344,7 +344,7 @@ function buildMenu(items) {
|
||||
}
|
||||
|
||||
/* ----------------- Staff (замерщик / сборщик) ----------------- */
|
||||
function renderStaff(me) {
|
||||
async function renderStaff(me) {
|
||||
app.innerHTML = "";
|
||||
|
||||
if (me.error === "no_staff_role") {
|
||||
@ -382,23 +382,53 @@ function renderStaff(me) {
|
||||
</div>
|
||||
`));
|
||||
|
||||
// Заглушка — реальный инбокс заявок будет в следующем коммите
|
||||
const inbox = el(`
|
||||
// Реальный инбокс — загружаем из /api/measurement_inbox
|
||||
const inboxSection = el(`
|
||||
<section class="block">
|
||||
<div class="block-head">📥 Входящие заявки</div>
|
||||
<div class="empty" style="padding:24px 12px;text-align:center;color:var(--muted);">
|
||||
Пока пусто — менеджеры ещё не назначили вам заявки.<br>
|
||||
Здесь появятся ${caps.measurer ? "замеры" : ""}${caps.measurer && caps.assembler ? " и " : ""}${caps.assembler ? "сборки" : ""}.
|
||||
</div>
|
||||
<div class="block-head">📥 Входящие заявки на замер</div>
|
||||
<div id="inboxList"><div class="loader-inline"><div class="spinner"></div></div></div>
|
||||
</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) {
|
||||
const quick = el(`
|
||||
<div class="podbor-cta-row" style="margin-top:16px;">
|
||||
<button class="btn-primary" id="newMeasure">📐 Сделать новый замер</button>
|
||||
<button class="btn-secondary" id="newMeasure">📐 Замер без заявки (вручную)</button>
|
||||
</div>
|
||||
`);
|
||||
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() {
|
||||
app.innerHTML = "";
|
||||
app.appendChild(el(`
|
||||
@ -439,12 +658,17 @@ async function init() {
|
||||
// Hash-роутер: позволяет открывать подэкраны (например подбор) напрямую
|
||||
window.addEventListener("hashchange", routeByHash);
|
||||
|
||||
// ?go=podbor|clients|measure — бот может задать стартовый экран через query,
|
||||
// ?go=podbor|clients|measure|request — бот может задать стартовый экран через query,
|
||||
// потому что Telegram WebApp не передаёт hash через KeyboardButton.web_app.
|
||||
const qp = new URLSearchParams(window.location.search);
|
||||
const goScreen = qp.get("go");
|
||||
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]) {
|
||||
// Меняем hash без триггера hashchange (init сам отрендерит правильный экран)
|
||||
history.replaceState(null, "", location.pathname + location.search + map[goScreen]);
|
||||
@ -469,6 +693,17 @@ async function init() {
|
||||
hideSplash();
|
||||
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") {
|
||||
renderStaff(me);
|
||||
} else if (me.role === "manager") {
|
||||
@ -491,6 +726,10 @@ function routeByHash() {
|
||||
Clients.mount(app);
|
||||
} else if (location.hash.startsWith("#/measure")) {
|
||||
Measurements.mount(app);
|
||||
} else if (location.hash.startsWith("#/request")) {
|
||||
MeasurementRequest.mount(app);
|
||||
} else if (location.hash.startsWith("#/inbox/")) {
|
||||
renderInboxDetail(location.hash.replace("#/inbox/", ""));
|
||||
} else {
|
||||
// Главный экран по роли
|
||||
const me = window.__zovMe;
|
||||
|
||||
@ -21,6 +21,7 @@ const Measurements = (function () {
|
||||
let state = loadState();
|
||||
let root = null;
|
||||
let currentStep = "client";
|
||||
let measurementId = ""; // если задан — wizard работает в update-mode (закрывает заявку)
|
||||
|
||||
function loadState() {
|
||||
try {
|
||||
@ -71,9 +72,50 @@ const Measurements = (function () {
|
||||
if (oldNav) oldNav.remove();
|
||||
currentStep = "client";
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!STEPS.includes(step)) return;
|
||||
currentStep = step;
|
||||
@ -511,6 +553,8 @@ const Measurements = (function () {
|
||||
// Контакт клиента — заносим в заметки если он не зарегистрирован в системе
|
||||
client_name: state.client_name,
|
||||
client_phone: state.client_phone,
|
||||
// Если задан — backend обновит существующую заявку (update-mode)
|
||||
measurement_id: measurementId || undefined,
|
||||
};
|
||||
|
||||
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="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>
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260513f">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513f">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260513g">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513g">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Splash — за пределами #app, render-функции его не смывают -->
|
||||
@ -34,12 +34,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<main id="app"></main>
|
||||
<script src="assets/icons.js?v=20260513f"></script>
|
||||
<script src="assets/podbor.config.js?v=20260513f"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260513f"></script>
|
||||
<script src="assets/podbor.js?v=20260513f"></script>
|
||||
<script src="assets/clients.js?v=20260513f"></script>
|
||||
<script src="assets/measurements.js?v=20260513f"></script>
|
||||
<script src="assets/app.js?v=20260513f"></script>
|
||||
<script src="assets/icons.js?v=20260513g"></script>
|
||||
<script src="assets/podbor.config.js?v=20260513g"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260513g"></script>
|
||||
<script src="assets/podbor.js?v=20260513g"></script>
|
||||
<script src="assets/clients.js?v=20260513g"></script>
|
||||
<script src="assets/measurements.js?v=20260513g"></script>
|
||||
<script src="assets/request.js?v=20260513g"></script>
|
||||
<script src="assets/app.js?v=20260513g"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user