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 @@
Сделано с душой!
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+