feat: смена статуса замера из карточки + статус-бейдж

- backend: новый endpoint /api/measurement_set_status (cancelled / completed)
  только менеджер-владелец, только из requested/scheduled
- clients.js: renderMeasurement — статус-бейдж с цветом в шапке
  кнопки «Отметить выполненным» (requested) и «Отменить замер» (requested/scheduled)
  setMeasurementStatus() — confirm → API → reload
- podbor.css: .mz-status-badge, .mz-status-actions, .mz-status-btn, .ct-ok/.ct-err
- index.html: cache bump → v=20260514o

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-15 22:42:40 +03:00
parent 715ac96de8
commit 5186afe0e0
4 changed files with 154 additions and 14 deletions

View File

@ -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 карты для менеджера на главной:
завершённые замеры где ещё не зафиксировано решение про подбор."""

View File

@ -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(`
<div class="measurement-detail-head">
<div class="kicker">Замер #${(m.id || "").slice(0, 8)}</div>
<div class="kicker" style="display:flex;align-items:center;gap:8px;">
Замер #${(m.id || "").slice(0, 8)}
<span class="mz-status-badge" style="color:${statusColor};"> ${escHtml(statusLabel)}</span>
</div>
<h2 class="display-title">${escHtml(layoutLabel(m.layout))}</h2>
<div class="measurement-detail-meta">
<span>📅 ${formatDate(m.created_at)}</span>
${m.scheduled_at ? `<span>🗓 ${formatDate(m.scheduled_at)}</span>` : ""}
${m.area_m2 ? `<span>📐 ${escHtml(m.area_m2)} м²</span>` : ""}
${m.ceiling_mm ? `<span>📏 потолок ${escHtml(m.ceiling_mm)} мм</span>` : ""}
${m.address ? `<span>📍 ${escHtml(m.address)}</span>` : ""}
</div>
</div>
`));
// Кнопки смены статуса (только для requested / scheduled)
if (m.status === "requested" || m.status === "scheduled") {
const statusRow = el(`<div class="mz-status-actions"></div>`);
if (m.status === "requested") {
const btnDone = el(`<button class="btn-primary mz-status-btn" type="button">✓ Отметить выполненным</button>`);
btnDone.addEventListener("click", () => setMeasurementStatus(measurementId, "completed", statusRow));
statusRow.appendChild(btnDone);
}
const btnCancel = el(`<button class="btn-secondary mz-status-btn" type="button">✕ Отменить замер</button>`);
btnCancel.addEventListener("click", () => setMeasurementStatus(measurementId, "cancelled", statusRow));
statusRow.appendChild(btnCancel);
root.appendChild(statusRow);
}
const printBtn = el(`<button class="report-print-btn">🖨️ Скачать PDF / Печать</button>`);
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 = `<span class="muted" style="font-size:13px;">Сохраняем...</span>`;
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 = `<span class="ct-ok">✓ Статус обновлён. Перезагружаем...</span>`;
setTimeout(() => window.location.reload(), 900);
} else {
container.innerHTML = `<span class="ct-err">Ошибка: ${escHtml(data.msg || data.error)}</span>`;
}
} catch (e) {
container.innerHTML = `<span class="ct-err">Сеть: ${escHtml(e.message)}</span>`;
}
}
/* ===================== Чертежи / DWG ===================== */
function renderDesignFilesBlock(measurement) {

View File

@ -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 {

View File

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