mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 17:44:48 +00:00
A+B: голос в мастере замера + Google Calendar события
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.
This commit is contained in:
parent
18c2325440
commit
e808880d8e
176
backend-py/app/gcalendar.py
Normal file
176
backend-py/app/gcalendar.py
Normal file
@ -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
|
||||||
@ -115,6 +115,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"measurement_logistics": _handle_measurement_logistics,
|
"measurement_logistics": _handle_measurement_logistics,
|
||||||
"geocode": _handle_geocode,
|
"geocode": _handle_geocode,
|
||||||
"client_note": _handle_client_note,
|
"client_note": _handle_client_note,
|
||||||
|
"client_create": _handle_client_create,
|
||||||
"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(),
|
||||||
@ -229,6 +230,12 @@ async def api_client_note(request: Request):
|
|||||||
return _handle_client_note(body)
|
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")
|
@app.post("/api/grant_role")
|
||||||
async def api_grant_role(request: Request):
|
async def api_grant_role(request: Request):
|
||||||
"""Админ выдаёт роль другому пользователю.
|
"""Админ выдаёт роль другому пользователю.
|
||||||
@ -637,6 +644,8 @@ def _measurement_columns() -> list[str]:
|
|||||||
# parking_type: free | paid | street | none
|
# parking_type: free | paid | street | none
|
||||||
"entrance", "floor", "gps_lat", "gps_lng",
|
"entrance", "floor", "gps_lat", "gps_lng",
|
||||||
"parking_type", "parking_note", "delivery_notes",
|
"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": "",
|
"preferred_type": "", "preferred_date": "", "preferred_time_of_day": "", "preferred_note": "",
|
||||||
"entrance": "", "floor": "", "gps_lat": "", "gps_lng": "",
|
"entrance": "", "floor": "", "gps_lat": "", "gps_lng": "",
|
||||||
"parking_type": "", "parking_note": "", "delivery_notes": "",
|
"parking_type": "", "parking_note": "", "delivery_notes": "",
|
||||||
|
"gcal_event_id": "", "gcal_event_url": "",
|
||||||
}
|
}
|
||||||
base.update(fields)
|
base.update(fields)
|
||||||
return [str(base.get(c, "")) for c in cols]
|
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]:
|
def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Возвращает список клиентов менеджера со сводкой по подборам."""
|
"""Возвращает список клиентов менеджера со сводкой по подборам.
|
||||||
|
Агрегирует клиентов из Leads И Measurements (включая draft-карточки)."""
|
||||||
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"):
|
||||||
|
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"}
|
return {"error": "invalid_init_data"}
|
||||||
tg_id = auth["user"]["id"]
|
tg_id = auth["user"]["id"]
|
||||||
user = sheets.find_user(tg_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"}
|
return {"error": "only_manager"}
|
||||||
|
|
||||||
|
by_client: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
def _ensure_client(key: str, name: str, phone: str, ctg_id: str):
|
||||||
|
if key not in by_client:
|
||||||
|
by_client[key] = {
|
||||||
|
"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": [],
|
||||||
|
}
|
||||||
|
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]
|
||||||
|
|
||||||
|
# 1. Из Leads — собираем подборы
|
||||||
try:
|
try:
|
||||||
ws = sheets.sheet("Leads")
|
ws = sheets.sheet("Leads")
|
||||||
rows = ws.get_all_values()
|
rows = ws.get_all_values()
|
||||||
except Exception as e:
|
if rows and len(rows) >= 2:
|
||||||
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]
|
headers = rows[0]
|
||||||
by_client: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
for r in rows[1:]:
|
for r in rows[1:]:
|
||||||
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
|
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
|
||||||
if str(row.get("manager_tg_id", "")) != str(tg_id):
|
if str(row.get("manager_tg_id", "")) != str(tg_id):
|
||||||
continue
|
continue
|
||||||
client_name = (row.get("client_name") or "").strip()
|
client_name = (row.get("client_name") or "").strip()
|
||||||
client_tg_id = (row.get("client_tg_id") or "").strip()
|
client_tg_id = (row.get("client_tg_id") or "").strip()
|
||||||
# Ключ для группировки: tg_id если есть, иначе имя
|
phone = ""
|
||||||
key = client_tg_id or client_name.lower()
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
if key not in by_client:
|
|
||||||
by_client[key] = {
|
|
||||||
"client_name": client_name,
|
|
||||||
"client_tg_id": client_tg_id or None,
|
|
||||||
"client_phone": "",
|
|
||||||
"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", "")
|
checklist_str = row.get("checklist", "")
|
||||||
if checklist_str:
|
if checklist_str:
|
||||||
try:
|
try:
|
||||||
cl = json.loads(checklist_str)
|
cl = json.loads(checklist_str)
|
||||||
if cl.get("client_phone"):
|
phone = cl.get("client_phone", "") or ""
|
||||||
c["client_phone"] = cl["client_phone"]
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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-карточек и заявок без подборов
|
||||||
clients = sorted(by_client.values(), key=lambda x: x["last_lead_at"], reverse=True)
|
try:
|
||||||
# Внутри каждого клиента — leads тоже по дате desc
|
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:
|
for c in clients:
|
||||||
c["leads"].sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
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, "scheduled_at", scheduled_at)
|
||||||
sheets.update_cell_by_key("Measurements", "id", measurement_id, "status", "scheduled")
|
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")
|
notify_to = row.get("requested_by_tg_id") or row.get("manager_tg_id")
|
||||||
if notify_to and str(notify_to) != str(tg_id):
|
if notify_to and str(notify_to) != str(tg_id):
|
||||||
try:
|
try:
|
||||||
|
cal_line = f"\n📅 <a href=\"{gcal_url}\">В календаре</a>" if gcal_url else ""
|
||||||
tg.send_message(
|
tg.send_message(
|
||||||
int(notify_to),
|
int(notify_to),
|
||||||
f"📅 <b>Замер назначен</b>\n\n"
|
f"📅 <b>Замер назначен</b>\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"Дата: {_format_date_human(scheduled_at)}\n"
|
||||||
f"Замерщик: {user.get('full_name') or tg_id}\n"
|
f"Замерщик: {user.get('full_name') or tg_id}\n"
|
||||||
f"Адрес: {row.get('address') or '—'}"
|
f"Адрес: {row.get('address') or '—'}"
|
||||||
|
f"{cal_line}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
sheets.log_event("measurement_scheduled", tg_id, {
|
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]:
|
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}
|
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:
|
def _normalize_client_key(name: str, phone: str) -> str:
|
||||||
"""Стабильный ключ клиента: телефон в цифрах либо имя в lower."""
|
"""Стабильный ключ клиента: телефон в цифрах либо имя в lower."""
|
||||||
digits = "".join(c for c in (phone or "") if c.isdigit())
|
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_type": row.get("parking_type", ""),
|
||||||
"parking_note": row.get("parking_note", ""),
|
"parking_note": row.get("parking_note", ""),
|
||||||
"delivery_notes": row.get("delivery_notes", ""),
|
"delivery_notes": row.get("delivery_notes", ""),
|
||||||
|
# Google Calendar
|
||||||
|
"gcal_event_id": row.get("gcal_event_id", ""),
|
||||||
|
"gcal_event_url": row.get("gcal_event_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ pydantic>=2.9
|
|||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
gspread>=6.0.0
|
gspread>=6.0.0
|
||||||
google-auth>=2.30.0
|
google-auth>=2.30.0
|
||||||
|
google-api-python-client>=2.140.0
|
||||||
python-dotenv>=1.0.0
|
python-dotenv>=1.0.0
|
||||||
beautifulsoup4>=4.12.0
|
beautifulsoup4>=4.12.0
|
||||||
lxml>=5.2.0
|
lxml>=5.2.0
|
||||||
|
|||||||
@ -142,9 +142,9 @@ async function renderManagerHome(me) {
|
|||||||
// Quick actions
|
// Quick actions
|
||||||
const quickActions = [
|
const quickActions = [
|
||||||
{ icon: "user", title: "Клиенты", subtitle: "История + хронология", href: "#/clients" },
|
{ icon: "user", title: "Клиенты", subtitle: "История + хронология", href: "#/clients" },
|
||||||
|
{ icon: "plus", title: "Новый клиент", subtitle: "Завести карточку", href: "#/clients/new" },
|
||||||
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
|
||||||
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
|
{ icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" },
|
||||||
{ 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>`);
|
||||||
@ -949,6 +949,7 @@ async function renderInboxDetail(measurementId) {
|
|||||||
<section class="block date-set-block">
|
<section class="block date-set-block">
|
||||||
<div class="block-head">📅 Замер назначен</div>
|
<div class="block-head">📅 Замер назначен</div>
|
||||||
<div class="date-set-value">${escHtml(formatDateHuman(m.scheduled_at))}</div>
|
<div class="date-set-value">${escHtml(formatDateHuman(m.scheduled_at))}</div>
|
||||||
|
${m.gcal_event_url ? `<div style="padding:4px 4px 8px;"><a href="${m.gcal_event_url}" target="_blank" rel="noopener" style="color:var(--accent-1, #003E7E);font-size:13px;">📅 Открыть в Google Calendar</a></div>` : ""}
|
||||||
<div class="podbor-cta-row">
|
<div class="podbor-cta-row">
|
||||||
<button class="btn-secondary" id="changeDate" type="button">Изменить дату</button>
|
<button class="btn-secondary" id="changeDate" type="button">Изменить дату</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,7 +15,9 @@ const Clients = (function () {
|
|||||||
if (oldNav) oldNav.remove();
|
if (oldNav) oldNav.remove();
|
||||||
|
|
||||||
const sub = location.hash.replace(/^#\/clients\/?/, "");
|
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);
|
const leadId = sub.slice(5);
|
||||||
renderLead(leadId);
|
renderLead(leadId);
|
||||||
} else if (sub.startsWith("measurement/")) {
|
} 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(`
|
||||||
|
<section class="podbor-step">
|
||||||
|
<h2 class="display-title">Заводим<br><span class="accent">клиента</span></h2>
|
||||||
|
<p class="lede">Карточка клиента появится в списке. Замер и подбор техники можно заказать позже из его карточки.</p>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">ФИО клиента *</span>
|
||||||
|
<input type="text" id="fn" 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" id="ph" placeholder="+7 921 555-12-34" autocomplete="tel">
|
||||||
|
<span class="field-error" id="errPhone"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Адрес</span>
|
||||||
|
<input type="text" id="ad" placeholder="СПб, Просвещения 87, кв. 12">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Примечание (можно голосом)</span>
|
||||||
|
<textarea id="nt" rows="3" placeholder="как познакомились, особенности, контекст"></textarea>
|
||||||
|
<div class="note-actions" style="margin-top:6px;">
|
||||||
|
<button class="btn-mic" id="newMic" type="button">🎤 Диктовать</button>
|
||||||
|
<span class="note-status" id="newMicStatus"></span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="podbor-cta-row" style="margin-top:18px;">
|
||||||
|
<button class="btn-primary" id="saveBtn" type="button">Завести клиента</button>
|
||||||
|
</div>
|
||||||
|
<div id="result" class="submit-result"></div>
|
||||||
|
</section>
|
||||||
|
`);
|
||||||
|
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 = `<div class="error">Ошибка: ${data.error}</div>`;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Завести клиента";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
haptic && haptic("success");
|
||||||
|
result.innerHTML = `
|
||||||
|
<div class="success">
|
||||||
|
<div class="success-icon">${ICONS.check}</div>
|
||||||
|
<div>
|
||||||
|
<div class="success-title">Клиент заведён</div>
|
||||||
|
<div class="success-sub">${escHtml(name)} · ${escHtml(phone)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="podbor-cta-row" style="margin-top:14px;">
|
||||||
|
<button class="btn-secondary" id="another" type="button">Ещё клиент</button>
|
||||||
|
<button class="btn-primary" id="openCard" type="button">Открыть карточку</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `<div class="error">Сеть: ${e.message}</div>`;
|
||||||
|
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() {
|
async function renderList() {
|
||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
root.appendChild(headerEl("Клиенты", null));
|
root.appendChild(headerEl("Клиенты", null));
|
||||||
|
|
||||||
|
// Большая кнопка «Новый клиент»
|
||||||
|
const addBtn = el(`
|
||||||
|
<div class="podbor-cta-row" style="margin:6px 0 14px;">
|
||||||
|
<button class="btn-primary" id="addClientBtn" type="button">+ Новый клиент</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
addBtn.querySelector("#addClientBtn").addEventListener("click", () => {
|
||||||
|
haptic && haptic("impact");
|
||||||
|
location.hash = "#/clients/new";
|
||||||
|
});
|
||||||
|
root.appendChild(addBtn);
|
||||||
|
|
||||||
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
|
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
|
||||||
root.appendChild(loading);
|
root.appendChild(loading);
|
||||||
|
|
||||||
@ -52,8 +248,8 @@ const Clients = (function () {
|
|||||||
root.appendChild(el(`
|
root.appendChild(el(`
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p class="lede" style="text-align:center;padding:40px 20px;color:var(--muted)">
|
<p class="lede" style="text-align:center;padding:40px 20px;color:var(--muted)">
|
||||||
У тебя пока нет подборов с клиентами.<br>
|
Пока нет клиентов.<br>
|
||||||
Сделай первый — в кабинете «Подбор техники».
|
Заведите первого — кнопка выше.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`));
|
`));
|
||||||
|
|||||||
@ -198,8 +198,12 @@ const Measurements = (function () {
|
|||||||
|
|
||||||
<div class="form-row" style="margin-top:18px;">
|
<div class="form-row" style="margin-top:18px;">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Заметки (опционально)</span>
|
<span class="field-label">Заметки (голосом или текстом)</span>
|
||||||
<textarea data-bind="notes" rows="3" placeholder="особенности доступа, газ/электро, что важно учесть">${escHtml(state.notes || "")}</textarea>
|
<textarea data-bind="notes" id="zamerNotes" rows="3" placeholder="особенности доступа, газ/электро, что важно учесть">${escHtml(state.notes || "")}</textarea>
|
||||||
|
<div class="note-actions" style="margin-top:6px;">
|
||||||
|
<button class="btn-mic" id="zamerMic" type="button">🎤 Диктовать</button>
|
||||||
|
<span class="note-status" id="zamerMicStatus"></span>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -220,6 +224,14 @@ const Measurements = (function () {
|
|||||||
});
|
});
|
||||||
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
|
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) {
|
if (!state.zamer_no) {
|
||||||
fetchNextZamerNo(node);
|
fetchNextZamerNo(node);
|
||||||
@ -231,6 +243,77 @@ const Measurements = (function () {
|
|||||||
return node;
|
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) {
|
async function fetchNextZamerNo(node) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}/api/measurement_next_no`, {
|
const res = await fetch(`${BACKEND_URL}/api/measurement_next_no`, {
|
||||||
|
|||||||
@ -12,14 +12,14 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<link rel="stylesheet" href="assets/styles.css?v=20260513zm">
|
<link rel="stylesheet" href="assets/styles.css?v=20260513zn">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513zm">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260513zn">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
|
||||||
<div class="loader splash" id="splash">
|
<div class="loader splash" id="splash">
|
||||||
<div class="brand-logo-wrap">
|
<div class="brand-logo-wrap">
|
||||||
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260513zm" alt="@wasrusgen1">
|
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260513zn" alt="@wasrusgen1">
|
||||||
<div class="splash-dust" aria-hidden="true">
|
<div class="splash-dust" aria-hidden="true">
|
||||||
<span class="dust d1"></span> <span class="dust d2"></span>
|
<span class="dust d1"></span> <span class="dust d2"></span>
|
||||||
<span class="dust d3"></span> <span class="dust d4"></span>
|
<span class="dust d3"></span> <span class="dust d4"></span>
|
||||||
@ -35,14 +35,14 @@
|
|||||||
<div class="brand-tagline-gold">CRM</div>
|
<div class="brand-tagline-gold">CRM</div>
|
||||||
</div>
|
</div>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<script src="assets/icons.js?v=20260513zm"></script>
|
<script src="assets/icons.js?v=20260513zn"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260513zm"></script>
|
<script src="assets/podbor.config.js?v=20260513zn"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260513zm"></script>
|
<script src="assets/podbor.picts.js?v=20260513zn"></script>
|
||||||
<script src="assets/podbor.js?v=20260513zm"></script>
|
<script src="assets/podbor.js?v=20260513zn"></script>
|
||||||
<script src="assets/clients.js?v=20260513zm"></script>
|
<script src="assets/clients.js?v=20260513zn"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260513zm"></script>
|
<script src="assets/zamer-picts.js?v=20260513zn"></script>
|
||||||
<script src="assets/measurements.js?v=20260513zm"></script>
|
<script src="assets/measurements.js?v=20260513zn"></script>
|
||||||
<script src="assets/request.js?v=20260513zm"></script>
|
<script src="assets/request.js?v=20260513zn"></script>
|
||||||
<script src="assets/app.js?v=20260513zm"></script>
|
<script src="assets/app.js?v=20260513zn"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user