mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:44:47 +00:00
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:
parent
0d2973ea77
commit
bfd661575c
@ -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,41 +2443,35 @@ 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")
|
||||||
idx_note = headers.index("note")
|
idx_note = headers.index("note")
|
||||||
idx_upd = headers.index("updated_at")
|
idx_upd = headers.index("updated_at")
|
||||||
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]:
|
||||||
|
|||||||
@ -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 history = section.querySelector("#noteHistory");
|
||||||
const displayTx = section.querySelector("#noteDisplayText");
|
const textarea = section.querySelector("#noteText");
|
||||||
const textarea = section.querySelector("#noteText");
|
const addBtn = section.querySelector("#noteAddBtn");
|
||||||
const meta = section.querySelector("#noteMeta");
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user