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:
wasrusgen 2026-05-13 23:49:20 +03:00
parent 18c2325440
commit e808880d8e
7 changed files with 671 additions and 73 deletions

176
backend-py/app/gcalendar.py Normal file
View 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

View File

@ -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"):
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"}
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:
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": []}
if rows and len(rows) >= 2:
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
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
phone = ""
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"]
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)
# Сортируем по дате последнего подбора (новые сверху)
clients = sorted(by_client.values(), key=lambda x: x["last_lead_at"], reverse=True)
# Внутри каждого клиента — leads тоже по дате desc
# 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📅 <a href=\"{gcal_url}\">В календаре</a>" if gcal_url else ""
tg.send_message(
int(notify_to),
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"Замерщик: {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", ""),
}

View File

@ -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

View File

@ -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(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
const grid = el(`<div class="quick-grid"></div>`);
@ -949,6 +949,7 @@ async function renderInboxDetail(measurementId) {
<section class="block date-set-block">
<div class="block-head">📅 Замер назначен</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">
<button class="btn-secondary" id="changeDate" type="button">Изменить дату</button>
</div>

View File

@ -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(`
<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() {
root.innerHTML = "";
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>`);
root.appendChild(loading);
@ -52,8 +248,8 @@ const Clients = (function () {
root.appendChild(el(`
<div class="empty">
<p class="lede" style="text-align:center;padding:40px 20px;color:var(--muted)">
У тебя пока нет подборов с клиентами.<br>
Сделай первый в кабинете «Подбор техники».
Пока нет клиентов.<br>
Заведите первого кнопка выше.
</p>
</div>
`));

View File

@ -198,8 +198,12 @@ const Measurements = (function () {
<div class="form-row" style="margin-top:18px;">
<label class="field">
<span class="field-label">Заметки (опционально)</span>
<textarea data-bind="notes" rows="3" placeholder="особенности доступа, газ/электро, что важно учесть">${escHtml(state.notes || "")}</textarea>
<span class="field-label">Заметки (голосом или текстом)</span>
<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>
</div>
@ -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`, {

View File

@ -12,14 +12,14 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513zm">
<link rel="stylesheet" href="assets/podbor.css?v=20260513zm">
<link rel="stylesheet" href="assets/styles.css?v=20260513zn">
<link rel="stylesheet" href="assets/podbor.css?v=20260513zn">
</head>
<body>
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
<div class="loader splash" id="splash">
<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">
<span class="dust d1"></span> <span class="dust d2"></span>
<span class="dust d3"></span> <span class="dust d4"></span>
@ -35,14 +35,14 @@
<div class="brand-tagline-gold">CRM</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260513zm"></script>
<script src="assets/podbor.config.js?v=20260513zm"></script>
<script src="assets/podbor.picts.js?v=20260513zm"></script>
<script src="assets/podbor.js?v=20260513zm"></script>
<script src="assets/clients.js?v=20260513zm"></script>
<script src="assets/zamer-picts.js?v=20260513zm"></script>
<script src="assets/measurements.js?v=20260513zm"></script>
<script src="assets/request.js?v=20260513zm"></script>
<script src="assets/app.js?v=20260513zm"></script>
<script src="assets/icons.js?v=20260513zn"></script>
<script src="assets/podbor.config.js?v=20260513zn"></script>
<script src="assets/podbor.picts.js?v=20260513zn"></script>
<script src="assets/podbor.js?v=20260513zn"></script>
<script src="assets/clients.js?v=20260513zn"></script>
<script src="assets/zamer-picts.js?v=20260513zn"></script>
<script src="assets/measurements.js?v=20260513zn"></script>
<script src="assets/request.js?v=20260513zn"></script>
<script src="assets/app.js?v=20260513zn"></script>
</body>
</html>