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]:
"""Чтение/запись примечания менеджера по клиенту.
body: {initData, client_name, client_phone, note?, read?}
"""Чтение/запись примечаний менеджера по клиенту (append-only история).
body: {initData, client_name, client_phone, note?}
Если note передано пишем (upsert). Иначе просто читаем.
Возвращает {ok, note, updated_at}."""
Если note передано добавляем новую запись (append).
Возвращает {ok, notes: [{note, updated_at}, ...], note, updated_at} (notes = все записи, новые сверху)."""
cfg = get_config()
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
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()
# Ищем существующую заметку этого менеджера по этому клиенту
# Если 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:
ws = sheets.sheet("ClientNotes")
rows = ws.get_all_values()
@ -2434,41 +2443,35 @@ def _handle_client_note(body: dict[str, Any]) -> dict[str, Any]:
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 = ""
notes: list[dict[str, str]] = []
if rows and len(rows) >= 2:
headers = rows[0]
try:
idx_mgr = headers.index("manager_tg_id")
idx_key = headers.index("client_key")
idx_mgr = headers.index("manager_tg_id")
idx_key = headers.index("client_key")
idx_note = headers.index("note")
idx_upd = headers.index("updated_at")
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
for r in rows[1:]:
if (r[idx_mgr] if idx_mgr < len(r) else "") == str(tg_id) \
and (r[idx_key] if idx_key < len(r) else "") == key:
note_text = r[idx_note] if idx_note < len(r) else ""
upd = r[idx_upd] if idx_upd < len(r) else ""
if note_text:
notes.append({"note": note_text, "updated_at": upd})
# Если 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}
# Новые сверху
notes.sort(key=lambda x: x.get("updated_at", ""), reverse=True)
latest = notes[0] if notes else {}
return {
"ok": True,
"notes": notes,
"note": latest.get("note", ""),
"updated_at": latest.get("updated_at", ""),
"client_key": key,
}
def _handle_geocode(body: dict[str, Any]) -> dict[str, Any]:

View File

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

View File

@ -2360,9 +2360,20 @@
background: rgba(107, 74, 43, 0.10);
}
/* Просмотр-режим */
.note-view {
padding: 6px 2px 4px;
/* Лента примечаний */
.note-history {
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 {
font-size: 14px;

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=20260514l">
<link rel="stylesheet" href="assets/podbor.css?v=20260514l">
<link rel="stylesheet" href="assets/styles.css?v=20260514m">
<link rel="stylesheet" href="assets/podbor.css?v=20260514m">
</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=20260514l" alt="@wasrusgen1">
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514m" 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,15 +35,15 @@
<div class="brand-tagline-gold">CRM</div>
</div>
<main id="app"></main>
<script src="assets/icons.js?v=20260514l"></script>
<script src="assets/podbor.config.js?v=20260514l"></script>
<script src="assets/podbor.picts.js?v=20260514l"></script>
<script src="assets/podbor.js?v=20260514l"></script>
<script src="assets/clients.js?v=20260514l"></script>
<script src="assets/zamer-picts.js?v=20260514l"></script>
<script src="assets/measurements.js?v=20260514l"></script>
<script src="assets/request.js?v=20260514l"></script>
<script src="assets/assembly.js?v=20260514l"></script>
<script src="assets/app.js?v=20260514l"></script>
<script src="assets/icons.js?v=20260514m"></script>
<script src="assets/podbor.config.js?v=20260514m"></script>
<script src="assets/podbor.picts.js?v=20260514m"></script>
<script src="assets/podbor.js?v=20260514m"></script>
<script src="assets/clients.js?v=20260514m"></script>
<script src="assets/zamer-picts.js?v=20260514m"></script>
<script src="assets/measurements.js?v=20260514m"></script>
<script src="assets/request.js?v=20260514m"></script>
<script src="assets/assembly.js?v=20260514m"></script>
<script src="assets/app.js?v=20260514m"></script>
</body>
</html>