mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 18:04:47 +00:00
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:
parent
715ac96de8
commit
5186afe0e0
@ -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 карты для менеджера на главной:
|
||||
завершённые замеры где ещё не зафиксировано решение про подбор."""
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user