fix: карточка клиента — данные, редактирование, удаление по правилам

Bug 1: «Завожу клиента и не вижу данных»
Причина: 3 эндпоинта проверяли user.get('role') == 'manager' напрямую,
вместо sheets.has_role(user, 'manager'). У админа теперь multi-role
(manager,measurer,assembler) → проверка падала с only_manager,
/api/measurements возвращало ошибку → таймлайн/файлы пустые.

Backend fixes:
- _handle_measurements_list: has_role + initDataUnsafe fallback + фильтр archived_at + возвращает client_name, client_phone, address, scheduled_at, client_no, contract_no, contract_date, assigned_to_tg_id
- _handle_measurement_request: has_role вместо ==
- _handle_measurement_detail: has_role + поддержка assigned_to_tg_id для мастера
- _handle_clients: возвращает address, contract_date, measurements_count, in_work
  in_work=True если: есть лиды ∨ есть не-draft замер ∨ есть сборка

Bug 2: «Не могу удалить клиента»
Причина: была спрятана в expand «Опасная зона» внизу страницы.

Новая логика прав (по запросу):
- Клиент не в работе → ✏️ Редактировать + 🗑 Удалить
- Клиент в работе → только ✏️ Редактировать
- Бэкенд тоже enforce: client_delete отвечает {error: 'in_work'}
  если есть лиды/сборки/не-draft замеры

Новые эндпоинты:
- POST /api/client_update — обновляет имя/телефон/адрес/договор
  во всех Measurements этого клиента. Возвращает обновлённый client_key
  если имя изменилось

Frontend:
- Секция «⚙️ Управление карточкой» вместо «Опасной зоны»
- Кнопка ✏️ Редактировать всегда видна, 🗑 Удалить только если !in_work
- renderEditClient — форма редактирования (имя, тел, адрес, договор № + дата)
- В шапке карточки теперь видны адрес и (если не в работе) бейдж «ещё не в работе»
- Draft-карточки скрыты из таймлайна (это техническая строка, не событие)

index.html: cache bump v=20260514d
This commit is contained in:
wasrusgen 2026-05-14 11:42:27 +03:00
parent 52eb0e4a96
commit 4612c3a4e4
3 changed files with 390 additions and 45 deletions

View File

