feat: история примечаний — append-only лента вместо одной записи

- backend: _handle_client_note — запись всегда append (не upsert), чтение возвращает notes[]
- clients.js: renderClientNoteBlock переписан — лог-лента всех записей, «+ Добавить» открывает textarea
- podbor.css: .note-history, .note-entry, .note-loading, .note-empty
- index.html: cache bump → v=20260514m

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-15 22:16:47 +03:00
parent 0d2973ea77
commit bfd661575c
4 changed files with 113 additions and 98 deletions

View File

@ -2401,11 +2401,11 @@ def _ensure_client_notes_sheet():
def _handle_client_note(body: dict[str, Any]) -> dict[str, Any]: def _handle_client_note(body: dict[str, Any]) -> dict[str, Any]:
"""Чтение/запись примечания менеджера по клиенту. """Чтение/запись примечаний менеджера по клиенту (append-only история).
body: {initData, client_name, client_phone, note?, read?} body: {initData, client_name, client_phone, note?}
Если note передано пишем (upsert). Иначе просто читаем. Если note передано добавляем новую запись (append).
Возвращает {ok, note, updated_at}.""" Возвращает {ok, notes: [{note, updated_at}, ...], note, updated_at} (notes = все записи, новые сверху)."""
cfg = get_config() cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token) auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
if not auth or not auth.get("user"): if not auth or not auth.get("user"):
@ -2426,7 +2426,16 @@ def _handle_client_note(body: dict[str, Any]) -> dict[str, Any]:
_ensure_client_notes_sheet() _ensure_client_notes_sheet()
# Ищем существующую заметку этого менеджера по этому клиенту # Если note передано — пишем новую запись (append-only, история не перезаписывается)
if "note" in body and body.get("note") is not None:
new_note = str(body.get("note") or "").strip()[:4000]
if not new_note:
return {"error": "empty_note"}
now_iso = _now_iso()
sheets.append_row("ClientNotes", [str(tg_id), key, new_note, now_iso])
sheets.log_event("client_note_added", tg_id, {"key": key, "len": len(new_note)})
# Читаем все заметки этого менеджера по этому клиенту
try: try:
ws = sheets.sheet("ClientNotes") ws = sheets.sheet("ClientNotes")
rows = ws.get_all_values() rows = ws.get_all_values()
@ -2434,11 +2443,9 @@ def _handle_client_note(body: dict[str, Any]) -> dict[str, Any]:
log.warning("ClientNotes read failed: %s", e) log.warning("ClientNotes read failed: %s", e)
rows = [] rows = []
headers = rows[0] if rows else _CLIENT_NOTES_HEADERS notes: list[dict[str, str]] = []
found_row_index = None # 1-based в Sheets
current_note = ""
current_updated = ""
if rows and len(rows) >= 2: if rows and len(rows) >= 2:
headers = rows[0]
try: try:
idx_mgr = headers.index("manager_tg_id") idx_mgr = headers.index("manager_tg_id")
idx_key = headers.index("client_key") idx_key = headers.index("client_key")
@ -2447,28 +2454,24 @@ def _handle_client_note(body: dict[str, Any]) -> dict[str, Any]:
except ValueError: except ValueError:
idx_mgr = idx_key = idx_note = idx_upd = -1 idx_mgr = idx_key = idx_note = idx_upd = -1
if idx_mgr >= 0 and idx_key >= 0: if idx_mgr >= 0 and idx_key >= 0:
for i, r in enumerate(rows[1:], start=2): for r in rows[1:]:
row_mgr = r[idx_mgr] if idx_mgr < len(r) else "" if (r[idx_mgr] if idx_mgr < len(r) else "") == str(tg_id) \
row_key = r[idx_key] if idx_key < len(r) else "" and (r[idx_key] if idx_key < len(r) else "") == key:
if str(row_mgr) == str(tg_id) and row_key == key: note_text = r[idx_note] if idx_note < len(r) else ""
found_row_index = i upd = r[idx_upd] if idx_upd < len(r) else ""
current_note = r[idx_note] if idx_note < len(r) else "" if note_text:
current_updated = r[idx_upd] if idx_upd < len(r) else "" notes.append({"note": note_text, "updated_at": upd})
break
# Если note передано — пишем (upsert) # Новые сверху
if "note" in body and body.get("note") is not None: notes.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
new_note = str(body.get("note") or "").strip()[:4000] latest = notes[0] if notes else {}
now_iso = _now_iso() return {
if found_row_index: "ok": True,
ws.update_cell(found_row_index, headers.index("note") + 1, new_note) "notes": notes,
ws.update_cell(found_row_index, headers.index("updated_at") + 1, now_iso) "note": latest.get("note", ""),
else: "updated_at": latest.get("updated_at", ""),
sheets.append_row("ClientNotes", [str(tg_id), key, new_note, now_iso]) "client_key": key,
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]: def _handle_geocode(body: dict[str, Any]) -> dict[str, Any]:

