diff --git a/backend-py/app/main.py b/backend-py/app/main.py index b05b0d5..8a48789 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -120,6 +120,7 @@ async def _dispatch_post(request: Request): "client_delete": _handle_client_delete, "measurement_design_upload": _handle_measurement_design_upload, "measurement_decision": _handle_measurement_decision, + "measurement_set_status": _handle_measurement_set_status, "manager_pending": _handle_manager_pending, "assembly_create": _handle_assembly_create, "assembly_list": _handle_assembly_list, @@ -268,6 +269,12 @@ async def api_measurement_decision(request: Request): return _handle_measurement_decision(body) +@app.post("/api/measurement_set_status") +async def api_measurement_set_status(request: Request): + body = await _safe_json(request) + return _handle_measurement_set_status(body) + + @app.post("/api/manager_pending") async def api_manager_pending(request: Request): body = await _safe_json(request) @@ -1748,6 +1755,51 @@ def _handle_measurement_decision(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "id": measurement_id, "decision": decision} +def _handle_measurement_set_status(body: dict[str, Any]) -> dict[str, Any]: + """Менеджер меняет статус замера из карточки. + body: {initData, measurement_id, status} + Допустимые целевые статусы: cancelled, completed. + Из draft/completed/cancelled — изменения запрещены.""" + 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"} + + measurement_id = (body.get("measurement_id") or "").strip() + new_status = (body.get("status") or "").strip() + + if not measurement_id: + return {"error": "missing_measurement_id"} + if new_status not in ("cancelled", "completed"): + return {"error": "bad_status", "msg": "Допустимо: cancelled, completed"} + + row = sheets.find_row("Measurements", "id", measurement_id) + if not row: + return {"error": "measurement_not_found"} + + # Только владелец-менеджер + if str(row.get("manager_tg_id", "")) != str(tg_id): + return {"error": "forbidden"} + + current = (row.get("status") or "").strip() + if current in ("draft", "completed", "cancelled"): + return {"error": "cannot_change", "msg": f"Статус «{current}» нельзя изменить"} + # requested / scheduled → cancelled или completed + sheets.update_cell_by_key("Measurements", "id", measurement_id, "status", new_status) + sheets.log_event("measurement_status_changed", tg_id, { + "id": measurement_id, "from": current, "to": new_status, + }) + return {"ok": True, "id": measurement_id, "status": new_status, "prev_status": current} + + def _handle_manager_pending(body: dict[str, Any]) -> dict[str, Any]: """Возвращает actionable карты для менеджера на главной: завершённые замеры где ещё не зафиксировано решение про подбор.""" diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index 3464e88..b9128d7 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -1242,19 +1242,56 @@ const Clients = (function () { const openings = m.openings || {}; + // Статусные метки и цвета + const STATUS_LABEL = { + draft: "Карточка", + requested: "Заявка", + scheduled: "Назначен", + completed: "Выполнен", + cancelled: "Отменён", + }; + const STATUS_COLOR = { + draft: "var(--muted,#998877)", + requested: "#E67E22", + scheduled: "#2980B9", + completed: "#27AE60", + cancelled: "#C0392B", + }; + const statusLabel = STATUS_LABEL[m.status] || m.status || "—"; + const statusColor = STATUS_COLOR[m.status] || "var(--muted)"; + // Шапка + кнопка печати/PDF root.appendChild(el(`
-
Замер #${(m.id || "").slice(0, 8)}
+
+ Замер #${(m.id || "").slice(0, 8)} + ● ${escHtml(statusLabel)} +

${escHtml(layoutLabel(m.layout))}

📅 ${formatDate(m.created_at)} + ${m.scheduled_at ? `🗓 ${formatDate(m.scheduled_at)}` : ""} ${m.area_m2 ? `📐 ${escHtml(m.area_m2)} м²` : ""} ${m.ceiling_mm ? `📏 потолок ${escHtml(m.ceiling_mm)} мм` : ""} + ${m.address ? `📍 ${escHtml(m.address)}` : ""}
`)); + // Кнопки смены статуса (только для requested / scheduled) + if (m.status === "requested" || m.status === "scheduled") { + const statusRow = el(`
`); + if (m.status === "requested") { + const btnDone = el(``); + btnDone.addEventListener("click", () => setMeasurementStatus(measurementId, "completed", statusRow)); + statusRow.appendChild(btnDone); + } + const btnCancel = el(``); + btnCancel.addEventListener("click", () => setMeasurementStatus(measurementId, "cancelled", statusRow)); + statusRow.appendChild(btnCancel); + root.appendChild(statusRow); + } + const printBtn = el(``); printBtn.addEventListener("click", () => window.print()); root.appendChild(printBtn); @@ -1293,6 +1330,40 @@ const Clients = (function () { root.appendChild(renderDesignFilesBlock(m)); } + /* ===================== Смена статуса замера ===================== */ + + async function setMeasurementStatus(measurementId, newStatus, container) { + const confirmed = await confirmDialog( + newStatus === "cancelled" + ? "Отменить этот замер? Действие необратимо." + : "Отметить замер выполненным?" + ); + if (!confirmed) return; + + container.innerHTML = `Сохраняем...`; + try { + const res = await fetch(`${BACKEND_URL}/api/measurement_set_status`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + measurement_id: measurementId, + status: newStatus, + }), + }); + const data = await res.json(); + if (data.ok) { + haptic && haptic("success"); + container.innerHTML = `✓ Статус обновлён. Перезагружаем...`; + setTimeout(() => window.location.reload(), 900); + } else { + container.innerHTML = `Ошибка: ${escHtml(data.msg || data.error)}`; + } + } catch (e) { + container.innerHTML = `Сеть: ${escHtml(e.message)}`; + } + } + /* ===================== Чертежи / DWG ===================== */ function renderDesignFilesBlock(measurement) { diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index f7db2c2..d879066 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -3412,6 +3412,23 @@ .geo-ok { color: #27AE60; } .geo-warn { color: #C0392B; } +/* ===== Статус замера ===== */ +.mz-status-badge { + font-size: 12px; + font-weight: 600; + font-family: inherit; + letter-spacing: 0; +} +.mz-status-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin: 4px 0 12px; +} +.mz-status-btn { flex: 1; min-width: 120px; } +.ct-ok { color: #27AE60; font-size: 13px; } +.ct-err { color: #C0392B; font-size: 13px; } + /* ===== Пикер клиента (замер) ===== */ .client-picker-wrap { margin-bottom: 2px; } .picker-open-btn { diff --git a/miniapp/index.html b/miniapp/index.html index 0699599..2dc3164 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,14 +12,14 @@ - - + +
- +
- - - - - - - - - - + + + + + + + + + +