mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 17:04:48 +00:00
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:
parent
52eb0e4a96
commit
4612c3a4e4
@ -116,6 +116,7 @@ async def _dispatch_post(request: Request):
|
||||
"geocode": _handle_geocode,
|
||||
"client_note": _handle_client_note,
|
||||
"client_create": _handle_client_create,
|
||||
"client_update": _handle_client_update,
|
||||
"client_delete": _handle_client_delete,
|
||||
"measurement_design_upload": _handle_measurement_design_upload,
|
||||
"measurement_decision": _handle_measurement_decision,
|
||||
@ -249,6 +250,12 @@ async def api_client_delete(request: Request):
|
||||
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")
|
||||
async def api_measurement_design_upload(request: 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)
|
||||
if not user:
|
||||
return {"error": "user_not_found"}
|
||||
if user.get("role") != "manager":
|
||||
if not sheets.has_role(user, "manager"):
|
||||
return {"error": "only_manager_can_request_podbor"}
|
||||
|
||||
checklist = body.get("checklist") or {}
|
||||
@ -1061,12 +1068,17 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"client_name": name or "Без имени",
|
||||
"client_tg_id": ctg_id or None,
|
||||
"client_phone": phone or "",
|
||||
"address": "",
|
||||
"client_no": "",
|
||||
"contract_no": "",
|
||||
"contract_date": "",
|
||||
"leads_count": 0,
|
||||
"measurements_count": 0,
|
||||
"last_lead_at": "",
|
||||
"last_lead_id": "",
|
||||
"leads": [],
|
||||
# in_work=True если есть хотя бы один лид или замер не-draft
|
||||
"in_work": False,
|
||||
}
|
||||
else:
|
||||
# Заполним пустые поля если в этой записи есть данные
|
||||
@ -1100,6 +1112,7 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
|
||||
continue
|
||||
c = _ensure_client(key, client_name, phone, client_tg_id)
|
||||
c["leads_count"] += 1
|
||||
c["in_work"] = True # есть подбор — клиент в работе
|
||||
lead_id = row.get("id", "")
|
||||
created_at = row.get("created_at", "")
|
||||
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_no = (row.get("client_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()
|
||||
if not key:
|
||||
continue
|
||||
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 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
|
||||
ts = row.get("ts") or row.get("created_at") or ""
|
||||
if ts > c["last_lead_at"]:
|
||||
@ -1141,6 +1164,30 @@ def _handle_clients(body: dict[str, Any]) -> dict[str, Any]:
|
||||
except Exception as 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)
|
||||
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]:
|
||||
"""Soft-delete всех записей Measurements по клиенту (для текущего менеджера).
|
||||
Удаление разрешено ТОЛЬКО если у клиента нет реальной работы:
|
||||
нет лидов и все его замеры в статусе 'draft' (карточка не использована).
|
||||
body: {initData, client_key} — client_key это name.lower() как в _handle_clients."""
|
||||
cfg = get_config()
|
||||
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:
|
||||
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:
|
||||
ws = sheets.sheet("Measurements")
|
||||
rows = ws.get_all_values()
|
||||
@ -2162,6 +2245,20 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = rows[0]
|
||||
if "archived_at" not in headers or "client_name" not in headers or "manager_tg_id" not in headers:
|
||||
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
|
||||
now = _now_iso()
|
||||
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:
|
||||
continue
|
||||
if row.get("archived_at"):
|
||||
continue # уже архивирован
|
||||
continue
|
||||
ws.update_cell(i, archived_idx, now)
|
||||
count += 1
|
||||
|
||||
@ -2180,6 +2277,93 @@ def _handle_client_delete(body: dict[str, Any]) -> dict[str, Any]:
|
||||
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:
|
||||
"""Стабильный ключ клиента: телефон в цифрах либо имя в lower."""
|
||||
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:
|
||||
return {"error": "measurement_not_found"}
|
||||
|
||||
# Только владелец-менеджер или клиент-владелец видит замер
|
||||
if user.get("role") == "manager":
|
||||
if str(row.get("manager_tg_id", "")) != str(tg_id):
|
||||
return {"error": "forbidden"}
|
||||
else:
|
||||
if str(row.get("client_tg_id", "")) != str(tg_id):
|
||||
return {"error": "forbidden"}
|
||||
# Доступ: владелец-менеджер, назначенный мастер (замер/сборка), клиент-владелец
|
||||
is_owner_manager = sheets.has_role(user, "manager") and 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)
|
||||
is_client = str(row.get("client_tg_id", "")) == str(tg_id)
|
||||
if not (is_owner_manager or is_assigned_master or is_client):
|
||||
return {"error": "forbidden"}
|
||||
|
||||
def _safe_json(s: str) -> Any:
|
||||
try:
|
||||
@ -2477,10 +2660,14 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
|
||||
cfg = get_config()
|
||||
auth = verify_init_data(body.get("initData") or "", cfg.bot_token)
|
||||
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"]
|
||||
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"}
|
||||
|
||||
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))))
|
||||
if str(row.get("manager_tg_id", "")) != str(tg_id):
|
||||
continue
|
||||
# Скрываем soft-deleted
|
||||
if row.get("archived_at"):
|
||||
continue
|
||||
# Опциональные фильтры по клиенту
|
||||
if client_tg_id and str(row.get("client_tg_id", "")) != str(client_tg_id):
|
||||
continue
|
||||
# Из notes / других JSON-полей вытащим client_name если был передан в measurement
|
||||
# (он не сохраняется в отдельной колонке — только в JSON-обвязке)
|
||||
# Для MVP — фильтр по имени делаем после парсинга JSON-полей
|
||||
if client_name and (row.get("client_name") or "").strip().lower() != client_name:
|
||||
continue
|
||||
photo_files = [p for p in (row.get("photos") or "").split(",") if p]
|
||||
out.append({
|
||||
"id": row.get("id", ""),
|
||||
@ -2522,6 +2711,15 @@ def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"status": row.get("status", ""),
|
||||
"photos": 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
|
||||
|
||||
@ -394,15 +394,23 @@ const Clients = (function () {
|
||||
? `<span class="client-no-badge">#${escHtml(client.client_no)}</span>`
|
||||
: "";
|
||||
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(`
|
||||
<div class="client-detail-head">
|
||||
<div class="client-avatar lg">${initial(client.client_name)}</div>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<h2 class="client-detail-name">${escHtml(client.client_name)} ${noTag}</h2>
|
||||
${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""}
|
||||
${addressTag}
|
||||
${contractTag}
|
||||
${statusTag}
|
||||
</div>
|
||||
${callHref ? `<a class="client-call-btn" href="${callHref}" aria-label="Позвонить">📞</a>` : ""}
|
||||
</div>
|
||||
@ -474,25 +482,40 @@ const Clients = (function () {
|
||||
// Детальные списки внизу (свёрнуты)
|
||||
detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements));
|
||||
|
||||
// Опасная зона — удалить клиента (soft-delete всех его записей)
|
||||
const deleteZone = el(`
|
||||
<details class="danger-zone" style="margin-top:24px;">
|
||||
<summary>⚠️ Опасная зона</summary>
|
||||
<div style="padding:12px 4px;">
|
||||
<p style="font-size:13px;color:var(--muted);margin:0 0 12px;">
|
||||
При удалении клиент будет архивирован вместе со всеми его заявками,
|
||||
замерами и подборами. Из списка он исчезнет.
|
||||
</p>
|
||||
<button class="btn-danger" id="deleteClient" type="button">🗑 Удалить клиента</button>
|
||||
<div id="deleteResult" style="margin-top:8px;font-size:12px;"></div>
|
||||
// Управление карточкой клиента — редактировать + (условно) удалить
|
||||
root.appendChild(renderClientManagement(client));
|
||||
}
|
||||
|
||||
/* ===================== Управление карточкой (edit / delete) ===================== */
|
||||
|
||||
function renderClientManagement(client) {
|
||||
const inWork = !!client.in_work;
|
||||
const wrap = el(`
|
||||
<section class="block client-manage" style="margin-top:18px;">
|
||||
<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>
|
||||
</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}? Это нельзя отменить из бота.`);
|
||||
if (!confirmed) return;
|
||||
const btn = deleteZone.querySelector("#deleteClient");
|
||||
const result = deleteZone.querySelector("#deleteResult");
|
||||
const btn = wrap.querySelector("#deleteClient");
|
||||
const result = wrap.querySelector("#manageResult");
|
||||
btn.disabled = true; btn.textContent = "Удаляем...";
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}/api/client_delete`, {
|
||||
@ -505,7 +528,8 @@ const Clients = (function () {
|
||||
});
|
||||
const data = await res.json();
|
||||
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 = "🗑 Удалить клиента";
|
||||
return;
|
||||
}
|
||||
@ -518,7 +542,127 @@ const Clients = (function () {
|
||||
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) {
|
||||
@ -549,6 +693,9 @@ const Clients = (function () {
|
||||
|
||||
for (const m of measurements) {
|
||||
const photoCount = m.photo_count || (m.photos || []).length;
|
||||
// Скрываем draft-карточки из таймлайна — это пустая «техническая» строка,
|
||||
// которая создаётся при заведении клиента. В таймлайн попадают только реальные события.
|
||||
if (m.status === "draft") continue;
|
||||
// Создание заявки / замера
|
||||
events.push({
|
||||
ts: m.created_at,
|
||||
|
||||
@ -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=20260514c">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514c">
|
||||
<link rel="stylesheet" href="assets/styles.css?v=20260514d">
|
||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514d">
|
||||
</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=20260514c" alt="@wasrusgen1">
|
||||
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514d" 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=20260514c"></script>
|
||||
<script src="assets/podbor.config.js?v=20260514c"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260514c"></script>
|
||||
<script src="assets/podbor.js?v=20260514c"></script>
|
||||
<script src="assets/clients.js?v=20260514c"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260514c"></script>
|
||||
<script src="assets/measurements.js?v=20260514c"></script>
|
||||
<script src="assets/request.js?v=20260514c"></script>
|
||||
<script src="assets/assembly.js?v=20260514c"></script>
|
||||
<script src="assets/app.js?v=20260514c"></script>
|
||||
<script src="assets/icons.js?v=20260514d"></script>
|
||||
<script src="assets/podbor.config.js?v=20260514d"></script>
|
||||
<script src="assets/podbor.picts.js?v=20260514d"></script>
|
||||
<script src="assets/podbor.js?v=20260514d"></script>
|
||||
<script src="assets/clients.js?v=20260514d"></script>
|
||||
<script src="assets/zamer-picts.js?v=20260514d"></script>
|
||||
<script src="assets/measurements.js?v=20260514d"></script>
|
||||
<script src="assets/request.js?v=20260514d"></script>
|
||||
<script src="assets/assembly.js?v=20260514d"></script>
|
||||
<script src="assets/app.js?v=20260514d"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user