mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 14:24:49 +00:00
fix client create — 7 багов + правило самопроверки
#7 — submit-flow: - При успехе скрываем CTA с «Сохраняем...» (style.display=none) - Обработчики «Ещё клиент» / «Открыть карточку» прикрепляются к result-блоку (был form.querySelector — там их нет) - Backend возвращает client_key = name.lower() — совместимо с тем как ищет _handle_clients - clientsCache = null после успеха #3 — голос дублировался: - Переписан алгоритм: финальные транскрипты пересчитываются с нуля из ev.results[0..N] каждое событие (не аккумулируем дельтами). - confirmedFinal фиксируется в baseText только на onend. - Применено в measurements.js + clients.js #2 — телефон: - Frontend normalizePhone: убирает не-цифры, +7/8 → +7, добавляет +7 к 10-значным; auto-нормализация на blur - Backend _normalize_phone(): тот же алгоритм - Валидация: ровно 11 цифр начиная с 7 - Field-error «Введите корректный российский номер...» #1 — адрес: - Min 5 chars (улица + дом) - Backend проверка длины - Hint «Укажите город, улицу, дом, кв.» #5 — номер клиента: - Новая колонка client_no - _next_client_no() — максимум для текущего менеджера + 1 - Шильд #N рядом с именем в карточке клиента #6 — номер договора: - Новые колонки contract_no, contract_date - Поля в форме «Новый клиент» (опционально) - Шильд «📋 договор N» в карточке клиента #4 — удаление клиента: - Soft-delete через колонку archived_at - Endpoint /api/client_delete - «⚠️ Опасная зона» в карточке клиента (collapsible) - Confirm dialog через Telegram.WebApp.showConfirm - Архивированные клиенты не показываются в /api/clients #8 — правило самопроверки: - docs/SELF_CHECK_RULE.md — 10 пунктов чек-листа перед «готово» (end-to-end, ключи, UI-состояния, валидация, голос, deploy, логи) Cache bust v=20260514a.
This commit is contained in:
parent
e808880d8e
commit
34b83899b5
@ -116,6 +116,7 @@ async def _dispatch_post(request: Request):
|
|||||||
"geocode": _handle_geocode,
|
"geocode": _handle_geocode,
|
||||||
"client_note": _handle_client_note,
|
"client_note": _handle_client_note,
|
||||||
"client_create": _handle_client_create,
|
"client_create": _handle_client_create,
|
||||||
|
"client_delete": _handle_client_delete,
|
||||||
"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(),
|
||||||
@ -236,6 +237,12 @@ async def api_client_create(request: Request):
|
|||||||
return _handle_client_create(body)
|
return _handle_client_create(body)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/client_delete")
|
||||||
|
async def api_client_delete(request: Request):
|
||||||
|
body = await _safe_json(request)
|
||||||
|
return _handle_client_delete(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):
|
||||||
"""Админ выдаёт роль другому пользователю.
|
"""Админ выдаёт роль другому пользователю.
|
||||||
@ -646,6 +653,10 @@ def _measurement_columns() -> list[str]:
|
|||||||
"parking_type", "parking_note", "delivery_notes",
|
"parking_type", "parking_note", "delivery_notes",
|
||||||
# Google Calendar — событие при scheduled
|
# Google Calendar — событие при scheduled
|
||||||
"gcal_event_id", "gcal_event_url",
|
"gcal_event_id", "gcal_event_url",
|
||||||
|
# Идентификаторы клиента и договора (нумерация)
|
||||||
|
"client_no", "contract_no", "contract_date",
|
||||||
|
# Soft-delete
|
||||||
|
"archived_at",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -681,6 +692,8 @@ def _row_for_measurement(measurement_id: str, ts: str, **fields) -> list[str]:
|
|||||||
"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": "",
|
"gcal_event_id": "", "gcal_event_url": "",
|
||||||
|
"client_no": "", "contract_no": "", "contract_date": "",
|
||||||
|
"archived_at": "",
|
||||||
}
|
}
|
||||||
base.update(fields)
|
base.update(fields)
|
||||||
return [str(base.get(c, "")) for c in cols]
|
return [str(base.get(c, "")) for c in cols]
|
||||||
@ -991,6 +1004,8 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
"client_name": name or "Без имени",
|
"client_name": name or "Без имени",
|
||||||
"client_tg_id": ctg_id or None,
|
"client_tg_id": ctg_id or None,
|
||||||
"client_phone": phone or "",
|
"client_phone": phone or "",
|
||||||
|
"client_no": "",
|
||||||
|
"contract_no": "",
|
||||||
"leads_count": 0,
|
"leads_count": 0,
|
||||||
"last_lead_at": "",
|
"last_lead_at": "",
|
||||||
"last_lead_id": "",
|
"last_lead_id": "",
|
||||||
@ -1049,13 +1064,19 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
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
|
||||||
|
if row.get("archived_at"):
|
||||||
|
continue # soft-deleted клиент
|
||||||
client_name = (row.get("client_name") or "").strip()
|
client_name = (row.get("client_name") or "").strip()
|
||||||
client_phone = (row.get("client_phone") or "").strip()
|
client_phone = (row.get("client_phone") or "").strip()
|
||||||
client_tg_id = (row.get("client_tg_id") or "").strip()
|
client_tg_id = (row.get("client_tg_id") or "").strip()
|
||||||
|
client_no = (row.get("client_no") or "").strip()
|
||||||
|
contract_no = (row.get("contract_no") or "").strip()
|
||||||
key = client_tg_id or client_name.lower()
|
key = client_tg_id or client_name.lower()
|
||||||
if not key:
|
if not key:
|
||||||
continue
|
continue
|
||||||
c = _ensure_client(key, client_name, client_phone, client_tg_id)
|
c = _ensure_client(key, client_name, client_phone, client_tg_id)
|
||||||
|
if client_no and not c.get("client_no"): c["client_no"] = client_no
|
||||||
|
if contract_no and not c.get("contract_no"): c["contract_no"] = contract_no
|
||||||
# Если у клиента нет ни одного лида — last_at берём из measurement.ts
|
# Если у клиента нет ни одного лида — last_at берём из measurement.ts
|
||||||
ts = row.get("ts") or row.get("created_at") or ""
|
ts = row.get("ts") or row.get("created_at") or ""
|
||||||
if ts > c["last_lead_at"]:
|
if ts > c["last_lead_at"]:
|
||||||
@ -1453,9 +1474,52 @@ 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 _normalize_phone(raw: str) -> tuple[str, bool]:
|
||||||
|
"""Нормализует RU-телефон в формат +7XXXXXXXXXX.
|
||||||
|
Возвращает (нормализованный, валиден ли)."""
|
||||||
|
if not raw:
|
||||||
|
return "", False
|
||||||
|
digits = "".join(c for c in raw if c.isdigit())
|
||||||
|
# Если начинается с 8 — заменяем на 7
|
||||||
|
if len(digits) == 11 and digits.startswith("8"):
|
||||||
|
digits = "7" + digits[1:]
|
||||||
|
# Если 10 цифр — добавляем 7 в начало
|
||||||
|
if len(digits) == 10:
|
||||||
|
digits = "7" + digits
|
||||||
|
if len(digits) != 11 or not digits.startswith("7"):
|
||||||
|
return raw, False
|
||||||
|
return "+" + digits, True
|
||||||
|
|
||||||
|
|
||||||
|
def _next_client_no(manager_tg_id: str) -> int:
|
||||||
|
"""Следующий порядковый номер клиента для менеджера (1, 2, 3, ...)."""
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("Measurements")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
except Exception:
|
||||||
|
return 1
|
||||||
|
if not rows or len(rows) < 2:
|
||||||
|
return 1
|
||||||
|
headers = rows[0]
|
||||||
|
if "client_no" not in headers or "manager_tg_id" not in headers:
|
||||||
|
return 1
|
||||||
|
no_idx = headers.index("client_no")
|
||||||
|
mgr_idx = headers.index("manager_tg_id")
|
||||||
|
max_n = 0
|
||||||
|
for r in rows[1:]:
|
||||||
|
if mgr_idx < len(r) and str(r[mgr_idx]).strip() == str(manager_tg_id):
|
||||||
|
try:
|
||||||
|
n = int(str(r[no_idx]).strip()) if no_idx < len(r) and r[no_idx] else 0
|
||||||
|
if n > max_n:
|
||||||
|
max_n = n
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return max_n + 1
|
||||||
|
|
||||||
|
|
||||||
def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Менеджер заводит клиента без замера/подбора.
|
"""Менеджер заводит клиента без замера/подбора.
|
||||||
body: {initData, full_name, phone, address?, note?}
|
body: {initData, full_name, phone, address?, note?, contract_no?, contract_date?}
|
||||||
Создаёт пустую заявку-карточку (status='draft') чтобы клиент появился
|
Создаёт пустую заявку-карточку (status='draft') чтобы клиент появился
|
||||||
в списке клиентов менеджера и был доступен в карточке."""
|
в списке клиентов менеджера и был доступен в карточке."""
|
||||||
cfg = get_config()
|
cfg = get_config()
|
||||||
@ -1472,14 +1536,29 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
return {"error": "only_manager"}
|
return {"error": "only_manager"}
|
||||||
|
|
||||||
full_name = (body.get("full_name") or "").strip()
|
full_name = (body.get("full_name") or "").strip()
|
||||||
phone = (body.get("phone") or "").strip()
|
phone_raw = (body.get("phone") or "").strip()
|
||||||
address = (body.get("address") or "").strip()
|
address = (body.get("address") or "").strip()
|
||||||
note = (body.get("note") or "").strip()
|
note = (body.get("note") or "").strip()
|
||||||
if not full_name and not phone:
|
contract_no = (body.get("contract_no") or "").strip()
|
||||||
return {"error": "missing_client_id"}
|
contract_date = (body.get("contract_date") or "").strip()
|
||||||
|
|
||||||
|
# Валидация
|
||||||
|
if not full_name:
|
||||||
|
return {"error": "missing_name", "field": "full_name", "msg": "Укажите ФИО клиента"}
|
||||||
|
if len(full_name) < 2:
|
||||||
|
return {"error": "bad_name", "field": "full_name", "msg": "Имя слишком короткое"}
|
||||||
|
|
||||||
|
phone, phone_ok = _normalize_phone(phone_raw)
|
||||||
|
if not phone_ok:
|
||||||
|
return {"error": "bad_phone", "field": "phone", "msg": "Телефон в формате +7XXXXXXXXXX (10 цифр после +7)"}
|
||||||
|
|
||||||
|
if address and len(address) < 5:
|
||||||
|
return {"error": "bad_address", "field": "address", "msg": "Адрес слишком короткий"}
|
||||||
|
|
||||||
_ensure_measurements_sheet()
|
_ensure_measurements_sheet()
|
||||||
measurement_id = _short_id()
|
measurement_id = _short_id()
|
||||||
|
client_no = _next_client_no(str(tg_id))
|
||||||
|
|
||||||
# Создаём «карточку клиента» как заявку со статусом draft
|
# Создаём «карточку клиента» как заявку со статусом draft
|
||||||
sheets.append_row("Measurements", _row_for_measurement(
|
sheets.append_row("Measurements", _row_for_measurement(
|
||||||
measurement_id, _now_iso(),
|
measurement_id, _now_iso(),
|
||||||
@ -1492,6 +1571,9 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
client_phone=phone,
|
client_phone=phone,
|
||||||
notes=note,
|
notes=note,
|
||||||
preferred_note=note,
|
preferred_note=note,
|
||||||
|
client_no=str(client_no),
|
||||||
|
contract_no=contract_no,
|
||||||
|
contract_date=contract_date,
|
||||||
))
|
))
|
||||||
|
|
||||||
# Сохраняем заметку в ClientNotes если она передана
|
# Сохраняем заметку в ClientNotes если она передана
|
||||||
@ -1504,17 +1586,69 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
sheets.log_event("client_created", tg_id, {
|
sheets.log_event("client_created", tg_id, {
|
||||||
"id": measurement_id, "client": full_name, "phone": phone,
|
"id": measurement_id, "client": full_name, "phone": phone, "client_no": client_no,
|
||||||
})
|
})
|
||||||
|
# client_key — формат совместимый с _handle_clients (которое использует name.lower())
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"id": measurement_id,
|
"id": measurement_id,
|
||||||
"client_name": full_name,
|
"client_name": full_name,
|
||||||
"client_phone": phone,
|
"client_phone": phone,
|
||||||
"client_key": _normalize_client_key(full_name, phone),
|
"client_no": client_no,
|
||||||
|
"contract_no": contract_no,
|
||||||
|
"client_key": full_name.lower(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Soft-delete всех записей Measurements по клиенту (для текущего менеджера).
|
||||||
|
body: {initData, client_key} — client_key это name.lower() как в _handle_clients."""
|
||||||
|
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"}
|
||||||
|
|
||||||
|
client_key = (body.get("client_key") or "").strip().lower()
|
||||||
|
if not client_key:
|
||||||
|
return {"error": "missing_client_key"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
ws = sheets.sheet("Measurements")
|
||||||
|
rows = ws.get_all_values()
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"sheets: {e}"}
|
||||||
|
if not rows or len(rows) < 2:
|
||||||
|
return {"ok": True, "archived": 0}
|
||||||
|
|
||||||
|
headers = rows[0]
|
||||||
|
if "archived_at" not in headers or "client_name" not in headers or "manager_tg_id" not in headers:
|
||||||
|
return {"error": "schema_missing"}
|
||||||
|
archived_idx = headers.index("archived_at") + 1
|
||||||
|
now = _now_iso()
|
||||||
|
count = 0
|
||||||
|
for i, r in enumerate(rows[1:], start=2):
|
||||||
|
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
|
||||||
|
if str(row.get("manager_tg_id", "")) != str(tg_id):
|
||||||
|
continue
|
||||||
|
if (row.get("client_name") or "").strip().lower() != client_key:
|
||||||
|
continue
|
||||||
|
if row.get("archived_at"):
|
||||||
|
continue # уже архивирован
|
||||||
|
ws.update_cell(i, archived_idx, now)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
sheets.log_event("client_deleted", tg_id, {"client_key": client_key, "count": count})
|
||||||
|
return {"ok": True, "archived": count}
|
||||||
|
|
||||||
|
|
||||||
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())
|
||||||
|
|||||||
76
docs/SELF_CHECK_RULE.md
Normal file
76
docs/SELF_CHECK_RULE.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Правило самопроверки перед выдачей на тест
|
||||||
|
|
||||||
|
**Действует на каждый коммит/PR в `master`.**
|
||||||
|
|
||||||
|
## Чек-лист перед «готово, тестируй»
|
||||||
|
|
||||||
|
### 1. Поток end-to-end на бумаге
|
||||||
|
|
||||||
|
Каждое новое действие пользователя пройти мысленно от UI до Sheets:
|
||||||
|
- ✅ Пользователь нажимает «Сохранить»
|
||||||
|
- ✅ Frontend отправляет POST → правильный endpoint + правильный body
|
||||||
|
- ✅ Backend валидирует → пишет в Sheets с правильным набором колонок
|
||||||
|
- ✅ Backend возвращает ответ → frontend меняет UI (НЕ зависает «Сохраняем...»)
|
||||||
|
- ✅ Следующее действие (например «Открыть карточку») находит ровно эту запись
|
||||||
|
|
||||||
|
### 2. Ключи и идентификаторы
|
||||||
|
|
||||||
|
При создании сущности проверить что **возвращаемый ID/key совпадает с тем по которому потом ищется**:
|
||||||
|
- Если backend возвращает `client_key`, а frontend ищет по `name.lower()` — БАГ
|
||||||
|
- Если создаём `Measurement` с `manager_tg_id=A`, а `/api/measurements` фильтрует по `requested_by_tg_id` — БАГ
|
||||||
|
|
||||||
|
### 3. Состояния UI
|
||||||
|
|
||||||
|
После любого POST:
|
||||||
|
- Кнопка «Сохраняем...» должна СБРОСИТЬСЯ (или замениться, или скрыться)
|
||||||
|
- Не должно быть «зависших» загрузчиков
|
||||||
|
- Сообщение об ошибке/успехе должно быть видно
|
||||||
|
- Обработчики новых кнопок должны привязываться к НУЖНОМУ контейнеру (`result.querySelector` ≠ `form.querySelector`)
|
||||||
|
|
||||||
|
### 4. Валидация ввода
|
||||||
|
|
||||||
|
Для каждого поля формы спросить:
|
||||||
|
- Минимальная длина / формат?
|
||||||
|
- Нормализация (телефон в +7XXXXXXXXXX, имя trim'ить)?
|
||||||
|
- Дубликаты (тот же телефон уже есть)?
|
||||||
|
- XSS-эскейпинг при отображении?
|
||||||
|
|
||||||
|
### 5. Удаление и редактирование
|
||||||
|
|
||||||
|
Для каждой создаваемой сущности заложить:
|
||||||
|
- Возможность удалить (с подтверждением)
|
||||||
|
- Возможность отредактировать
|
||||||
|
- Soft-delete (флаг `archived`) предпочтительнее hard-delete
|
||||||
|
|
||||||
|
### 6. Нумерация
|
||||||
|
|
||||||
|
Для бизнес-сущностей (клиент, замер, договор) — **последовательный человекочитаемый номер** в дополнение к UUID. Иначе пользователь не сможет ссылаться на «клиента №123».
|
||||||
|
|
||||||
|
### 7. Голосовой ввод
|
||||||
|
|
||||||
|
При работе с SpeechRecognition:
|
||||||
|
- Финальные транскрипты не должны дублироваться
|
||||||
|
- Промежуточные не должны накапливаться
|
||||||
|
- При повторных запусках записи — продолжение, не сброс
|
||||||
|
|
||||||
|
### 8. Деплой и кэш
|
||||||
|
|
||||||
|
- Bump `v=...` в `index.html` для каждого изменения статики
|
||||||
|
- Дождаться раскатки GH Pages (~30 сек) перед уведомлением о готовности
|
||||||
|
- Backend redeploy: `docker compose build && up -d` ИЛИ `docker cp` + `restart` если Docker Hub rate-limit
|
||||||
|
|
||||||
|
### 9. Логи и наблюдение
|
||||||
|
|
||||||
|
После деплоя — заглянуть в `docker logs zov-backend --tail 20`:
|
||||||
|
- Нет ли `[WARN]` про новые endpoints
|
||||||
|
- При первом тестовом запросе — увидеть его в логах
|
||||||
|
|
||||||
|
### 10. Документация
|
||||||
|
|
||||||
|
Если коммит вводит **новый workflow** — описать его краткое:
|
||||||
|
- В коммит-сообщении
|
||||||
|
- В README или CHANGELOG если есть
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Применять до каждого «готово, тестируй» сообщения пользователю.**
|
||||||
@ -37,8 +37,6 @@ const Clients = (function () {
|
|||||||
root.innerHTML = "";
|
root.innerHTML = "";
|
||||||
root.appendChild(headerEl("Новый клиент", "#/clients"));
|
root.appendChild(headerEl("Новый клиент", "#/clients"));
|
||||||
|
|
||||||
let state = { full_name: "", phone: "", address: "", note: "" };
|
|
||||||
|
|
||||||
const form = el(`
|
const form = el(`
|
||||||
<section class="podbor-step">
|
<section class="podbor-step">
|
||||||
<h2 class="display-title">Заводим<br><span class="accent">клиента</span></h2>
|
<h2 class="display-title">Заводим<br><span class="accent">клиента</span></h2>
|
||||||
@ -54,7 +52,8 @@ const Clients = (function () {
|
|||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Телефон *</span>
|
<span class="field-label">Телефон *</span>
|
||||||
<input type="tel" id="ph" placeholder="+7 921 555-12-34" autocomplete="tel">
|
<input type="tel" id="ph" placeholder="+7 921 555-12-34" autocomplete="tel" inputmode="tel">
|
||||||
|
<span class="field-hint" id="phoneHint">Формат +7XXXXXXXXXX или 8XXXXXXXXXX</span>
|
||||||
<span class="field-error" id="errPhone"></span>
|
<span class="field-error" id="errPhone"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -62,6 +61,18 @@ const Clients = (function () {
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field-label">Адрес</span>
|
<span class="field-label">Адрес</span>
|
||||||
<input type="text" id="ad" placeholder="СПб, Просвещения 87, кв. 12">
|
<input type="text" id="ad" placeholder="СПб, Просвещения 87, кв. 12">
|
||||||
|
<span class="field-hint" id="addrHint">Укажите город, улицу, дом, кв.</span>
|
||||||
|
<span class="field-error" id="errAddr"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-row two-col">
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">№ договора (опц.)</span>
|
||||||
|
<input type="text" id="cn" placeholder="например 1487В-М">
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Дата договора</span>
|
||||||
|
<input type="date" id="cd">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@ -75,7 +86,7 @@ const Clients = (function () {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="podbor-cta-row" style="margin-top:18px;">
|
<div class="podbor-cta-row" id="saveCta" style="margin-top:18px;">
|
||||||
<button class="btn-primary" id="saveBtn" type="button">Завести клиента</button>
|
<button class="btn-primary" id="saveBtn" type="button">Завести клиента</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="result" class="submit-result"></div>
|
<div id="result" class="submit-result"></div>
|
||||||
@ -83,6 +94,13 @@ const Clients = (function () {
|
|||||||
`);
|
`);
|
||||||
root.appendChild(form);
|
root.appendChild(form);
|
||||||
|
|
||||||
|
// Авто-нормализация телефона при потере фокуса
|
||||||
|
const phoneInput = form.querySelector("#ph");
|
||||||
|
phoneInput.addEventListener("blur", () => {
|
||||||
|
const normalized = normalizePhone(phoneInput.value);
|
||||||
|
if (normalized.ok) phoneInput.value = normalized.value;
|
||||||
|
});
|
||||||
|
|
||||||
// Голосовой ввод
|
// Голосовой ввод
|
||||||
setupVoiceMicForField(
|
setupVoiceMicForField(
|
||||||
form.querySelector("#newMic"),
|
form.querySelector("#newMic"),
|
||||||
@ -92,18 +110,35 @@ const Clients = (function () {
|
|||||||
|
|
||||||
form.querySelector("#saveBtn").addEventListener("click", async () => {
|
form.querySelector("#saveBtn").addEventListener("click", async () => {
|
||||||
const btn = form.querySelector("#saveBtn");
|
const btn = form.querySelector("#saveBtn");
|
||||||
|
const cta = form.querySelector("#saveCta");
|
||||||
const result = form.querySelector("#result");
|
const result = form.querySelector("#result");
|
||||||
form.querySelector("#errName").textContent = "";
|
["errName", "errPhone", "errAddr"].forEach(id => {
|
||||||
form.querySelector("#errPhone").textContent = "";
|
const e = form.querySelector("#" + id);
|
||||||
|
if (e) e.textContent = "";
|
||||||
|
});
|
||||||
const name = (form.querySelector("#fn").value || "").trim();
|
const name = (form.querySelector("#fn").value || "").trim();
|
||||||
const phone = (form.querySelector("#ph").value || "").trim();
|
const phoneRaw = (form.querySelector("#ph").value || "").trim();
|
||||||
const address = (form.querySelector("#ad").value || "").trim();
|
const address = (form.querySelector("#ad").value || "").trim();
|
||||||
const note = (form.querySelector("#nt").value || "").trim();
|
const note = (form.querySelector("#nt").value || "").trim();
|
||||||
if (!name) { form.querySelector("#errName").textContent = "Укажите имя"; return; }
|
const contract_no = (form.querySelector("#cn").value || "").trim();
|
||||||
if (phone.replace(/\D/g, "").length < 10) {
|
const contract_date = (form.querySelector("#cd").value || "").trim();
|
||||||
form.querySelector("#errPhone").textContent = "Слишком короткий номер";
|
|
||||||
|
// Валидация на клиенте
|
||||||
|
if (!name || name.length < 2) {
|
||||||
|
form.querySelector("#errName").textContent = "Имя обязательно (минимум 2 символа)";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const norm = normalizePhone(phoneRaw);
|
||||||
|
if (!norm.ok) {
|
||||||
|
form.querySelector("#errPhone").textContent =
|
||||||
|
"Введите корректный российский номер (+7XXXXXXXXXX или 8XXXXXXXXXX)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (address && address.length < 5) {
|
||||||
|
form.querySelector("#errAddr").textContent = "Адрес слишком короткий — нужны улица + дом";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = "Сохраняем...";
|
btn.textContent = "Сохраняем...";
|
||||||
try {
|
try {
|
||||||
@ -112,23 +147,28 @@ const Clients = (function () {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
initData: tg?.initData || "",
|
initData: tg?.initData || "",
|
||||||
initDataUnsafe: tg?.initDataUnsafe || null,
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
full_name: name, phone, address, note,
|
full_name: name, phone: norm.value, address, note,
|
||||||
|
contract_no, contract_date,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
|
const fieldErr = data.field ? form.querySelector("#err" + data.field[0].toUpperCase() + data.field.slice(1)) : null;
|
||||||
|
if (fieldErr) fieldErr.textContent = data.msg || data.error;
|
||||||
|
else result.innerHTML = `<div class="error">Ошибка: ${escHtml(data.msg || data.error)}</div>`;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Завести клиента";
|
btn.textContent = "Завести клиента";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
haptic && haptic("success");
|
haptic && haptic("success");
|
||||||
|
// Прячем CTA с «Сохраняем...» и показываем success + кнопки
|
||||||
|
cta.style.display = "none";
|
||||||
result.innerHTML = `
|
result.innerHTML = `
|
||||||
<div class="success">
|
<div class="success">
|
||||||
<div class="success-icon">${ICONS.check}</div>
|
<div class="success-icon">${ICONS.check}</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="success-title">Клиент заведён</div>
|
<div class="success-title">Клиент #${data.client_no || "—"} заведён</div>
|
||||||
<div class="success-sub">${escHtml(name)} · ${escHtml(phone)}</div>
|
<div class="success-sub">${escHtml(name)} · ${escHtml(norm.value)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="podbor-cta-row" style="margin-top:14px;">
|
<div class="podbor-cta-row" style="margin-top:14px;">
|
||||||
@ -136,21 +176,35 @@ const Clients = (function () {
|
|||||||
<button class="btn-primary" id="openCard" type="button">Открыть карточку</button>
|
<button class="btn-primary" id="openCard" type="button">Открыть карточку</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const ckey = data.client_key || (phone.replace(/\D/g, "") || name.toLowerCase());
|
const ckey = data.client_key || name.toLowerCase();
|
||||||
// Сбросим кэш — карточка клиента подтянет свежие данные
|
clientsCache = null; // сброс кэша
|
||||||
clientsCache = null;
|
// ВАЖНО: обработчики ищем В RESULT, не в form (где их нет)
|
||||||
form.querySelector("#another")?.addEventListener("click", () => renderNewClient());
|
result.querySelector("#another")?.addEventListener("click", () => renderNewClient());
|
||||||
form.querySelector("#openCard")?.addEventListener("click", () => {
|
result.querySelector("#openCard")?.addEventListener("click", () => {
|
||||||
location.hash = `#/clients/client/${encodeURIComponent(ckey)}`;
|
location.hash = `#/clients/client/${encodeURIComponent(ckey)}`;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = "Завести клиента";
|
btn.textContent = "Завести клиента";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePhone(raw) {
|
||||||
|
if (!raw) return { ok: false, value: "" };
|
||||||
|
const digits = String(raw).replace(/\D/g, "");
|
||||||
|
let normalized = digits;
|
||||||
|
if (normalized.length === 11 && normalized.startsWith("8")) {
|
||||||
|
normalized = "7" + normalized.slice(1);
|
||||||
|
}
|
||||||
|
if (normalized.length === 10) normalized = "7" + normalized;
|
||||||
|
if (normalized.length !== 11 || !normalized.startsWith("7")) {
|
||||||
|
return { ok: false, value: raw };
|
||||||
|
}
|
||||||
|
return { ok: true, value: "+" + normalized };
|
||||||
|
}
|
||||||
|
|
||||||
function setupVoiceMicForField(micBtn, textarea, statusEl) {
|
function setupVoiceMicForField(micBtn, textarea, statusEl) {
|
||||||
if (!micBtn || !textarea) return;
|
if (!micBtn || !textarea) return;
|
||||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
@ -161,7 +215,10 @@ const Clients = (function () {
|
|||||||
if (statusEl) statusEl.textContent = "недоступно";
|
if (statusEl) statusEl.textContent = "недоступно";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let rec = null, recording = false, baseText = "";
|
let rec = null, recording = false;
|
||||||
|
let baseText = ""; // текст до начала записи
|
||||||
|
let confirmedFinal = ""; // финальные части накопленные в этой сессии записи
|
||||||
|
|
||||||
micBtn.addEventListener("click", () => {
|
micBtn.addEventListener("click", () => {
|
||||||
if (recording) { rec?.stop(); return; }
|
if (recording) { rec?.stop(); return; }
|
||||||
try {
|
try {
|
||||||
@ -172,7 +229,8 @@ const Clients = (function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
baseText = (textarea.value || "").trim();
|
baseText = (textarea.value || "").trim();
|
||||||
const sep = baseText ? "\n" : "";
|
confirmedFinal = "";
|
||||||
|
|
||||||
rec.onstart = () => {
|
rec.onstart = () => {
|
||||||
recording = true;
|
recording = true;
|
||||||
micBtn.classList.add("rec");
|
micBtn.classList.add("rec");
|
||||||
@ -181,17 +239,18 @@ const Clients = (function () {
|
|||||||
haptic && haptic("impact");
|
haptic && haptic("impact");
|
||||||
};
|
};
|
||||||
rec.onresult = (ev) => {
|
rec.onresult = (ev) => {
|
||||||
let interim = "", final = "";
|
// Пересчитываем ВСЕ финальные и interim с нуля каждый раз — гарантия от дублей
|
||||||
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
let finalAll = "";
|
||||||
|
let interim = "";
|
||||||
|
for (let i = 0; i < ev.results.length; i++) {
|
||||||
const t = ev.results[i][0].transcript;
|
const t = ev.results[i][0].transcript;
|
||||||
if (ev.results[i].isFinal) final += t; else interim += t;
|
if (ev.results[i].isFinal) finalAll += t;
|
||||||
}
|
else interim += t;
|
||||||
if (final) {
|
|
||||||
baseText = (baseText + sep + final).trim();
|
|
||||||
textarea.value = baseText;
|
|
||||||
} else if (interim) {
|
|
||||||
textarea.value = baseText + sep + interim;
|
|
||||||
}
|
}
|
||||||
|
confirmedFinal = finalAll.trim();
|
||||||
|
const finalPart = confirmedFinal ? (baseText ? " " : "") + confirmedFinal : "";
|
||||||
|
const interimPart = interim.trim() ? ((baseText || confirmedFinal) ? " " : "") + interim.trim() : "";
|
||||||
|
textarea.value = baseText + finalPart + interimPart;
|
||||||
};
|
};
|
||||||
rec.onerror = (ev) => {
|
rec.onerror = (ev) => {
|
||||||
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "");
|
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "");
|
||||||
@ -203,6 +262,11 @@ const Clients = (function () {
|
|||||||
recording = false;
|
recording = false;
|
||||||
micBtn.classList.remove("rec");
|
micBtn.classList.remove("rec");
|
||||||
micBtn.textContent = "🎤 Диктовать";
|
micBtn.textContent = "🎤 Диктовать";
|
||||||
|
// Фиксируем итоговый текст: baseText + final
|
||||||
|
if (confirmedFinal) {
|
||||||
|
baseText = (baseText + (baseText ? " " : "") + confirmedFinal).trim();
|
||||||
|
textarea.value = baseText;
|
||||||
|
}
|
||||||
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
|
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
|
||||||
haptic && haptic("impact");
|
haptic && haptic("impact");
|
||||||
};
|
};
|
||||||
@ -326,12 +390,19 @@ const Clients = (function () {
|
|||||||
// Шапка
|
// Шапка
|
||||||
const phoneNorm = (client.client_phone || "").replace(/[^\d+]/g, "");
|
const phoneNorm = (client.client_phone || "").replace(/[^\d+]/g, "");
|
||||||
const callHref = phoneNorm ? `tel:${phoneNorm}` : "";
|
const callHref = phoneNorm ? `tel:${phoneNorm}` : "";
|
||||||
|
const noTag = client.client_no
|
||||||
|
? `<span class="client-no-badge">#${escHtml(client.client_no)}</span>`
|
||||||
|
: "";
|
||||||
|
const contractTag = client.contract_no
|
||||||
|
? `<div class="client-detail-meta">📋 договор ${escHtml(client.contract_no)}</div>`
|
||||||
|
: "";
|
||||||
root.appendChild(el(`
|
root.appendChild(el(`
|
||||||
<div class="client-detail-head">
|
<div class="client-detail-head">
|
||||||
<div class="client-avatar lg">${initial(client.client_name)}</div>
|
<div class="client-avatar lg">${initial(client.client_name)}</div>
|
||||||
<div style="flex:1;min-width:0;">
|
<div style="flex:1;min-width:0;">
|
||||||
<h2 class="client-detail-name">${escHtml(client.client_name)}</h2>
|
<h2 class="client-detail-name">${escHtml(client.client_name)} ${noTag}</h2>
|
||||||
${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""}
|
${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""}
|
||||||
|
${contractTag}
|
||||||
</div>
|
</div>
|
||||||
${callHref ? `<a class="client-call-btn" href="${callHref}" aria-label="Позвонить">📞</a>` : ""}
|
${callHref ? `<a class="client-call-btn" href="${callHref}" aria-label="Позвонить">📞</a>` : ""}
|
||||||
</div>
|
</div>
|
||||||
@ -392,6 +463,62 @@ const Clients = (function () {
|
|||||||
filesPlaceholder.replaceWith(renderClientFiles(client, myMeasurements));
|
filesPlaceholder.replaceWith(renderClientFiles(client, myMeasurements));
|
||||||
// Детальные списки внизу (свёрнуты)
|
// Детальные списки внизу (свёрнуты)
|
||||||
detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements));
|
detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements));
|
||||||
|
|
||||||
|
// Опасная зона — удалить клиента (soft-delete всех его записей)
|
||||||
|
const deleteZone = el(`
|
||||||
|
<details class="danger-zone" style="margin-top:24px;">
|
||||||
|
<summary>⚠️ Опасная зона</summary>
|
||||||
|
<div style="padding:12px 4px;">
|
||||||
|
<p style="font-size:13px;color:var(--muted);margin:0 0 12px;">
|
||||||
|
При удалении клиент будет архивирован вместе со всеми его заявками,
|
||||||
|
замерами и подборами. Из списка он исчезнет.
|
||||||
|
</p>
|
||||||
|
<button class="btn-danger" id="deleteClient" type="button">🗑 Удалить клиента</button>
|
||||||
|
<div id="deleteResult" style="margin-top:8px;font-size:12px;"></div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
`);
|
||||||
|
deleteZone.querySelector("#deleteClient").addEventListener("click", async () => {
|
||||||
|
const confirmed = await confirmDialog(`Удалить клиента ${client.client_name}? Это нельзя отменить из бота.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
const btn = deleteZone.querySelector("#deleteClient");
|
||||||
|
const result = deleteZone.querySelector("#deleteResult");
|
||||||
|
btn.disabled = true; btn.textContent = "Удаляем...";
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${BACKEND_URL}/api/client_delete`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: tg?.initData || "",
|
||||||
|
initDataUnsafe: tg?.initDataUnsafe || null,
|
||||||
|
client_key: (client.client_name || "").toLowerCase(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
result.innerHTML = `<span style="color:#C0392B;">Ошибка: ${escHtml(data.error)}</span>`;
|
||||||
|
btn.disabled = false; btn.textContent = "🗑 Удалить клиента";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
haptic && haptic("success");
|
||||||
|
clientsCache = null;
|
||||||
|
result.innerHTML = `<span style="color:#27AE60;">Архивировано ${data.archived} записей. Возвращаемся в список...</span>`;
|
||||||
|
setTimeout(() => { location.hash = "#/clients"; window.location.reload(); }, 1200);
|
||||||
|
} catch (e) {
|
||||||
|
result.innerHTML = `<span style="color:#C0392B;">Сеть: ${escHtml(e.message)}</span>`;
|
||||||
|
btn.disabled = false; btn.textContent = "🗑 Удалить клиента";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.appendChild(deleteZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDialog(msg) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (window.Telegram?.WebApp?.showConfirm) {
|
||||||
|
window.Telegram.WebApp.showConfirm(msg, (ok) => resolve(!!ok));
|
||||||
|
} else {
|
||||||
|
resolve(window.confirm(msg));
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Хронология ===================== */
|
/* ===================== Хронология ===================== */
|
||||||
|
|||||||
@ -243,7 +243,7 @@ const Measurements = (function () {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Голосовой ввод заметок ===================== */
|
/* ===================== Голосовой ввод заметок (без дублей) ===================== */
|
||||||
function setupVoiceMic(micBtn, textarea, statusEl, onChange) {
|
function setupVoiceMic(micBtn, textarea, statusEl, onChange) {
|
||||||
if (!micBtn || !textarea) return;
|
if (!micBtn || !textarea) return;
|
||||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
@ -257,6 +257,7 @@ const Measurements = (function () {
|
|||||||
let rec = null;
|
let rec = null;
|
||||||
let recording = false;
|
let recording = false;
|
||||||
let baseText = "";
|
let baseText = "";
|
||||||
|
let confirmedFinal = "";
|
||||||
|
|
||||||
micBtn.addEventListener("click", () => {
|
micBtn.addEventListener("click", () => {
|
||||||
if (recording) { rec?.stop(); return; }
|
if (recording) { rec?.stop(); return; }
|
||||||
@ -270,7 +271,7 @@ const Measurements = (function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
baseText = (textarea.value || "").trim();
|
baseText = (textarea.value || "").trim();
|
||||||
const sep = baseText ? "\n" : "";
|
confirmedFinal = "";
|
||||||
|
|
||||||
rec.onstart = () => {
|
rec.onstart = () => {
|
||||||
recording = true;
|
recording = true;
|
||||||
@ -280,19 +281,17 @@ const Measurements = (function () {
|
|||||||
haptic && haptic("impact");
|
haptic && haptic("impact");
|
||||||
};
|
};
|
||||||
rec.onresult = (ev) => {
|
rec.onresult = (ev) => {
|
||||||
let interim = "", final = "";
|
// Пересчёт с нуля каждый раз — гарантия от дублей
|
||||||
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
let finalAll = "", interim = "";
|
||||||
|
for (let i = 0; i < ev.results.length; i++) {
|
||||||
const t = ev.results[i][0].transcript;
|
const t = ev.results[i][0].transcript;
|
||||||
if (ev.results[i].isFinal) final += t;
|
if (ev.results[i].isFinal) finalAll += t;
|
||||||
else interim += t;
|
else interim += t;
|
||||||
}
|
}
|
||||||
if (final) {
|
confirmedFinal = finalAll.trim();
|
||||||
baseText = (baseText + sep + final).trim();
|
const fp = confirmedFinal ? (baseText ? " " : "") + confirmedFinal : "";
|
||||||
textarea.value = baseText;
|
const ip = interim.trim() ? ((baseText || confirmedFinal) ? " " : "") + interim.trim() : "";
|
||||||
if (onChange) onChange(baseText);
|
textarea.value = baseText + fp + ip;
|
||||||
} else if (interim) {
|
|
||||||
textarea.value = baseText + sep + interim;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
rec.onerror = (ev) => {
|
rec.onerror = (ev) => {
|
||||||
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "неизвестно");
|
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "неизвестно");
|
||||||
@ -304,6 +303,10 @@ const Measurements = (function () {
|
|||||||
recording = false;
|
recording = false;
|
||||||
micBtn.classList.remove("rec");
|
micBtn.classList.remove("rec");
|
||||||
micBtn.textContent = "🎤 Диктовать";
|
micBtn.textContent = "🎤 Диктовать";
|
||||||
|
if (confirmedFinal) {
|
||||||
|
baseText = (baseText + (baseText ? " " : "") + confirmedFinal).trim();
|
||||||
|
textarea.value = baseText;
|
||||||
|
}
|
||||||
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
|
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
|
||||||
if (onChange) onChange(textarea.value || "");
|
if (onChange) onChange(textarea.value || "");
|
||||||
haptic && haptic("impact");
|
haptic && haptic("impact");
|
||||||
|
|||||||
@ -2066,6 +2066,57 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Бейдж номера клиента + договор ===== */
|
||||||
|
.client-no-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--warm, rgba(107, 74, 43, 0.10));
|
||||||
|
color: var(--walnut, #6B4A2B);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.client-detail-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted, #998877);
|
||||||
|
font-family: var(--font-mono, "JetBrains Mono", monospace);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Опасная зона удаления ===== */
|
||||||
|
.danger-zone {
|
||||||
|
border: 1px dashed rgba(192, 57, 43, 0.35);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(192, 57, 43, 0.03);
|
||||||
|
}
|
||||||
|
.danger-zone > summary {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #C0392B;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 0;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.danger-zone[open] > summary { margin-bottom: 6px; }
|
||||||
|
.btn-danger {
|
||||||
|
background: #C0392B;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-danger:active { background: #A93226; }
|
||||||
|
.btn-danger:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
|
||||||
/* ===== Карточка клиента: шапка + действия ===== */
|
/* ===== Карточка клиента: шапка + действия ===== */
|
||||||
.client-detail-head { position: relative; display: flex; align-items: center; gap: 14px; }
|
.client-detail-head { position: relative; display: flex; align-items: center; gap: 14px; }
|
||||||
.client-call-btn {
|
.client-call-btn {
|
||||||
|
|||||||
@ -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=20260513zn">
|
<link rel="stylesheet" href="assets/styles.css?v=20260514a">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513zn">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260514a">
|
||||||
</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=20260513zn" alt="@wasrusgen1">
|
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514a" 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=20260513zn"></script>
|
<script src="assets/icons.js?v=20260514a"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260513zn"></script>
|
<script src="assets/podbor.config.js?v=20260514a"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260513zn"></script>
|
<script src="assets/podbor.picts.js?v=20260514a"></script>
|
||||||
<script src="assets/podbor.js?v=20260513zn"></script>
|
<script src="assets/podbor.js?v=20260514a"></script>
|
||||||
<script src="assets/clients.js?v=20260513zn"></script>
|
<script src="assets/clients.js?v=20260514a"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260513zn"></script>
|
<script src="assets/zamer-picts.js?v=20260514a"></script>
|
||||||
<script src="assets/measurements.js?v=20260513zn"></script>
|
<script src="assets/measurements.js?v=20260514a"></script>
|
||||||
<script src="assets/request.js?v=20260513zn"></script>
|
<script src="assets/request.js?v=20260514a"></script>
|
||||||
<script src="assets/app.js?v=20260513zn"></script>
|
<script src="assets/app.js?v=20260514a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user