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:
wasrusgen 2026-05-14 00:09:14 +03:00
parent e808880d8e
commit 34b83899b5
6 changed files with 453 additions and 62 deletions

View File

@ -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
View 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 если есть
---
**Применять до каждого «готово, тестируй» сообщения пользователю.**

View File

@ -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));
}
});
}
/* ===================== Хронология ===================== */

View File

@ -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");

View File

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

View File

@ -12,14 +12,14 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&family=Caveat:wght@500;700&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=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>