mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +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]:
|
||||
"""Чтение/запись примечания менеджера по клиенту.
|
||||
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]:
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user