From e808880d8ea97074bcd0a1da819233f511bfe3e1 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Wed, 13 May 2026 23:49:20 +0300 Subject: [PATCH] =?UTF-8?q?A+B:=20=D0=B3=D0=BE=D0=BB=D0=BE=D1=81=20=D0=B2?= =?UTF-8?q?=20=D0=BC=D0=B0=D1=81=D1=82=D0=B5=D1=80=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D1=80=D0=B0=20+=20Google=20Calendar=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D1=8B=D1=82=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A — голосовой ввод заметок в мастере замера: - Кнопка 🎤 Диктовать рядом с textarea «Заметки» - Web Speech API ru-RU, interimResults показывает диктовку в реальном времени - Текст накапливается + сохраняется в state - Красная пульсация во время записи B — Google Calendar: - Новый модуль app/gcalendar.py — service account + Calendar API - Создание/обновление события при /api/measurement_schedule - 2 новые колонки в Measurements: gcal_event_id, gcal_event_url - При ошибке (нет API/прав) — fail gracefully, лог warning - Ссылка «📅 Открыть в Google Calendar» в карточке заявки - В DM менеджеру при назначении — clickable ссылка на событие - Требует env: GOOGLE_CALENDAR_ID + SA добавлен в редакторы календаря ДОПОЛНИТЕЛЬНО — заведение клиента менеджером: - Новый endpoint /api/client_create - /api/clients теперь читает И Leads И Measurements (включая draft) - UI: action card «Новый клиент» в quick-actions + кнопка «+ Новый клиент» в шапке списка клиентов - Форма (ФИО / Тел / Адрес / Примечание с 🎤 диктовкой) - После сохранения — переход в карточку клиента - has_role проверка вместо устаревшего user.role Cache bust v=20260513zn. --- backend-py/app/gcalendar.py | 176 +++++++++++++++++++++++ backend-py/app/main.py | 251 +++++++++++++++++++++++++-------- backend-py/requirements.txt | 1 + miniapp/assets/app.js | 3 +- miniapp/assets/clients.js | 202 +++++++++++++++++++++++++- miniapp/assets/measurements.js | 87 +++++++++++- miniapp/index.html | 24 ++-- 7 files changed, 671 insertions(+), 73 deletions(-) create mode 100644 backend-py/app/gcalendar.py diff --git a/backend-py/app/gcalendar.py b/backend-py/app/gcalendar.py new file mode 100644 index 0000000..0241676 --- /dev/null +++ b/backend-py/app/gcalendar.py @@ -0,0 +1,176 @@ +"""Google Calendar — создание событий замера через service account. + +Требования для работы: +1. В Google Cloud проекте включён Calendar API +2. Service account email добавлен в редакторы целевого календаря + (Google Calendar → Settings of calendar → Share with specific people) +3. GOOGLE_CALENDAR_ID в env (можно «primary» если SA имеет свой календарь, + или ID другого календаря в формате 'abc123@group.calendar.google.com') + +При ошибке (API не включён / нет прав / нет ID) функции возвращают None +и логируют warning — backend продолжает работать без календаря. +""" +from __future__ import annotations +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any + +log = logging.getLogger("zov.gcalendar") + +_SCOPES = ["https://www.googleapis.com/auth/calendar"] +_DEFAULT_TIMEZONE = "Europe/Moscow" +_DEFAULT_DURATION_MIN = 60 + +_service = None + + +def _get_service(): + """Lazy-init Google Calendar service. Возвращает None при ошибке.""" + global _service + if _service is not None: + return _service + try: + from google.oauth2.service_account import Credentials + from googleapiclient.discovery import build + except ImportError as e: + log.warning("google-api-python-client не установлен: %s", e) + return None + + creds_path = os.environ.get("GOOGLE_CREDENTIALS_PATH", "/app/credentials.json") + if not os.path.exists(creds_path): + log.warning("credentials.json не найден: %s", creds_path) + return None + try: + creds = Credentials.from_service_account_file(creds_path, scopes=_SCOPES) + _service = build("calendar", "v3", credentials=creds, cache_discovery=False) + log.info("Google Calendar service инициализирован") + return _service + except Exception as e: + log.warning("Не удалось инициализировать Calendar service: %s", e) + return None + + +def create_event( + *, + summary: str, + description: str = "", + start_iso: str, + duration_min: int = _DEFAULT_DURATION_MIN, + location: str = "", + timezone_name: str = _DEFAULT_TIMEZONE, + calendar_id: str | None = None, +) -> dict[str, Any] | None: + """Создаёт событие в Google Calendar. + Возвращает {'id', 'html_link'} или None при ошибке. + + start_iso: ISO 8601 datetime (с TZ или без — будет интерпретирован как timezone_name) + """ + service = _get_service() + if service is None: + return None + + cal_id = calendar_id or os.environ.get("GOOGLE_CALENDAR_ID", "").strip() + if not cal_id: + log.warning("GOOGLE_CALENDAR_ID не задан — событие не создано") + return None + + # Парсим start + try: + start_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00")) + except Exception as e: + log.warning("Bad start_iso=%r: %s", start_iso, e) + return None + end_dt = start_dt + timedelta(minutes=duration_min) + + body = { + "summary": summary, + "description": description or "", + "location": location or "", + "start": {"dateTime": start_dt.isoformat(), "timeZone": timezone_name}, + "end": {"dateTime": end_dt.isoformat(), "timeZone": timezone_name}, + # Напоминания за 1 час + "reminders": { + "useDefault": False, + "overrides": [{"method": "popup", "minutes": 60}], + }, + } + try: + ev = service.events().insert(calendarId=cal_id, body=body).execute() + log.info("Создано событие GCal: %s", ev.get("htmlLink")) + return {"id": ev.get("id"), "html_link": ev.get("htmlLink")} + except Exception as e: + log.warning("Не удалось создать событие GCal: %s", e) + return None + + +def update_event( + *, + event_id: str, + summary: str | None = None, + description: str | None = None, + start_iso: str | None = None, + duration_min: int = _DEFAULT_DURATION_MIN, + location: str | None = None, + timezone_name: str = _DEFAULT_TIMEZONE, + calendar_id: str | None = None, +) -> dict[str, Any] | None: + """Обновляет существующее событие. Только переданные поля меняются.""" + service = _get_service() + if service is None: + return None + cal_id = calendar_id or os.environ.get("GOOGLE_CALENDAR_ID", "").strip() + if not cal_id or not event_id: + return None + + try: + ev = service.events().get(calendarId=cal_id, eventId=event_id).execute() + except Exception as e: + log.warning("Событие не найдено для обновления: %s", e) + # Создадим новое если задан start_iso + if start_iso: + return create_event( + summary=summary or "Замер", + description=description or "", + start_iso=start_iso, + duration_min=duration_min, + location=location or "", + timezone_name=timezone_name, + calendar_id=cal_id, + ) + return None + + if summary is not None: ev["summary"] = summary + if description is not None: ev["description"] = description + if location is not None: ev["location"] = location + if start_iso is not None: + try: + start_dt = datetime.fromisoformat(start_iso.replace("Z", "+00:00")) + end_dt = start_dt + timedelta(minutes=duration_min) + ev["start"] = {"dateTime": start_dt.isoformat(), "timeZone": timezone_name} + ev["end"] = {"dateTime": end_dt.isoformat(), "timeZone": timezone_name} + except Exception as e: + log.warning("Bad start_iso: %s", e) + + try: + updated = service.events().update(calendarId=cal_id, eventId=event_id, body=ev).execute() + log.info("Обновлено событие GCal: %s", updated.get("htmlLink")) + return {"id": updated.get("id"), "html_link": updated.get("htmlLink")} + except Exception as e: + log.warning("Не удалось обновить событие GCal: %s", e) + return None + + +def delete_event(event_id: str, calendar_id: str | None = None) -> bool: + service = _get_service() + if service is None: + return False + cal_id = calendar_id or os.environ.get("GOOGLE_CALENDAR_ID", "").strip() + if not cal_id or not event_id: + return False + try: + service.events().delete(calendarId=cal_id, eventId=event_id).execute() + return True + except Exception as e: + log.warning("Не удалось удалить событие: %s", e) + return False diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 9df6a31..eaebeeb 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -115,6 +115,7 @@ async def _dispatch_post(request: Request): "measurement_logistics": _handle_measurement_logistics, "geocode": _handle_geocode, "client_note": _handle_client_note, + "client_create": _handle_client_create, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -229,6 +230,12 @@ async def api_client_note(request: Request): return _handle_client_note(body) +@app.post("/api/client_create") +async def api_client_create(request: Request): + body = await _safe_json(request) + return _handle_client_create(body) + + @app.post("/api/grant_role") async def api_grant_role(request: Request): """Админ выдаёт роль другому пользователю. @@ -637,6 +644,8 @@ def _measurement_columns() -> list[str]: # parking_type: free | paid | street | none "entrance", "floor", "gps_lat", "gps_lng", "parking_type", "parking_note", "delivery_notes", + # Google Calendar — событие при scheduled + "gcal_event_id", "gcal_event_url", ] @@ -671,6 +680,7 @@ def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]: "preferred_type": "", "preferred_date": "", "preferred_time_of_day": "", "preferred_note": "", "entrance": "", "floor": "", "gps_lat": "", "gps_lng": "", "parking_type": "", "parking_note": "", "delivery_notes": "", + "gcal_event_id": "", "gcal_event_url": "", } base.update(fields) return [str(base.get(c, "")) for c in cols] @@ -958,76 +968,103 @@ def _enrich_ai_marketplaces(ai_result: dict[str, Any]) -> None: def _handle_clients(body: dict[str, Any]) -> dict[str, Any]: - """Возвращает список клиентов менеджера со сводкой по подборам.""" + """Возвращает список клиентов менеджера со сводкой по подборам. + Агрегирует клиентов из Leads И Measurements (включая draft-карточки).""" 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"} + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} tg_id = auth["user"]["id"] user = sheets.find_user(tg_id) - if not user or user.get("role") != "manager": + if not user or not sheets.has_role(user, "manager"): return {"error": "only_manager"} - try: - ws = sheets.sheet("Leads") - rows = ws.get_all_values() - except Exception as e: - log.warning("Failed to read Leads: %s", e) - return {"ok": True, "clients": []} - - if not rows or len(rows) < 2: - return {"ok": True, "clients": []} - - headers = rows[0] by_client: dict[str, dict[str, Any]] = {} - for r in rows[1:]: - row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) - if str(row.get("manager_tg_id", "")) != str(tg_id): - continue - client_name = (row.get("client_name") or "").strip() - client_tg_id = (row.get("client_tg_id") or "").strip() - # Ключ для группировки: tg_id если есть, иначе имя - key = client_tg_id or client_name.lower() - if not key: - continue + def _ensure_client(key: str, name: str, phone: str, ctg_id: str): if key not in by_client: by_client[key] = { - "client_name": client_name, - "client_tg_id": client_tg_id or None, - "client_phone": "", + "client_name": name or "Без имени", + "client_tg_id": ctg_id or None, + "client_phone": phone or "", "leads_count": 0, "last_lead_at": "", "last_lead_id": "", "leads": [], } - c = by_client[key] - c["leads_count"] += 1 - lead_id = row.get("id", "") - created_at = row.get("created_at", "") - status = row.get("status", "") - c["leads"].append({ - "id": lead_id, - "created_at": created_at, - "status": status, - }) - # Обновляем «последний» - if created_at > c["last_lead_at"]: - c["last_lead_at"] = created_at - c["last_lead_id"] = lead_id - # Достаём телефон из checklist JSON - checklist_str = row.get("checklist", "") - if checklist_str: - try: - cl = json.loads(checklist_str) - if cl.get("client_phone"): - c["client_phone"] = cl["client_phone"] - except (ValueError, TypeError): - pass + else: + # Заполним пустые поля если в этой записи есть данные + c = by_client[key] + if name and not c.get("client_name"): c["client_name"] = name + if phone and not c.get("client_phone"): c["client_phone"] = phone + return by_client[key] - # Сортируем по дате последнего подбора (новые сверху) - clients = sorted(by_client.values(), key=lambda x: x["last_lead_at"], reverse=True) - # Внутри каждого клиента — leads тоже по дате desc + # 1. Из Leads — собираем подборы + try: + ws = sheets.sheet("Leads") + rows = ws.get_all_values() + if rows and len(rows) >= 2: + headers = rows[0] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + client_name = (row.get("client_name") or "").strip() + client_tg_id = (row.get("client_tg_id") or "").strip() + phone = "" + checklist_str = row.get("checklist", "") + if checklist_str: + try: + cl = json.loads(checklist_str) + phone = cl.get("client_phone", "") or "" + except (ValueError, TypeError): + pass + key = client_tg_id or client_name.lower() + if not key: + continue + c = _ensure_client(key, client_name, phone, client_tg_id) + c["leads_count"] += 1 + lead_id = row.get("id", "") + created_at = row.get("created_at", "") + status = row.get("status", "") + c["leads"].append({"id": lead_id, "created_at": created_at, "status": status}) + if created_at > c["last_lead_at"]: + c["last_lead_at"] = created_at + c["last_lead_id"] = lead_id + if phone and not c.get("client_phone"): c["client_phone"] = phone + except Exception as e: + log.warning("Failed to read Leads: %s", e) + + # 2. Из Measurements — для draft-карточек и заявок без подборов + try: + ws = sheets.sheet("Measurements") + rows = ws.get_all_values() + if rows and len(rows) >= 2: + headers = rows[0] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + client_name = (row.get("client_name") or "").strip() + client_phone = (row.get("client_phone") or "").strip() + client_tg_id = (row.get("client_tg_id") or "").strip() + key = client_tg_id or client_name.lower() + if not key: + continue + c = _ensure_client(key, client_name, client_phone, client_tg_id) + # Если у клиента нет ни одного лида — last_at берём из measurement.ts + ts = row.get("ts") or row.get("created_at") or "" + if ts > c["last_lead_at"]: + c["last_lead_at"] = ts + except Exception as e: + log.warning("Failed to read Measurements for clients: %s", e) + + # Сортируем по дате последней активности (новые сверху) + clients = sorted(by_client.values(), key=lambda x: x.get("last_lead_at") or "", reverse=True) for c in clients: c["leads"].sort(key=lambda x: x.get("created_at", ""), reverse=True) @@ -1293,10 +1330,45 @@ def _handle_measurement_schedule(body: dict[str, Any]) -> dict[str, Any]: sheets.update_cell_by_key("Measurements", "id", measurement_id, "scheduled_at", scheduled_at) sheets.update_cell_by_key("Measurements", "id", measurement_id, "status", "scheduled") + # Google Calendar — создаём или обновляем событие + gcal_url = "" + try: + from . import gcalendar + existing_event_id = row.get("gcal_event_id") or "" + client_name = row.get("client_name") or "—" + address = row.get("address") or "" + client_phone = row.get("client_phone") or "" + descr_parts = [f"Клиент: {client_name}"] + if client_phone: descr_parts.append(f"Телефон: {client_phone}") + if row.get("preferred_note"): descr_parts.append(f"Примечание: {row.get('preferred_note')}") + descr_parts.append(f"Замерщик: {user.get('full_name') or tg_id}") + descr_parts.append(f"\nЗаявка: {measurement_id}") + summary = f"Замер: {client_name}" + description = "\n".join(descr_parts) + + if existing_event_id: + ev = gcalendar.update_event( + event_id=existing_event_id, + summary=summary, description=description, + start_iso=scheduled_at, location=address, + ) + else: + ev = gcalendar.create_event( + summary=summary, description=description, + start_iso=scheduled_at, location=address, + ) + if ev: + sheets.update_cell_by_key("Measurements", "id", measurement_id, "gcal_event_id", ev.get("id", "")) + sheets.update_cell_by_key("Measurements", "id", measurement_id, "gcal_event_url", ev.get("html_link", "")) + gcal_url = ev.get("html_link", "") + except Exception as e: + log.warning("GCal integration error: %s", e) + # Уведомляем менеджера 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: + cal_line = f"\n📅 В календаре" if gcal_url else "" tg.send_message( int(notify_to), f"📅 Замер назначен\n\n" @@ -1304,14 +1376,18 @@ def _handle_measurement_schedule(body: dict[str, Any]) -> dict[str, Any]: f"Дата: {_format_date_human(scheduled_at)}\n" f"Замерщик: {user.get('full_name') or tg_id}\n" f"Адрес: {row.get('address') or '—'}" + f"{cal_line}" ) except Exception: pass sheets.log_event("measurement_scheduled", tg_id, { - "id": measurement_id, "scheduled_at": scheduled_at, + "id": measurement_id, "scheduled_at": scheduled_at, "gcal": bool(gcal_url), }) - return {"ok": True, "id": measurement_id, "status": "scheduled", "scheduled_at": scheduled_at} + return { + "ok": True, "id": measurement_id, "status": "scheduled", + "scheduled_at": scheduled_at, "gcal_event_url": gcal_url, + } def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]: @@ -1377,6 +1453,68 @@ def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "id": measurement_id, "logistics": updates} +def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер заводит клиента без замера/подбора. + body: {initData, full_name, phone, address?, note?} + Создаёт пустую заявку-карточку (status='draft') чтобы клиент появился + в списке клиентов менеджера и был доступен в карточке.""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"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"} + + full_name = (body.get("full_name") or "").strip() + phone = (body.get("phone") or "").strip() + address = (body.get("address") or "").strip() + note = (body.get("note") or "").strip() + if not full_name and not phone: + return {"error": "missing_client_id"} + + _ensure_measurements_sheet() + measurement_id = _short_id() + # Создаём «карточку клиента» как заявку со статусом draft + sheets.append_row("Measurements", _row_for_measurement( + measurement_id, _now_iso(), + manager_tg_id=str(tg_id), + requested_by_tg_id=str(tg_id), + filled_by="client_card", + status="draft", # карточка клиента, без активного замера + address=address, + client_name=full_name, + client_phone=phone, + notes=note, + preferred_note=note, + )) + + # Сохраняем заметку в ClientNotes если она передана + if note: + try: + _ensure_client_notes_sheet() + key = _normalize_client_key(full_name, phone) + sheets.append_row("ClientNotes", [str(tg_id), key, note, _now_iso()]) + except Exception: + pass + + sheets.log_event("client_created", tg_id, { + "id": measurement_id, "client": full_name, "phone": phone, + }) + return { + "ok": True, + "id": measurement_id, + "client_name": full_name, + "client_phone": phone, + "client_key": _normalize_client_key(full_name, phone), + } + + def _normalize_client_key(name: str, phone: str) -> str: """Стабильный ключ клиента: телефон в цифрах либо имя в lower.""" digits = "".join(c for c in (phone or "") if c.isdigit()) @@ -1654,6 +1792,9 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]: "parking_type": row.get("parking_type", ""), "parking_note": row.get("parking_note", ""), "delivery_notes": row.get("delivery_notes", ""), + # Google Calendar + "gcal_event_id": row.get("gcal_event_id", ""), + "gcal_event_url": row.get("gcal_event_url", ""), } diff --git a/backend-py/requirements.txt b/backend-py/requirements.txt index 1e5408b..4926da3 100644 --- a/backend-py/requirements.txt +++ b/backend-py/requirements.txt @@ -4,6 +4,7 @@ pydantic>=2.9 httpx>=0.27.0 gspread>=6.0.0 google-auth>=2.30.0 +google-api-python-client>=2.140.0 python-dotenv>=1.0.0 beautifulsoup4>=4.12.0 lxml>=5.2.0 diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index 0e28f7c..e9d0021 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -142,9 +142,9 @@ async function renderManagerHome(me) { // Quick actions const quickActions = [ { icon: "user", title: "Клиенты", subtitle: "История + хронология", href: "#/clients" }, + { icon: "plus", title: "Новый клиент", subtitle: "Завести карточку", href: "#/clients/new" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, - { icon: "camera", title: "Замер сейчас", subtitle: "Заполнить вручную", href: "#/measure" }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); @@ -949,6 +949,7 @@ async function renderInboxDetail(measurementId) {
📅 Замер назначен
${escHtml(formatDateHuman(m.scheduled_at))}
+ ${m.gcal_event_url ? `
📅 Открыть в Google Calendar
` : ""}
diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index cfda357..ff5b30e 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -15,7 +15,9 @@ const Clients = (function () { if (oldNav) oldNav.remove(); const sub = location.hash.replace(/^#\/clients\/?/, ""); - if (sub.startsWith("lead/")) { + if (sub === "new" || sub.startsWith("new")) { + renderNewClient(); + } else if (sub.startsWith("lead/")) { const leadId = sub.slice(5); renderLead(leadId); } else if (sub.startsWith("measurement/")) { @@ -29,11 +31,205 @@ const Clients = (function () { } } + /* ===================== Заведение нового клиента ===================== */ + + function renderNewClient() { + root.innerHTML = ""; + root.appendChild(headerEl("Новый клиент", "#/clients")); + + let state = { full_name: "", phone: "", address: "", note: "" }; + + const form = el(` +
+

Заводим
клиента

+

Карточка клиента появится в списке. Замер и подбор техники можно заказать позже из его карточки.

+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+ +
+
+
+ `); + root.appendChild(form); + + // Голосовой ввод + setupVoiceMicForField( + form.querySelector("#newMic"), + form.querySelector("#nt"), + form.querySelector("#newMicStatus"), + ); + + form.querySelector("#saveBtn").addEventListener("click", async () => { + const btn = form.querySelector("#saveBtn"); + const result = form.querySelector("#result"); + form.querySelector("#errName").textContent = ""; + form.querySelector("#errPhone").textContent = ""; + const name = (form.querySelector("#fn").value || "").trim(); + const phone = (form.querySelector("#ph").value || "").trim(); + const address = (form.querySelector("#ad").value || "").trim(); + const note = (form.querySelector("#nt").value || "").trim(); + if (!name) { form.querySelector("#errName").textContent = "Укажите имя"; return; } + if (phone.replace(/\D/g, "").length < 10) { + form.querySelector("#errPhone").textContent = "Слишком короткий номер"; + return; + } + btn.disabled = true; + btn.textContent = "Сохраняем..."; + try { + const res = await fetch(`${BACKEND_URL}/api/client_create`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + full_name: name, phone, address, note, + }), + }); + const data = await res.json(); + if (data.error) { + result.innerHTML = `
Ошибка: ${data.error}
`; + btn.disabled = false; + btn.textContent = "Завести клиента"; + return; + } + haptic && haptic("success"); + result.innerHTML = ` +
+
${ICONS.check}
+
+
Клиент заведён
+
${escHtml(name)} · ${escHtml(phone)}
+
+
+
+ + +
+ `; + const ckey = data.client_key || (phone.replace(/\D/g, "") || name.toLowerCase()); + // Сбросим кэш — карточка клиента подтянет свежие данные + clientsCache = null; + form.querySelector("#another")?.addEventListener("click", () => renderNewClient()); + form.querySelector("#openCard")?.addEventListener("click", () => { + location.hash = `#/clients/client/${encodeURIComponent(ckey)}`; + }); + } catch (e) { + result.innerHTML = `
Сеть: ${e.message}
`; + btn.disabled = false; + btn.textContent = "Завести клиента"; + } + }); + } + + function setupVoiceMicForField(micBtn, textarea, statusEl) { + if (!micBtn || !textarea) return; + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SR) { + micBtn.disabled = true; + micBtn.title = "Браузер не поддерживает голос"; + micBtn.style.opacity = "0.5"; + if (statusEl) statusEl.textContent = "недоступно"; + return; + } + let rec = null, recording = false, baseText = ""; + micBtn.addEventListener("click", () => { + if (recording) { rec?.stop(); return; } + try { + rec = new SR(); + rec.lang = "ru-RU"; rec.continuous = true; rec.interimResults = true; + } catch (e) { + if (statusEl) statusEl.textContent = "Микрофон недоступен"; + return; + } + baseText = (textarea.value || "").trim(); + const sep = baseText ? "\n" : ""; + rec.onstart = () => { + recording = true; + micBtn.classList.add("rec"); + micBtn.textContent = "⏹ Стоп"; + if (statusEl) statusEl.textContent = "Слушаю..."; + haptic && haptic("impact"); + }; + rec.onresult = (ev) => { + let interim = "", final = ""; + for (let i = ev.resultIndex; i < ev.results.length; i++) { + const t = ev.results[i][0].transcript; + if (ev.results[i].isFinal) final += t; else interim += t; + } + if (final) { + baseText = (baseText + sep + final).trim(); + textarea.value = baseText; + } else if (interim) { + textarea.value = baseText + sep + interim; + } + }; + rec.onerror = (ev) => { + if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || ""); + recording = false; + micBtn.classList.remove("rec"); + micBtn.textContent = "🎤 Диктовать"; + }; + rec.onend = () => { + recording = false; + micBtn.classList.remove("rec"); + micBtn.textContent = "🎤 Диктовать"; + if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = ""; + haptic && haptic("impact"); + }; + try { rec.start(); } catch (e) { + if (statusEl) statusEl.textContent = "Не запустить: " + e.message; + } + }); + } + /* ===================== Список клиентов ===================== */ async function renderList() { root.innerHTML = ""; root.appendChild(headerEl("Клиенты", null)); + + // Большая кнопка «Новый клиент» + const addBtn = el(` +
+ +
+ `); + addBtn.querySelector("#addClientBtn").addEventListener("click", () => { + haptic && haptic("impact"); + location.hash = "#/clients/new"; + }); + root.appendChild(addBtn); + const loading = el(`
`); root.appendChild(loading); @@ -52,8 +248,8 @@ const Clients = (function () { root.appendChild(el(`

- У тебя пока нет подборов с клиентами.
- Сделай первый — в кабинете «Подбор техники». + Пока нет клиентов.
+ Заведите первого — кнопка выше.

`)); diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js index 01058c4..097d9d4 100644 --- a/miniapp/assets/measurements.js +++ b/miniapp/assets/measurements.js @@ -198,8 +198,12 @@ const Measurements = (function () {
@@ -220,6 +224,14 @@ const Measurements = (function () { }); node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); + // Голосовой ввод заметок + setupVoiceMic( + node.querySelector("#zamerMic"), + node.querySelector("#zamerNotes"), + node.querySelector("#zamerMicStatus"), + (text) => { state.notes = text; saveState(); }, + ); + // Подгружаем следующий № замера если поле пустое if (!state.zamer_no) { fetchNextZamerNo(node); @@ -231,6 +243,77 @@ const Measurements = (function () { return node; } + /* ===================== Голосовой ввод заметок ===================== */ + function setupVoiceMic(micBtn, textarea, statusEl, onChange) { + if (!micBtn || !textarea) return; + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SR) { + micBtn.disabled = true; + micBtn.title = "Браузер не поддерживает голосовой ввод"; + micBtn.style.opacity = "0.5"; + if (statusEl) statusEl.textContent = "недоступно в этом браузере"; + return; + } + let rec = null; + let recording = false; + let baseText = ""; + + micBtn.addEventListener("click", () => { + if (recording) { rec?.stop(); return; } + try { + rec = new SR(); + rec.lang = "ru-RU"; + rec.continuous = true; + rec.interimResults = true; + } catch (e) { + if (statusEl) statusEl.textContent = "Микрофон недоступен: " + e.message; + return; + } + baseText = (textarea.value || "").trim(); + const sep = baseText ? "\n" : ""; + + rec.onstart = () => { + recording = true; + micBtn.classList.add("rec"); + micBtn.textContent = "⏹ Стоп"; + if (statusEl) statusEl.textContent = "Слушаю..."; + haptic && haptic("impact"); + }; + rec.onresult = (ev) => { + let interim = "", final = ""; + for (let i = ev.resultIndex; i < ev.results.length; i++) { + const t = ev.results[i][0].transcript; + if (ev.results[i].isFinal) final += t; + else interim += t; + } + if (final) { + baseText = (baseText + sep + final).trim(); + textarea.value = baseText; + if (onChange) onChange(baseText); + } else if (interim) { + textarea.value = baseText + sep + interim; + } + }; + rec.onerror = (ev) => { + if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "неизвестно"); + recording = false; + micBtn.classList.remove("rec"); + micBtn.textContent = "🎤 Диктовать"; + }; + rec.onend = () => { + recording = false; + micBtn.classList.remove("rec"); + micBtn.textContent = "🎤 Диктовать"; + if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = ""; + if (onChange) onChange(textarea.value || ""); + haptic && haptic("impact"); + }; + try { rec.start(); } catch (e) { + if (statusEl) statusEl.textContent = "Не запустить: " + e.message; + } + }); + } + async function fetchNextZamerNo(node) { try { const res = await fetch(`${BACKEND_URL}/api/measurement_next_no`, { diff --git a/miniapp/index.html b/miniapp/index.html index d011ecf..0cfb0d0 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,14 +12,14 @@ - - + +
- +
- - - - - - - - - + + + + + + + + +