View File

@ -1373,19 +1373,13 @@ const Clients = (function () {
const section = el(` const section = el(`
<section class="block client-note-block"> <section class="block client-note-block">
<div class="block-head"> <div class="block-head">
<span>📝 Примечание</span> <span>📝 Примечания</span>
<button class="note-edit-toggle" id="noteEditBtn" type="button" title="Редактировать">Изменить</button> <button class="note-edit-toggle" id="noteAddBtn" type="button">+ Добавить</button>
</div> </div>
<!-- Режим просмотра --> <!-- Форма добавления новой заметки (скрыта по умолчанию) -->
<div class="note-view" id="noteView">
<p class="note-text" id="noteDisplayText" style="color:var(--muted,#998877);font-style:italic;">Загружаем...</p>
<span class="note-meta" id="noteMeta"></span>
</div>
<!-- Режим редактирования (скрыт по умолчанию) -->
<div class="note-editor" id="noteEditor" style="display:none;"> <div class="note-editor" id="noteEditor" style="display:none;">
<textarea id="noteText" rows="4" placeholder="Заметки по клиенту — характер, предпочтения, договорённости, статус..."></textarea> <textarea id="noteText" rows="3" placeholder="Новая заметка — характер, договорённости, статус..."></textarea>
<div class="note-actions"> <div class="note-actions">
<button class="btn-mic" id="noteMic" type="button" title="Голосовой ввод">🎤 Диктовать</button> <button class="btn-mic" id="noteMic" type="button" title="Голосовой ввод">🎤 Диктовать</button>
<button class="btn-secondary" id="noteCancel" type="button">Отмена</button> <button class="btn-secondary" id="noteCancel" type="button">Отмена</button>
@ -1393,65 +1387,74 @@ const Clients = (function () {
</div> </div>
<div class="note-status" id="noteStatus"></div> <div class="note-status" id="noteStatus"></div>
</div> </div>
<!-- Лента примечаний -->
<div class="note-history" id="noteHistory">
<div class="note-loading">Загружаем...</div>
</div>
</section> </section>
`); `);
const view = section.querySelector("#noteView");
const editor = section.querySelector("#noteEditor"); const editor = section.querySelector("#noteEditor");
const displayTx = section.querySelector("#noteDisplayText"); const history = section.querySelector("#noteHistory");
const textarea = section.querySelector("#noteText"); const textarea = section.querySelector("#noteText");
const meta = section.querySelector("#noteMeta"); const addBtn = section.querySelector("#noteAddBtn");
const status = section.querySelector("#noteStatus"); const status = section.querySelector("#noteStatus");
const editBtn = section.querySelector("#noteEditBtn");
let savedText = "";
function showView(text, updatedAt) { function renderFeed(notes) {
savedText = text || ""; history.innerHTML = "";
displayTx.style.fontStyle = text ? "normal" : "italic"; if (!notes || !notes.length) {
displayTx.style.color = text ? "var(--ink,#1F1A14)" : "var(--muted,#998877)"; history.innerHTML = `<div class="note-empty">Примечаний пока нет</div>`;
displayTx.textContent = text || "Нет примечания"; return;
if (updatedAt) meta.textContent = "обновлено " + formatDate(updatedAt); }
editor.style.display = "none"; notes.forEach(n => {
view.style.display = ""; const entry = el(`
editBtn.textContent = "Изменить"; <div class="note-entry">
<p class="note-text">${escHtml(n.note)}</p>
${n.updated_at ? `<span class="note-meta">${escHtml(formatDate(n.updated_at))}</span>` : ""}
</div>
`);
history.appendChild(entry);
});
} }
function showEditor() { function openEditor() {
textarea.value = savedText; textarea.value = "";
status.textContent = ""; status.textContent = "";
status.className = "note-status"; status.className = "note-status";
editor.style.display = ""; editor.style.display = "";
view.style.display = "none"; addBtn.textContent = "Свернуть";
editBtn.textContent = "Свернуть";
textarea.focus(); textarea.focus();
} }
// Загружаем сохранённую заметку function closeEditor() {
editor.style.display = "none";
addBtn.textContent = "+ Добавить";
}
// Загружаем историю
fetchClientNote(client) fetchClientNote(client)
.then(data => showView(data?.note || "", data?.updated_at || "")) .then(data => renderFeed(data?.notes || []))
.catch(() => showView("", "")); .catch(() => renderFeed([]));
// Переключатель просмотр ↔ редактирование addBtn.addEventListener("click", () => {
editBtn.addEventListener("click", () => { if (editor.style.display === "none") openEditor(); else closeEditor();
if (editor.style.display === "none") showEditor();
else showView(savedText, meta.textContent.replace("обновлено ", ""));
}); });
// Отмена — вернуть исходный текст section.querySelector("#noteCancel").addEventListener("click", closeEditor);
section.querySelector("#noteCancel").addEventListener("click", () => {
showView(savedText, meta.textContent.replace("обновлено ", ""));
});
// Сохранение
section.querySelector("#noteSave").addEventListener("click", async () => { section.querySelector("#noteSave").addEventListener("click", async () => {
const txt = (textarea.value || "").trim();
if (!txt) { status.textContent = "Напишите заметку"; return; }
const btn = section.querySelector("#noteSave"); const btn = section.querySelector("#noteSave");
btn.disabled = true; btn.textContent = "Сохраняем..."; btn.disabled = true; btn.textContent = "Сохраняем...";
status.textContent = ""; status.className = "note-status"; status.textContent = ""; status.className = "note-status";
try { try {
const data = await saveClientNote(client, textarea.value); const data = await saveClientNote(client, txt);
if (data?.ok) { if (data?.ok) {
haptic && haptic("success"); haptic && haptic("success");
showView(textarea.value, data.updated_at || ""); closeEditor();
renderFeed(data.notes || []);
} else { } else {
status.textContent = "Ошибка: " + (data?.error || "не сохранилось"); status.textContent = "Ошибка: " + (data?.error || "не сохранилось");
status.className = "note-status err"; status.className = "note-status err";
@ -1464,9 +1467,7 @@ const Clients = (function () {
} }
}); });
// Голосовой ввод
setupVoiceInput(section.querySelector("#noteMic"), textarea, status); setupVoiceInput(section.querySelector("#noteMic"), textarea, status);
return section; return section;
} }

View File

@ -2360,9 +2360,20 @@
background: rgba(107, 74, 43, 0.10); background: rgba(107, 74, 43, 0.10);
} }
/* Просмотр-режим */ /* Лента примечаний */
.note-view { .note-history {
padding: 6px 2px 4px; margin-top: 6px;
}
.note-entry {
padding: 8px 0;
border-bottom: 1px solid rgba(107,74,43,0.08);
}
.note-entry:last-child { border-bottom: none; }
.note-loading, .note-empty {
font-size: 13px;
color: var(--muted, #998877);
font-style: italic;
padding: 6px 0;
} }
.note-text { .note-text {
font-size: 14px; font-size: 14px;

View File

@ -12,14 +12,14 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260514l"> <link rel="stylesheet" href="assets/styles.css?v=20260514m">
<link rel="stylesheet" href="assets/podbor.css?v=20260514l"> <link rel="stylesheet" href="assets/podbor.css?v=20260514m">
</head> </head>
<body> <body>
<!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск --> <!-- Splash — лого @wasrusgen1 + опилки (16) + вращающийся диск -->
<div class="loader splash" id="splash"> <div class="loader splash" id="splash">
<div class="brand-logo-wrap"> <div class="brand-logo-wrap">
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514l" alt="@wasrusgen1"> <img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514m" alt="@wasrusgen1">
<div class="splash-dust" aria-hidden="true"> <div class="splash-dust" aria-hidden="true">
<span class="dust d1"></span> <span class="dust d2"></span> <span class="dust d1"></span> <span class="dust d2"></span>
<span class="dust d3"></span> <span class="dust d4"></span> <span class="dust d3"></span> <span class="dust d4"></span>
@ -35,15 +35,15 @@
<div class="brand-tagline-gold">CRM</div> <div class="brand-tagline-gold">CRM</div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260514l"></script> <script src="assets/icons.js?v=20260514m"></script>
<script src="assets/podbor.config.js?v=20260514l"></script> <script src="assets/podbor.config.js?v=20260514m"></script>
<script src="assets/podbor.picts.js?v=20260514l"></script> <script src="assets/podbor.picts.js?v=20260514m"></script>
<script src="assets/podbor.js?v=20260514l"></script> <script src="assets/podbor.js?v=20260514m"></script>
<script src="assets/clients.js?v=20260514l"></script> <script src="assets/clients.js?v=20260514m"></script>
<script src="assets/zamer-picts.js?v=20260514l"></script> <script src="assets/zamer-picts.js?v=20260514m"></script>
<script src="assets/measurements.js?v=20260514l"></script> <script src="assets/measurements.js?v=20260514m"></script>
<script src="assets/request.js?v=20260514l"></script> <script src="assets/request.js?v=20260514m"></script>
<script src="assets/assembly.js?v=20260514l"></script> <script src="assets/assembly.js?v=20260514m"></script>
<script src="assets/app.js?v=20260514l"></script> <script src="assets/app.js?v=20260514m"></script>
</body> </body>
</html> </html>