From 34b83899b575d4f8596dd78803a39b08b08e5879 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Thu, 14 May 2026 00:09:14 +0300 Subject: [PATCH] =?UTF-8?q?fix=20client=20create=20=E2=80=94=207=20=D0=B1?= =?UTF-8?q?=D0=B0=D0=B3=D0=BE=D0=B2=20+=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=20=D1=81=D0=B0=D0=BC=D0=BE=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- backend-py/app/main.py | 146 +++++++++++++++++++++++-- docs/SELF_CHECK_RULE.md | 76 +++++++++++++ miniapp/assets/clients.js | 191 +++++++++++++++++++++++++++------ miniapp/assets/measurements.js | 27 ++--- miniapp/assets/podbor.css | 51 +++++++++ miniapp/index.html | 24 ++--- 6 files changed, 453 insertions(+), 62 deletions(-) create mode 100644 docs/SELF_CHECK_RULE.md diff --git a/backend-py/app/main.py b/backend-py/app/main.py index eaebeeb..cda5a95 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -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()) diff --git a/docs/SELF_CHECK_RULE.md b/docs/SELF_CHECK_RULE.md new file mode 100644 index 0000000..2cc1b97 --- /dev/null +++ b/docs/SELF_CHECK_RULE.md @@ -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 если есть + +--- + +**Применять до каждого «готово, тестируй» сообщения пользователю.** diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index ff5b30e..9a156f4 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -37,8 +37,6 @@ const Clients = (function () { root.innerHTML = ""; root.appendChild(headerEl("Новый клиент", "#/clients")); - let state = { full_name: "", phone: "", address: "", note: "" }; - const form = el(`

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

@@ -54,7 +52,8 @@ const Clients = (function () {
@@ -62,6 +61,18 @@ const Clients = (function () { + +
+ +
@@ -75,7 +86,7 @@ const Clients = (function () {
-
+
@@ -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 = `
Ошибка: ${data.error}
`; + 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 = `
Ошибка: ${escHtml(data.msg || data.error)}
`; btn.disabled = false; btn.textContent = "Завести клиента"; return; } haptic && haptic("success"); + // Прячем CTA с «Сохраняем...» и показываем success + кнопки + cta.style.display = "none"; result.innerHTML = `
${ICONS.check}
-
Клиент заведён
-
${escHtml(name)} · ${escHtml(phone)}
+
Клиент #${data.client_no || "—"} заведён
+
${escHtml(name)} · ${escHtml(norm.value)}
@@ -136,21 +176,35 @@ const Clients = (function () {
`; - 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 = `
Сеть: ${e.message}
`; + result.innerHTML = `
Сеть: ${escHtml(e.message)}
`; 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 + ? `#${escHtml(client.client_no)}` + : ""; + const contractTag = client.contract_no + ? `
📋 договор ${escHtml(client.contract_no)}
` + : ""; root.appendChild(el(`
${initial(client.client_name)}
-

${escHtml(client.client_name)}

+

${escHtml(client.client_name)} ${noTag}

${client.client_phone ? `
${escHtml(client.client_phone)}
` : ""} + ${contractTag}
${callHref ? `📞` : ""}
@@ -392,6 +463,62 @@ const Clients = (function () { filesPlaceholder.replaceWith(renderClientFiles(client, myMeasurements)); // Детальные списки внизу (свёрнуты) detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements)); + + // Опасная зона — удалить клиента (soft-delete всех его записей) + const deleteZone = el(` +
+ ⚠️ Опасная зона +
+

+ При удалении клиент будет архивирован вместе со всеми его заявками, + замерами и подборами. Из списка он исчезнет. +

+ +
+
+
+ `); + 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 = `Ошибка: ${escHtml(data.error)}`; + btn.disabled = false; btn.textContent = "🗑 Удалить клиента"; + return; + } + haptic && haptic("success"); + clientsCache = null; + result.innerHTML = `Архивировано ${data.archived} записей. Возвращаемся в список...`; + setTimeout(() => { location.hash = "#/clients"; window.location.reload(); }, 1200); + } catch (e) { + result.innerHTML = `Сеть: ${escHtml(e.message)}`; + 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)); + } + }); } /* ===================== Хронология ===================== */ diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js index 097d9d4..48a559a 100644 --- a/miniapp/assets/measurements.js +++ b/miniapp/assets/measurements.js @@ -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"); diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index 84c782b..2877944 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -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 { diff --git a/miniapp/index.html b/miniapp/index.html index 0cfb0d0..fa5730d 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,14 +12,14 @@ - - + +
- +
- - - - - - - - - + + + + + + + + +