mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 13:24:48 +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,
|
||||
"client_note": _handle_client_note,
|
||||
"client_create": _handle_client_create,
|
||||
"client_delete": _handle_client_delete,
|
||||
"ping": lambda b: {"pong": True, "time": _now_iso()},
|
||||
"seed_admin": lambda b: _handle_seed_admin(),
|
||||
"test_ai": lambda b: _handle_test_ai(),
|
||||
@ -236,6 +237,12 @@ async def api_client_create(request: Request):
|
||||
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")
|
||||
async def api_grant_role(request: Request):
|
||||
"""Админ выдаёт роль другому пользователю.
|
||||
@ -646,6 +653,10 @@ def _measurement_columns() -> list[str]:
|
||||
"parking_type", "parking_note", "delivery_notes",
|
||||
# Google Calendar — событие при scheduled
|
||||
"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": "",
|
||||
"parking_type": "", "parking_note": "", "delivery_notes": "",
|
||||
"gcal_event_id": "", "gcal_event_url": "",
|
||||
"client_no": "", "contract_no": "", "contract_date": "",
|
||||
"archived_at": "",
|
||||
}
|
||||
base.update(fields)
|
||||
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_tg_id": ctg_id or None,
|
||||
"client_phone": phone or "",
|
||||
"client_no": "",
|
||||
"contract_no": "",
|
||||
"leads_count": 0,
|
||||
"last_lead_at": "",
|
||||
"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))))
|
||||
if str(row.get("manager_tg_id", "")) != str(tg_id):
|
||||
continue
|
||||
if row.get("archived_at"):
|
||||
continue # soft-deleted клиент
|
||||
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()
|
||||
client_no = (row.get("client_no") or "").strip()
|
||||
contract_no = (row.get("contract_no") 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)
|
||||
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
|
||||
ts = row.get("ts") or row.get("created_at") or ""
|
||||
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}
|
||||
|
||||
|
||||
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]:
|
||||
"""Менеджер заводит клиента без замера/подбора.
|
||||
body: {initData, full_name, phone, address?, note?}
|
||||
body: {initData, full_name, phone, address?, note?, contract_no?, contract_date?}
|
||||
Создаёт пустую заявку-карточку (status='draft') чтобы клиент появился
|
||||
в списке клиентов менеджера и был доступен в карточке."""
|
||||
cfg = get_config()
|
||||
@ -1472,14 +1536,29 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"error": "only_manager"}
|
||||
|
||||
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()
|
||||
note = (body.get("note") or "").strip()
|
||||
if not full_name and not phone:
|
||||
return {"error": "missing_client_id"}
|
||||
contract_no = (body.get("contract_no") or "").strip()
|
||||
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()
|
||||
measurement_id = _short_id()
|
||||
client_no = _next_client_no(str(tg_id))
|
||||
|
||||
# Создаём «карточку клиента» как заявку со статусом draft
|
||||
sheets.append_row("Measurements", _row_for_measurement(
|
||||
measurement_id, _now_iso(),
|
||||
@ -1492,6 +1571,9 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
||||
client_phone=phone,
|
||||
notes=note,
|
||||
preferred_note=note,
|
||||
client_no=str(client_no),
|
||||
contract_no=contract_no,
|
||||
contract_date=contract_date,
|
||||
))
|
||||
|
||||
# Сохраняем заметку в ClientNotes если она передана
|
||||
@ -1504,17 +1586,69 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
|
||||
pass
|
||||
|
||||
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 {
|
||||
"ok": True,
|
||||
"id": measurement_id,
|
||||
"client_name": full_name,
|
||||
"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:
|
||||
"""Стабильный ключ клиента: телефон в цифрах либо имя в lower."""
|
||||
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.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>
|
||||
@ -54,7 +52,8 @@ const Clients = (function () {
|
||||
<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">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
@ -62,6 +61,18 @@ const Clients = (function () {
|
||||
<label class="field">
|
||||
<span class="field-label">Адрес</span>
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
@ -75,7 +86,7 @@ const Clients = (function () {
|
||||
</label>
|
||||
</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>
|
||||
</div>
|
||||
<div id="result" class="submit-result"></div>
|
||||
@ -83,6 +94,13 @@ const Clients = (function () {
|
||||
`);
|
||||
root.appendChild(form);
|
||||
|
||||
// Авто-нормализация телефона при потере фокуса
|
||||
const phoneInput = form.querySelector("#ph");
|
||||
phoneInput.addEventListener("blur", () => {
|
||||
const normalized = normalizePhone(phoneInput.value);
|
||||
if (normalized.ok) phoneInput.value = normalized.value;
|
||||
});
|
||||
|
||||
// Голосовой ввод
|
||||
setupVoiceMicForField(
|
||||
form.querySelector("#newMic"),
|
||||
@ -92,18 +110,35 @@ const Clients = (function () {
|
||||
|
||||
form.querySelector("#saveBtn").addEventListener("click", async () => {
|
||||
const btn = form.querySelector("#saveBtn");
|
||||
const cta = form.querySelector("#saveCta");
|
||||
const result = form.querySelector("#result");
|
||||
form.querySelector("#errName").textContent = "";
|
||||
form.querySelector("#errPhone").textContent = "";
|
||||
["errName", "errPhone", "errAddr"].forEach(id => {
|
||||
const e = form.querySelector("#" + id);
|
||||
if (e) e.textContent = "";
|
||||
});
|
||||
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 note = (form.querySelector("#nt").value || "").trim();
|
||||
if (!name) { form.querySelector("#errName").textContent = "Укажите имя"; return; }
|
||||
if (phone.replace(/\D/g, "").length < 10) {
|
||||
form.querySelector("#errPhone").textContent = "Слишком короткий номер";
|
||||
const contract_no = (form.querySelector("#cn").value || "").trim();
|
||||
const contract_date = (form.querySelector("#cd").value || "").trim();
|
||||
|
||||
// Валидация на клиенте
|
||||
if (!name || name.length < 2) {
|
||||
form.querySelector("#errName").textContent = "Имя обязательно (минимум 2 символа)";
|
||||
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.textContent = "Сохраняем...";
|
||||
try {
|
||||
@ -112,23 +147,28 @@ const Clients = (function () {
|
||||
body: JSON.stringify({
|
||||
initData: tg?.initData || "",
|
||||
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();
|
||||
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.textContent = "Завести клиента";
|
||||
return;
|
||||
}
|
||||
haptic && haptic("success");
|
||||
// Прячем CTA с «Сохраняем...» и показываем success + кнопки
|
||||
cta.style.display = "none";
|
||||
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 class="success-title">Клиент #${data.client_no || "—"} заведён</div>
|
||||
<div class="success-sub">${escHtml(name)} · ${escHtml(norm.value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</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", () => {
|
||||
const ckey = data.client_key || name.toLowerCase();
|
||||
clientsCache = null; // сброс кэша
|
||||
// ВАЖНО: обработчики ищем В RESULT, не в form (где их нет)
|
||||
result.querySelector("#another")?.addEventListener("click", () => renderNewClient());
|
||||
result.querySelector("#openCard")?.addEventListener("click", () => {
|
||||
location.hash = `#/clients/client/${encodeURIComponent(ckey)}`;
|
||||
});
|
||||
} catch (e) {
|
||||
result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
|
||||
result.innerHTML = `<div class="error">Сеть: ${escHtml(e.message)}</div>`;
|
||||
btn.disabled = false;
|
||||
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) {
|
||||
if (!micBtn || !textarea) return;
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@ -161,7 +215,10 @@ const Clients = (function () {
|
||||
if (statusEl) statusEl.textContent = "недоступно";
|
||||
return;
|
||||
}
|
||||
let rec = null, recording = false, baseText = "";
|
||||
let rec = null, recording = false;
|
||||
let baseText = ""; // текст до начала записи
|
||||
let confirmedFinal = ""; // финальные части накопленные в этой сессии записи
|
||||
|
||||
micBtn.addEventListener("click", () => {
|
||||
if (recording) { rec?.stop(); return; }
|
||||
try {
|
||||
@ -172,7 +229,8 @@ const Clients = (function () {
|
||||
return;
|
||||
}
|
||||
baseText = (textarea.value || "").trim();
|
||||
const sep = baseText ? "\n" : "";
|
||||
confirmedFinal = "";
|
||||
|
||||
rec.onstart = () => {
|
||||
recording = true;
|
||||
micBtn.classList.add("rec");
|
||||
@ -181,17 +239,18 @@ const Clients = (function () {
|
||||
haptic && haptic("impact");
|
||||
};
|
||||
rec.onresult = (ev) => {
|
||||
let interim = "", final = "";
|
||||
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
||||
// Пересчитываем ВСЕ финальные и interim с нуля каждый раз — гарантия от дублей
|
||||
let finalAll = "";
|
||||
let interim = "";
|
||||
for (let i = 0; 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;
|
||||
if (ev.results[i].isFinal) finalAll += t;
|
||||
else interim += t;
|
||||
}
|
||||
confirmedFinal = finalAll.trim();
|
||||
const finalPart = confirmedFinal ? (baseText ? " " : "") + confirmedFinal : "";
|
||||
const interimPart = interim.trim() ? ((baseText || confirmedFinal) ? " " : "") + interim.trim() : "";
|
||||
textarea.value = baseText + finalPart + interimPart;
|
||||
};
|
||||
rec.onerror = (ev) => {
|
||||
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "");
|
||||
@ -203,6 +262,11 @@ const Clients = (function () {
|
||||
recording = false;
|
||||
micBtn.classList.remove("rec");
|
||||
micBtn.textContent = "🎤 Диктовать";
|
||||
// Фиксируем итоговый текст: baseText + final
|
||||
if (confirmedFinal) {
|
||||
baseText = (baseText + (baseText ? " " : "") + confirmedFinal).trim();
|
||||
textarea.value = baseText;
|
||||
}
|
||||
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
|
||||
haptic && haptic("impact");
|
||||
};
|
||||
@ -326,12 +390,19 @@ const Clients = (function () {
|
||||
// Шапка
|
||||
const phoneNorm = (client.client_phone || "").replace(/[^\d+]/g, "");
|
||||
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(`
|
||||
<div class="client-detail-head">
|
||||
<div class="client-avatar lg">${initial(client.client_name)}</div>
|
||||
<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>` : ""}
|
||||
${contractTag}
|
||||
</div>
|
||||
${callHref ? `<a class="client-call-btn" href="${callHref}" aria-label="Позвонить">📞</a>` : ""}
|
||||
</div>
|
||||
@ -392,6 +463,62 @@ const Clients = (function () {
|
||||
filesPlaceholder.replaceWith(renderClientFiles(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;
|
||||
}
|
||||
|
||||
/* ===================== Голосовой ввод заметок ===================== */
|
||||
/* ===================== Голосовой ввод заметок (без дублей) ===================== */
|
||||
function setupVoiceMic(micBtn, textarea, statusEl, onChange) {
|
||||
if (!micBtn || !textarea) return;
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
@ -257,6 +257,7 @@ const Measurements = (function () {
|
||||
let rec = null;
|
||||
let recording = false;
|
||||
let baseText = "";
|
||||
let confirmedFinal = "";
|
||||
|
||||
micBtn.addEventListener("click", () => {
|
||||
if (recording) { rec?.stop(); return; }
|
||||
@ -270,7 +271,7 @@ const Measurements = (function () {
|
||||
return;
|
||||
}
|
||||
baseText = (textarea.value || "").trim();
|
||||
const sep = baseText ? "\n" : "";
|
||||
confirmedFinal = "";
|
||||
|
||||
rec.onstart = () => {
|
||||
recording = true;
|
||||
@ -280,19 +281,17 @@ const Measurements = (function () {
|
||||
haptic && haptic("impact");
|
||||
};
|
||||
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;
|
||||
if (ev.results[i].isFinal) final += t;
|
||||
if (ev.results[i].isFinal) finalAll += 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;
|
||||
}
|
||||
confirmedFinal = finalAll.trim();
|
||||
const fp = confirmedFinal ? (baseText ? " " : "") + confirmedFinal : "";
|
||||
const ip = interim.trim() ? ((baseText || confirmedFinal) ? " " : "") + interim.trim() : "";
|
||||
textarea.value = baseText + fp + ip;
|
||||
};
|
||||
rec.onerror = (ev) => {
|
||||
if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "неизвестно");
|
||||
@ -304,6 +303,10 @@ const Measurements = (function () {
|
||||
recording = false;
|
||||
micBtn.classList.remove("rec");
|
||||
micBtn.textContent = "🎤 Диктовать";
|
||||
if (confirmedFinal) {
|
||||
baseText = (baseText + (baseText ? " " : "") + confirmedFinal).trim();
|
||||
textarea.value = baseText;
|
||||
}
|
||||
if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = "";
|
||||
if (onChange) onChange(textarea.value || "");
|
||||
haptic && haptic("impact");
|
||||
|
||||
@ -2066,6 +2066,57 @@
|
||||
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-call-btn {
|
||||
|
||||
@ -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=20260513zn">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260513zn">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260514a">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514a">
|
||||
</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=20260513zn" alt="@wasrusgen1">
|
||||
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514a" 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=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>
|
||||
<script src="assets/icons.js?v=20260514a"></script>
|
||||
<script src="assets/podbor.config.js?v=20260514a"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260514a"></script>
|
||||
<script src="assets/podbor.js?v=20260514a"></script>
|
||||
<script src="assets/clients.js?v=20260514a"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260514a"></script>
|
||||
<script src="assets/measurements.js?v=20260514a"></script>
|
||||
<script src="assets/request.js?v=20260514a"></script>
|
||||
<script src="assets/app.js?v=20260514a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user