@ -116,6 +116,7 @@ async def _dispatch_post(request: Request):
"geocode": _handle_geocode, "geocode": _handle_geocode,
"client_note": _handle_client_note, "client_note": _handle_client_note,
"client_create": _handle_client_create, "client_create": _handle_client_create,
"client_update": _handle_client_update,
"client_delete": _handle_client_delete, "client_delete": _handle_client_delete,
"measurement_design_upload": _handle_measurement_design_upload, "measurement_design_upload": _handle_measurement_design_upload,
"measurement_decision": _handle_measurement_decision, "measurement_decision": _handle_measurement_decision,
@ -249,6 +250,12 @@ async def api_client_delete(request: Request):
return _handle_client_delete(body) return _handle_client_delete(body)
@app.post("/api/client_update")
async def api_client_update(request: Request):
body = await _safe_json(request)
return _handle_client_update(body)
@app.post("/api/measurement_design_upload") @app.post("/api/measurement_design_upload")
async def api_measurement_design_upload(request: Request): async def api_measurement_design_upload(request: Request):
body = await _safe_json(request) body = await _safe_json(request)
@ -942,7 +949,7 @@ def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]:
user = sheets.find_user(tg_id) user = sheets.find_user(tg_id)
if not user: if not user:
return {"error": "user_not_found"} return {"error": "user_not_found"}
if user.get("role") != "manager": if not sheets.has_role(user, "manager"):
return {"error": "only_manager_can_request_podbor"} return {"error": "only_manager_can_request_podbor"}
checklist = body.get("checklist") or {} checklist = body.get("checklist") or {}
@ -1061,12 +1068,17 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
"client_name": name or "Без имени", "client_name": name or "Без имени",
"client_tg_id": ctg_id or None, "client_tg_id": ctg_id or None,
"client_phone": phone or "", "client_phone": phone or "",
"address": "",
"client_no": "", "client_no": "",
"contract_no": "", "contract_no": "",
"contract_date": "",
"leads_count": 0, "leads_count": 0,
"measurements_count": 0,
"last_lead_at": "", "last_lead_at": "",
"last_lead_id": "", "last_lead_id": "",
"leads": [], "leads": [],
# in_work=True если есть хотя бы один лид или замер не-draft
"in_work": False,
} }
else: else:
# Заполним пустые поля если в этой записи есть данные # Заполним пустые поля если в этой записи есть данные
@ -1100,6 +1112,7 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
continue continue
c = _ensure_client(key, client_name, phone, client_tg_id) c = _ensure_client(key, client_name, phone, client_tg_id)
c["leads_count"] += 1 c["leads_count"] += 1
c["in_work"] = True # есть подбор — клиент в работе
lead_id = row.get("id", "") lead_id = row.get("id", "")
created_at = row.get("created_at", "") created_at = row.get("created_at", "")
status = row.get("status", "") status = row.get("status", "")
@ -1128,12 +1141,22 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
client_tg_id = (row.get("client_tg_id") or "").strip() client_tg_id = (row.get("client_tg_id") or "").strip()
client_no = (row.get("client_no") or "").strip() client_no = (row.get("client_no") or "").strip()
contract_no = (row.get("contract_no") or "").strip() contract_no = (row.get("contract_no") or "").strip()
contract_date = (row.get("contract_date") or "").strip()
address = (row.get("address") or "").strip()
m_status = (row.get("status") or "").strip()
key = client_tg_id or client_name.lower() key = client_tg_id or client_name.lower()
if not key: if not key:
continue continue
c = _ensure_client(key, client_name, client_phone, client_tg_id) c = _ensure_client(key, client_name, client_phone, client_tg_id)
if client_no and not c.get("client_no"): c["client_no"] = client_no if client_no and not c.get("client_no"): c["client_no"] = client_no
if contract_no and not c.get("contract_no"): c["contract_no"] = contract_no if contract_no and not c.get("contract_no"): c["contract_no"] = contract_no
if contract_date and not c.get("contract_date"): c["contract_date"] = contract_date
if address and not c.get("address"): c["address"] = address
if client_phone and not c.get("client_phone"): c["client_phone"] = client_phone
c["measurements_count"] = c.get("measurements_count", 0) + 1
# Замер не-draft = клиент в работе (requested/scheduled/completed)
if m_status and m_status != "draft":
c["in_work"] = True
# Если у клиента нет ни одного лида — last_at берём из measurement.ts # Если у клиента нет ни одного лида — last_at берём из measurement.ts
ts = row.get("ts") or row.get("created_at") or "" ts = row.get("ts") or row.get("created_at") or ""
if ts > c["last_lead_at"]: if ts > c["last_lead_at"]:
@ -1141,6 +1164,30 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
except Exception as e: except Exception as e:
log.warning("Failed to read Measurements for clients: %s", e) log.warning("Failed to read Measurements for clients: %s", e)
# 3. Из Assemblies — есть сборка = клиент в работе
try:
ws = sheets.sheet("Assemblies")
rows = ws.get_all_values()
if rows and len(rows) >= 2:
headers = rows[0]
for r in rows[1:]:
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
if str(row.get("manager_tg_id", "")) != str(tg_id):
continue
if row.get("archived_at"):
continue
a_name = (row.get("client_name") or "").strip()
a_ctg = (row.get("client_tg_id") or "").strip()
a_phone = (row.get("client_phone") or "").strip()
key = a_ctg or a_name.lower()
if not key:
continue
c = _ensure_client(key, a_name, a_phone, a_ctg)
c["in_work"] = True
except Exception:
# Лист может ещё не существовать — не критично
pass
# Сортируем по дате последней активности (новые сверху) # Сортируем по дате последней активности (новые сверху)
clients = sorted(by_client.values(), key=lambda x: x.get("last_lead_at") or "", reverse=True) clients = sorted(by_client.values(), key=lambda x: x.get("last_lead_at") or "", reverse=True)
for c in clients: for c in clients:
@ -2133,6 +2180,8 @@ def _handle_client_create(body: dict[str, Any]) -> dict[str, Any]:
def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]: def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]:
"""Soft-delete всех записей Measurements по клиенту (для текущего менеджера). """Soft-delete всех записей Measurements по клиенту (для текущего менеджера).
Удаление разрешено ТОЛЬКО если у клиента нет реальной работы:
нет лидов и все его замеры в статусе 'draft' (карточка не использована).
body: {initData, client_key} client_key это name.lower() как в _handle_clients.""" body: {initData, client_key} client_key это name.lower() как в _handle_clients."""
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)
@ -2151,6 +2200,40 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]:
if not client_key: if not client_key:
return {"error": "missing_client_key"} return {"error": "missing_client_key"}
# Проверка: есть ли у клиента работа в Leads
try:
ws_l = sheets.sheet("Leads")
rows_l = ws_l.get_all_values()
if rows_l and len(rows_l) >= 2:
headers_l = rows_l[0]
for r in rows_l[1:]:
row = dict(zip(headers_l, r + [""] * (len(headers_l) - len(r))))
if str(row.get("manager_tg_id", "")) != str(tg_id):
continue
if (row.get("client_name") or "").strip().lower() != client_key:
continue
return {"error": "in_work", "msg": "У клиента есть подбор — удаление запрещено. Используйте редактирование."}
except Exception:
pass
# Проверка: есть ли у клиента сборки
try:
ws_a = sheets.sheet("Assemblies")
rows_a = ws_a.get_all_values()
if rows_a and len(rows_a) >= 2:
headers_a = rows_a[0]
for r in rows_a[1:]:
row = dict(zip(headers_a, r + [""] * (len(headers_a) - len(r))))
if str(row.get("manager_tg_id", "")) != str(tg_id):
continue
if row.get("archived_at"):
continue
if (row.get("client_name") or "").strip().lower() != client_key:
continue
return {"error": "in_work", "msg": "У клиента есть сборка — удаление запрещено. Используйте редактирование."}
except Exception:
pass
try: try:
ws = sheets.sheet("Measurements") ws = sheets.sheet("Measurements")
rows = ws.get_all_values() rows = ws.get_all_values()
@ -2162,6 +2245,20 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]:
headers = rows[0] headers = rows[0]
if "archived_at" not in headers or "client_name" not in headers or "manager_tg_id" not in headers: if "archived_at" not in headers or "client_name" not in headers or "manager_tg_id" not in headers:
return {"error": "schema_missing"} return {"error": "schema_missing"}
# Проверка: есть ли не-draft замеры?
for r in rows[1:]:
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
if str(row.get("manager_tg_id", "")) != str(tg_id):
continue
if (row.get("client_name") or "").strip().lower() != client_key:
continue
if row.get("archived_at"):
continue
status = (row.get("status") or "").strip()
if status and status != "draft":
return {"error": "in_work", "msg": "У клиента есть замер в работе — удаление запрещено. Используйте редактирование."}
archived_idx = headers.index("archived_at") + 1 archived_idx = headers.index("archived_at") + 1
now = _now_iso() now = _now_iso()
count = 0 count = 0
@ -2172,7 +2269,7 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]:
if (row.get("client_name") or "").strip().lower() != client_key: if (row.get("client_name") or "").strip().lower() != client_key:
continue continue
if row.get("archived_at"): if row.get("archived_at"):
continue # уже архивирован continue
ws.update_cell(i, archived_idx, now) ws.update_cell(i, archived_idx, now)
count += 1 count += 1
@ -2180,6 +2277,93 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]:
return {"ok": True, "archived": count} return {"ok": True, "archived": count}
def _handle_client_update(body: dict[str, Any]) -> dict[str, Any]:
"""Менеджер обновляет данные клиента (имя, телефон, адрес, договор).
Обновляет ВСЕ строки Measurements этого менеджера для этого клиента.
body: {initData, client_key, full_name?, phone?, address?, contract_no?, contract_date?}"""
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 isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
auth = {"user": unsafe["user"]}
else:
return {"error": "invalid_init_data"}
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_key = (body.get("client_key") or "").strip().lower()
if not client_key:
return {"error": "missing_client_key"}
new_name = (body.get("full_name") or "").strip()
new_phone_raw = (body.get("phone") or "").strip()
new_address = body.get("address")
new_contract_no = body.get("contract_no")
new_contract_date = body.get("contract_date")
if new_name and len(new_name) < 2:
return {"error": "bad_name", "msg": "Имя слишком короткое"}
new_phone = ""
if new_phone_raw:
norm, ok = _normalize_phone(new_phone_raw)
if not ok:
return {"error": "bad_phone", "msg": "Телефон в формате +7XXXXXXXXXX"}
new_phone = norm
if isinstance(new_address, str) and new_address.strip() and len(new_address.strip()) < 5:
return {"error": "bad_address", "msg": "Адрес слишком короткий"}
try:
ws = sheets.sheet("Measurements")
rows = ws.get_all_values()
except Exception as e:
return {"error": f"sheets: {e}"}
if not rows or len(rows) < 2:
return {"ok": True, "updated": 0}
headers = rows[0]
if "client_name" not in headers or "manager_tg_id" not in headers:
return {"error": "schema_missing"}
def col_idx(name: str) -> int | None:
return headers.index(name) + 1 if name in headers else None
name_col = col_idx("client_name")
phone_col = col_idx("client_phone")
address_col = col_idx("address")
contract_no_col = col_idx("contract_no")
contract_date_col = col_idx("contract_date")
updated = 0
for i, r in enumerate(rows[1:], start=2):
row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
if str(row.get("manager_tg_id", "")) != str(tg_id):
continue
if (row.get("client_name") or "").strip().lower() != client_key:
continue
if row.get("archived_at"):
continue
if new_name and name_col:
ws.update_cell(i, name_col, new_name)
if new_phone and phone_col:
ws.update_cell(i, phone_col, new_phone)
if isinstance(new_address, str) and address_col:
ws.update_cell(i, address_col, new_address.strip())
if isinstance(new_contract_no, str) and contract_no_col:
ws.update_cell(i, contract_no_col, new_contract_no.strip())
if isinstance(new_contract_date, str) and contract_date_col:
ws.update_cell(i, contract_date_col, new_contract_date.strip())
updated += 1
sheets.log_event("client_updated", tg_id, {"client_key": client_key, "updated": updated})
new_key = new_name.lower() if new_name else client_key
return {"ok": True, "updated": updated, "client_key": new_key}
def _normalize_client_key(name: str, phone: str) -> str: def _normalize_client_key(name: str, phone: str) -> str:
"""Стабильный ключ клиента: телефон в цифрах либо имя в lower.""" """Стабильный ключ клиента: телефон в цифрах либо имя в lower."""
digits = "".join(c for c in (phone or "") if c.isdigit()) digits = "".join(c for c in (phone or "") if c.isdigit())
@ -2399,13 +2583,12 @@ def _handle_measurement_detail(body: dict[str, Any]) -> dict[str, Any]:
if not row: if not row:
return {"error": "measurement_not_found"} return {"error": "measurement_not_found"}
# Только владелец-менеджер или клиент-владелец видит замер # Доступ: владелец-менеджер, назначенный мастер (замер/сборка), клиент-владелец
if user.get("role") == "manager": is_owner_manager = sheets.has_role(user, "manager") and str(row.get("manager_tg_id", "")) == str(tg_id)
if str(row.get("manager_tg_id", "")) != str(tg_id): is_assigned_master = sheets.is_master(user) and str(row.get("assigned_to_tg_id", "")) == str(tg_id)
return {"error": "forbidden"} is_client = str(row.get("client_tg_id", "")) == str(tg_id)
else: if not (is_owner_manager or is_assigned_master or is_client):
if str(row.get("client_tg_id", "")) != str(tg_id): return {"error": "forbidden"}
return {"error": "forbidden"}
def _safe_json(s: str) -> Any: def _safe_json(s: str) -> Any:
try: try:
@ -2477,10 +2660,14 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
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"):
return {"error": "invalid_init_data"} unsafe = body.get("initDataUnsafe") or {}
if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"):
auth = {"user": unsafe["user"]}
else:
return {"error": "invalid_init_data"}
tg_id = auth["user"]["id"] tg_id = auth["user"]["id"]
user = sheets.find_user(tg_id) user = sheets.find_user(tg_id)
if not user or user.get("role") != "manager": if not user or not sheets.has_role(user, "manager"):
return {"error": "only_manager"} return {"error": "only_manager"}
client_tg_id = body.get("client_tg_id") or "" client_tg_id = body.get("client_tg_id") or ""
@ -2502,12 +2689,14 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) row = dict(zip(headers, r + [""] * (len(headers) - len(r))))
if str(row.get("manager_tg_id", "")) != str(tg_id): if str(row.get("manager_tg_id", "")) != str(tg_id):
continue continue
# Скрываем soft-deleted
if row.get("archived_at"):
continue
# Опциональные фильтры по клиенту # Опциональные фильтры по клиенту
if client_tg_id and str(row.get("client_tg_id", "")) != str(client_tg_id): if client_tg_id and str(row.get("client_tg_id", "")) != str(client_tg_id):
continue continue
# Из notes / других JSON-полей вытащим client_name если был передан в measurement if client_name and (row.get("client_name") or "").strip().lower() != client_name:
# (он не сохраняется в отдельной колонке — только в JSON-обвязке) continue
# Для MVP — фильтр по имени делаем после парсинга JSON-полей
photo_files = [p for p in (row.get("photos") or "").split(",") if p] photo_files = [p for p in (row.get("photos") or "").split(",") if p]
out.append({ out.append({
"id": row.get("id", ""), "id": row.get("id", ""),
@ -2522,6 +2711,15 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
"status": row.get("status", ""), "status": row.get("status", ""),
"photos": photo_files, "photos": photo_files,
"photo_count": len(photo_files), "photo_count": len(photo_files),
# Ключевые поля для рендера карточки клиента и таймлайна
"client_name": row.get("client_name", ""),
"client_phone": row.get("client_phone", ""),
"address": row.get("address", ""),
"scheduled_at": row.get("scheduled_at", ""),
"client_no": row.get("client_no", ""),
"contract_no": row.get("contract_no", ""),
"contract_date": row.get("contract_date", ""),
"assigned_to_tg_id": row.get("assigned_to_tg_id", ""),
}) })
# Сортируем по дате desc # Сортируем по дате desc

