diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 554b45a..f5037b4 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -114,6 +114,7 @@ async def _dispatch_post(request: Request): "measurement_next_no": _handle_measurement_next_no, "measurement_logistics": _handle_measurement_logistics, "geocode": _handle_geocode, + "client_note": _handle_client_note, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -222,6 +223,12 @@ async def api_geocode(request: Request): return _handle_geocode(body) +@app.post("/api/client_note") +async def api_client_note(request: Request): + body = await _safe_json(request) + return _handle_client_note(body) + + @app.post("/api/grant_role") async def api_grant_role(request: Request): """Админ выдаёт роль другому пользователю. @@ -1373,6 +1380,96 @@ def _handle_measurement_logistics(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "id": measurement_id, "logistics": updates} +def _normalize_client_key(name: str, phone: str) -> str: + """Стабильный ключ клиента: телефон в цифрах либо имя в lower.""" + digits = "".join(c for c in (phone or "") if c.isdigit()) + if len(digits) >= 10: + # Нормализуем +7/8 → 7XXXXXXXXXX (последние 10 цифр) + return "p:" + digits[-10:] + return "n:" + (name or "").strip().lower() + + +_CLIENT_NOTES_HEADERS = ["manager_tg_id", "client_key", "note", "updated_at"] + + +def _ensure_client_notes_sheet(): + try: + sheets.ensure_sheet("ClientNotes", _CLIENT_NOTES_HEADERS) + except Exception as e: + log.warning("Не удалось убедиться что ClientNotes есть: %s", e) + + +def _handle_client_note(body: dict[str, Any]) -> dict[str, Any]: + """Чтение/запись примечания менеджера по клиенту. + body: {initData, client_name, client_phone, note?, read?} + + Если note передано — пишем (upsert). Иначе просто читаем. + Возвращает {ok, note, updated_at}.""" + 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 not (isinstance(unsafe, dict) and unsafe.get("user", {}).get("id")): + return {"error": "invalid_init_data"} + auth = {"user": unsafe["user"]} + 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_name = (body.get("client_name") or "").strip() + client_phone = (body.get("client_phone") or "").strip() + if not client_name and not client_phone: + return {"error": "missing_client_id"} + key = _normalize_client_key(client_name, client_phone) + + _ensure_client_notes_sheet() + + # Ищем существующую заметку этого менеджера по этому клиенту + try: + ws = sheets.sheet("ClientNotes") + rows = ws.get_all_values() + except Exception as e: + log.warning("ClientNotes read failed: %s", e) + rows = [] + + headers = rows[0] if rows else _CLIENT_NOTES_HEADERS + found_row_index = None # 1-based в Sheets + current_note = "" + current_updated = "" + if rows and len(rows) >= 2: + try: + idx_mgr = headers.index("manager_tg_id") + idx_key = headers.index("client_key") + idx_note = headers.index("note") + idx_upd = headers.index("updated_at") + except ValueError: + idx_mgr = idx_key = idx_note = idx_upd = -1 + if idx_mgr >= 0 and idx_key >= 0: + for i, r in enumerate(rows[1:], start=2): + row_mgr = r[idx_mgr] if idx_mgr < len(r) else "" + row_key = r[idx_key] if idx_key < len(r) else "" + if str(row_mgr) == str(tg_id) and row_key == key: + found_row_index = i + current_note = r[idx_note] if idx_note < len(r) else "" + current_updated = r[idx_upd] if idx_upd < len(r) else "" + break + + # Если note передано — пишем (upsert) + if "note" in body and body.get("note") is not None: + new_note = str(body.get("note") or "").strip()[:4000] + now_iso = _now_iso() + if found_row_index: + ws.update_cell(found_row_index, headers.index("note") + 1, new_note) + ws.update_cell(found_row_index, headers.index("updated_at") + 1, now_iso) + else: + sheets.append_row("ClientNotes", [str(tg_id), key, new_note, now_iso]) + sheets.log_event("client_note_updated", tg_id, {"key": key, "len": len(new_note)}) + return {"ok": True, "note": new_note, "updated_at": now_iso, "client_key": key} + + return {"ok": True, "note": current_note, "updated_at": current_updated, "client_key": key} + + def _handle_geocode(body: dict[str, Any]) -> dict[str, Any]: """Прямое геокодирование: текст адреса → lat/lon. Использует Yandex (если есть YANDEX_GEOCODER_API_KEY в env) с fallback на OSM. diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index a207660..862415e 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -137,6 +137,9 @@ const Clients = (function () { `)); + // Примечание менеджера — текст или голосовой ввод + root.appendChild(renderClientNoteBlock(client)); + root.appendChild(el(`
Подборы · ${client.leads_count}
`)); const leadsList = el(`
`); @@ -340,6 +343,171 @@ const Clients = (function () { return await res.json(); } + /* ===================== Примечание по клиенту ===================== */ + + function renderClientNoteBlock(client) { + const section = el(` +
+
+ 📝 Примечание + +
+
+ +
+ + +
+
+
+
+ `); + + const textarea = section.querySelector("#noteText"); + const meta = section.querySelector("#noteMeta"); + const status = section.querySelector("#noteStatus"); + + // Загружаем сохранённую заметку + fetchClientNote(client).then(data => { + if (data?.note) textarea.value = data.note; + if (data?.updated_at) { + meta.textContent = "обновлено " + formatDate(data.updated_at); + } + }).catch(() => {}); + + // Сохранение + section.querySelector("#noteSave").addEventListener("click", async () => { + const btn = section.querySelector("#noteSave"); + btn.disabled = true; + btn.textContent = "Сохраняем..."; + try { + const data = await saveClientNote(client, textarea.value); + if (data?.ok) { + status.textContent = "✓ сохранено"; + status.className = "note-status ok"; + if (data.updated_at) meta.textContent = "обновлено " + formatDate(data.updated_at); + setTimeout(() => { status.textContent = ""; }, 2500); + } else { + status.textContent = "Ошибка: " + (data?.error || "не сохранилось"); + status.className = "note-status err"; + } + } catch (e) { + status.textContent = "Сеть: " + e.message; + status.className = "note-status err"; + } + btn.disabled = false; + btn.textContent = "Сохранить"; + }); + + // Голосовой ввод через Web Speech API + const micBtn = section.querySelector("#noteMic"); + setupVoiceInput(micBtn, textarea, status); + + return section; + } + + async function fetchClientNote(client) { + const res = await fetch(`${BACKEND_URL}/api/client_note`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + client_name: client.client_name || "", + client_phone: client.client_phone || "", + }), + }); + return await res.json(); + } + + async function saveClientNote(client, note) { + const res = await fetch(`${BACKEND_URL}/api/client_note`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + client_name: client.client_name || "", + client_phone: client.client_phone || "", + note: note || "", + }), + }); + return await res.json(); + } + + function setupVoiceInput(micBtn, textarea, status) { + const SR = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SR) { + micBtn.disabled = true; + micBtn.title = "Браузер не поддерживает голосовой ввод"; + micBtn.style.opacity = "0.5"; + return; + } + let rec = null; + let recording = false; + let baseText = ""; // текст до начала записи — чтобы не перетирать + + micBtn.addEventListener("click", () => { + if (recording) { + rec?.stop(); + return; + } + try { + rec = new SR(); + rec.lang = "ru-RU"; + rec.continuous = true; + rec.interimResults = true; + } catch (e) { + status.textContent = "Микрофон недоступен: " + e.message; + status.className = "note-status err"; + return; + } + baseText = (textarea.value || "").trim(); + const sep = baseText ? "\n" : ""; + + rec.onstart = () => { + recording = true; + micBtn.classList.add("rec"); + micBtn.textContent = "⏹ Стоп"; + status.textContent = "Слушаю..."; + status.className = "note-status"; + haptic && haptic("impact"); + }; + rec.onresult = (ev) => { + let interim = ""; + let final = ""; + for (let i = ev.resultIndex; i < ev.results.length; i++) { + const t = ev.results[i][0].transcript; + if (ev.results[i].isFinal) final += t; + else interim += t; + } + if (final) { + baseText = (baseText + sep + final).trim(); + textarea.value = baseText; + } else if (interim) { + textarea.value = baseText + sep + interim; + } + }; + rec.onerror = (ev) => { + status.textContent = "Ошибка распознавания: " + (ev.error || "неизвестно"); + status.className = "note-status err"; + recording = false; + micBtn.classList.remove("rec"); + micBtn.textContent = "🎤 Диктовать"; + }; + rec.onend = () => { + recording = false; + micBtn.classList.remove("rec"); + micBtn.textContent = "🎤 Диктовать"; + if (status.textContent === "Слушаю...") status.textContent = ""; + haptic && haptic("impact"); + }; + try { rec.start(); } + catch (e) { + status.textContent = "Не запустить: " + e.message; + status.className = "note-status err"; + } + }); + } + /* ===================== Helpers ===================== */ function headerEl(title, backHref) { diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index 50bec77..fbbbab7 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -2066,6 +2066,80 @@ flex-shrink: 0; } +/* ===== Примечание по клиенту ===== */ +.client-note-block .block-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.client-note-block .note-meta { + font-size: 11px; + color: var(--muted, #998877); + font-family: var(--font-mono, "JetBrains Mono", monospace); + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} +.client-note-block textarea { + width: 100%; + padding: 10px 12px; + background: var(--paper, #FBF7F0); + border: 1px solid rgba(107, 74, 43, 0.18); + border-radius: 8px; + resize: vertical; + min-height: 70px; + font-family: inherit; + font-size: 14px; + color: var(--ink, #1F1A14); + line-height: 1.45; +} +.client-note-block textarea:focus { outline: none; border-color: var(--walnut, #6B4A2B); } +.client-note-block .note-actions { + display: flex; + gap: 8px; + margin-top: 8px; + align-items: center; +} +.client-note-block .btn-mic { + background: transparent; + border: 1px solid var(--walnut, #6B4A2B); + color: var(--walnut, #6B4A2B); + padding: 8px 14px; + border-radius: 8px; + cursor: pointer; + font-family: inherit; + font-size: 13px; + font-weight: 500; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.client-note-block .btn-mic:disabled { cursor: not-allowed; } +.client-note-block .btn-mic.rec { + background: #C0392B; + border-color: #C0392B; + color: white; + animation: micPulse 1.2s ease-in-out infinite; +} +@keyframes micPulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(192, 57, 43, 0.5); } + 50% { box-shadow: 0 0 0 6px rgba(192, 57, 43, 0); } +} +.client-note-block .btn-secondary { + white-space: nowrap; +} +.client-note-block .note-status { + margin-top: 6px; + font-size: 12px; + font-family: var(--font-mono, "JetBrains Mono", monospace); + color: var(--muted, #998877); + min-height: 16px; +} +.client-note-block .note-status.ok { color: #27AE60; } +.client-note-block .note-status.err { color: #C0392B; } + /* ===== Заявка на замер: выбор «когда удобно» ===== */ .preferred-options { display: grid; diff --git a/miniapp/index.html b/miniapp/index.html index 6ec506e..a64e686 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + + @@ -31,14 +31,14 @@
Сделано с душой!
- - - - - - - - - + + + + + + + + +