View File

@ -394,15 +394,23 @@ const Clients = (function () {
? `<span class="client-no-badge">#${escHtml(client.client_no)}</span>` ? `<span class="client-no-badge">#${escHtml(client.client_no)}</span>`
: ""; : "";
const contractTag = client.contract_no const contractTag = client.contract_no
? `<div class="client-detail-meta">📋 договор ${escHtml(client.contract_no)}</div>` ? `<div class="client-detail-meta">📋 договор ${escHtml(client.contract_no)}${client.contract_date ? ` · ${escHtml(client.contract_date)}` : ""}</div>`
: ""; : "";
const addressTag = client.address
? `<div class="client-detail-meta">📍 ${escHtml(client.address)}</div>`
: "";
const statusTag = client.in_work
? ""
: `<div class="client-detail-meta" style="color:var(--accent-2,#76BD22);">● ещё не в работе</div>`;
root.appendChild(el(` root.appendChild(el(`
<div class="client-detail-head"> <div class="client-detail-head">
<div class="client-avatar lg">${initial(client.client_name)}</div> <div class="client-avatar lg">${initial(client.client_name)}</div>
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<h2 class="client-detail-name">${escHtml(client.client_name)} ${noTag}</h2> <h2 class="client-detail-name">${escHtml(client.client_name)} ${noTag}</h2>
${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""} ${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""}
${addressTag}
${contractTag} ${contractTag}
${statusTag}
</div> </div>
${callHref ? `<a class="client-call-btn" href="${callHref}" aria-label="Позвонить">📞</a>` : ""} ${callHref ? `<a class="client-call-btn" href="${callHref}" aria-label="Позвонить">📞</a>` : ""}
</div> </div>
@ -474,25 +482,40 @@ const Clients = (function () {
// Детальные списки внизу (свёрнуты) // Детальные списки внизу (свёрнуты)
detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements)); detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements));
// Опасная зона — удалить клиента (soft-delete всех его записей) // Управление карточкой клиента — редактировать + (условно) удалить
const deleteZone = el(` root.appendChild(renderClientManagement(client));
<details class="danger-zone" style="margin-top:24px;"> }
<summary> Опасная зона</summary>
<div style="padding:12px 4px;"> /* ===================== Управление карточкой (edit / delete) ===================== */
<p style="font-size:13px;color:var(--muted);margin:0 0 12px;">
При удалении клиент будет архивирован вместе со всеми его заявками, function renderClientManagement(client) {
замерами и подборами. Из списка он исчезнет. const inWork = !!client.in_work;
</p> const wrap = el(`
<button class="btn-danger" id="deleteClient" type="button">🗑 Удалить клиента</button> <section class="block client-manage" style="margin-top:18px;">
<div id="deleteResult" style="margin-top:8px;font-size:12px;"></div> <div class="block-head"> Управление карточкой</div>
<div class="client-manage-info" style="padding:6px 4px 10px;font-size:12.5px;color:var(--muted);line-height:1.4;">
${inWork
? "Клиент в работе. Удалить нельзя, можно только отредактировать данные."
: "Клиент ещё не передан в работу — можно изменить данные или удалить карточку."}
</div> </div>
</details> <div class="podbor-cta-row" style="gap:8px;flex-wrap:wrap;">
<button class="btn-secondary" id="editClient" type="button"> Редактировать</button>
${inWork ? "" : `<button class="btn-danger" id="deleteClient" type="button">🗑 Удалить клиента</button>`}
</div>
<div id="manageResult" style="margin-top:10px;font-size:12.5px;"></div>
</section>
`); `);
deleteZone.querySelector("#deleteClient").addEventListener("click", async () => {
wrap.querySelector("#editClient")?.addEventListener("click", () => {
haptic && haptic("impact");
renderEditClient(client);
});
wrap.querySelector("#deleteClient")?.addEventListener("click", async () => {
const confirmed = await confirmDialog(`Удалить клиента ${client.client_name}? Это нельзя отменить из бота.`); const confirmed = await confirmDialog(`Удалить клиента ${client.client_name}? Это нельзя отменить из бота.`);
if (!confirmed) return; if (!confirmed) return;
const btn = deleteZone.querySelector("#deleteClient"); const btn = wrap.querySelector("#deleteClient");
const result = deleteZone.querySelector("#deleteResult"); const result = wrap.querySelector("#manageResult");
btn.disabled = true; btn.textContent = "Удаляем..."; btn.disabled = true; btn.textContent = "Удаляем...";
try { try {
const res = await fetch(`${BACKEND_URL}/api/client_delete`, { const res = await fetch(`${BACKEND_URL}/api/client_delete`, {
@ -505,7 +528,8 @@ const Clients = (function () {
}); });
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error) {
result.innerHTML = `<span style="color:#C0392B;">Ошибка: ${escHtml(data.error)}</span>`; const msg = data.msg || data.error;
result.innerHTML = `<span style="color:#C0392B;">${escHtml(msg)}</span>`;
btn.disabled = false; btn.textContent = "🗑 Удалить клиента"; btn.disabled = false; btn.textContent = "🗑 Удалить клиента";
return; return;
} }
@ -518,7 +542,127 @@ const Clients = (function () {
btn.disabled = false; btn.textContent = "🗑 Удалить клиента"; btn.disabled = false; btn.textContent = "🗑 Удалить клиента";
} }
}); });
root.appendChild(deleteZone);
return wrap;
}
/* ===================== Форма редактирования клиента ===================== */
function renderEditClient(client) {
root.innerHTML = "";
root.appendChild(headerEl("Редактировать клиента", "#/clients"));
const form = el(`
<section class="podbor-step">
<h2 class="display-title">Редактируем<br><span class="accent">клиента</span></h2>
<p class="lede">Изменения применятся ко всем заявкам и замерам этого клиента.</p>
<div class="form-row">
<label class="field">
<span class="field-label">ФИО клиента *</span>
<input type="text" id="ed_fn" value="${escAttr(client.client_name || "")}" placeholder="Иванов Иван Иванович">
<span class="field-error" id="ed_errName"></span>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Телефон *</span>
<input type="tel" id="ed_ph" value="${escAttr(client.client_phone || "")}" placeholder="+7 921 555-12-34" inputmode="tel">
<span class="field-error" id="ed_errPhone"></span>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Адрес</span>
<input type="text" id="ed_addr" value="${escAttr(client.address || "")}" placeholder="СПб, Просвещения 87, кв. 12">
</label>
</div>
<div class="form-row two-col">
<label class="field">
<span class="field-label"> договора</span>
<input type="text" id="ed_cno" value="${escAttr(client.contract_no || "")}" placeholder="2026-0123">
</label>
<label class="field">
<span class="field-label">Дата договора</span>
<input type="date" id="ed_cdate" value="${escAttr(client.contract_date || "")}">
</label>
</div>
<div class="podbor-cta-row" style="margin-top:18px;gap:8px;">
<button class="btn-secondary" id="ed_cancel" type="button">Отмена</button>
<button class="btn-primary" id="ed_save" type="button">Сохранить</button>
</div>
<div id="ed_result" style="margin-top:10px;font-size:13px;"></div>
</section>
`);
root.appendChild(form);
form.querySelector("#ed_cancel").addEventListener("click", () => {
const key = client.client_tg_id || (client.client_name || "").toLowerCase();
location.hash = `#/clients/client/${encodeURIComponent(key)}`;
});
form.querySelector("#ed_save").addEventListener("click", async () => {
const fn = form.querySelector("#ed_fn").value.trim();
const ph = form.querySelector("#ed_ph").value.trim();
const addr = form.querySelector("#ed_addr").value.trim();
const cno = form.querySelector("#ed_cno").value.trim();
const cdate = form.querySelector("#ed_cdate").value.trim();
const errName = form.querySelector("#ed_errName");
const errPhone = form.querySelector("#ed_errPhone");
const result = form.querySelector("#ed_result");
errName.textContent = ""; errPhone.textContent = ""; result.innerHTML = "";
if (!fn || fn.length < 2) {
errName.textContent = "Имя слишком короткое";
return;
}
const norm = normalizePhone(ph);
if (!norm.ok) {
errPhone.textContent = "Телефон в формате +7XXXXXXXXXX";
return;
}
if (addr && addr.length < 5) {
result.innerHTML = `<span style="color:#C0392B;">Адрес слишком короткий</span>`;
return;
}
const btn = form.querySelector("#ed_save");
btn.disabled = true; btn.textContent = "Сохраняем...";
try {
const res = await fetch(`${BACKEND_URL}/api/client_update`, {
method: "POST",
body: JSON.stringify({
initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
client_key: (client.client_name || "").toLowerCase(),
full_name: fn,
phone: norm.value,
address: addr,
contract_no: cno,
contract_date: cdate,
}),
});
const data = await res.json();
if (data.error) {
result.innerHTML = `<span style="color:#C0392B;">${escHtml(data.msg || data.error)}</span>`;
btn.disabled = false; btn.textContent = "Сохранить";
return;
}
haptic && haptic("success");
clientsCache = null;
const newKey = data.client_key || fn.toLowerCase();
result.innerHTML = `<span style="color:#27AE60;">✓ обновлено ${data.updated} запис(ей). Открываем карточку...</span>`;
setTimeout(() => {
location.hash = `#/clients/client/${encodeURIComponent(newKey)}`;
window.location.reload();
}, 800);
} catch (e) {
result.innerHTML = `<span style="color:#C0392B;">Сеть: ${escHtml(e.message)}</span>`;
btn.disabled = false; btn.textContent = "Сохранить";
}
});
} }
function confirmDialog(msg) { function confirmDialog(msg) {
@ -549,6 +693,9 @@ const Clients = (function () {
for (const m of measurements) { for (const m of measurements) {
const photoCount = m.photo_count || (m.photos || []).length; const photoCount = m.photo_count || (m.photos || []).length;
// Скрываем draft-карточки из таймлайна — это пустая «техническая» строка,
// которая создаётся при заведении клиента. В таймлайн попадают только реальные события.
if (m.status === "draft") continue;
// Создание заявки / замера // Создание заявки / замера
events.push({ events.push({
ts: m.created_at, ts: m.created_at,

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=20260514c"> <link rel="stylesheet" href="assets/styles.css?v=20260514d">
<link rel="stylesheet" href="assets/podbor.css?v=20260514c"> <link rel="stylesheet" href="assets/podbor.css?v=20260514d">
</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=20260514c" alt="@wasrusgen1"> <img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514d" 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=20260514c"></script> <script src="assets/icons.js?v=20260514d"></script>
<script src="assets/podbor.config.js?v=20260514c"></script> <script src="assets/podbor.config.js?v=20260514d"></script>
<script src="assets/podbor.picts.js?v=20260514c"></script> <script src="assets/podbor.picts.js?v=20260514d"></script>
<script src="assets/podbor.js?v=20260514c"></script> <script src="assets/podbor.js?v=20260514d"></script>
<script src="assets/clients.js?v=20260514c"></script> <script src="assets/clients.js?v=20260514d"></script>
<script src="assets/zamer-picts.js?v=20260514c"></script> <script src="assets/zamer-picts.js?v=20260514d"></script>
<script src="assets/measurements.js?v=20260514c"></script> <script src="assets/measurements.js?v=20260514d"></script>
<script src="assets/request.js?v=20260514c"></script> <script src="assets/request.js?v=20260514d"></script>
<script src="assets/assembly.js?v=20260514c"></script> <script src="assets/assembly.js?v=20260514d"></script>
<script src="assets/app.js?v=20260514c"></script> <script src="assets/app.js?v=20260514d"></script>
</body> </body>
</html> </html>