mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 17:24:49 +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,
|
"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,
|
||||||
|
"measurement_set_status": _handle_measurement_set_status,
|
||||||
"manager_pending": _handle_manager_pending,
|
"manager_pending": _handle_manager_pending,
|
||||||
"assembly_create": _handle_assembly_create,
|
"assembly_create": _handle_assembly_create,
|
||||||
"assembly_list": _handle_assembly_list,
|
"assembly_list": _handle_assembly_list,
|
||||||
@ -268,6 +269,12 @@ async def api_measurement_decision(request: Request):
|
|||||||
return _handle_measurement_decision(body)
|
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")
|
@app.post("/api/manager_pending")
|
||||||
async def api_manager_pending(request: Request):
|
async def api_manager_pending(request: Request):
|
||||||
body = await _safe_json(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}
|
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]:
|
def _handle_manager_pending(body: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Возвращает actionable карты для менеджера на главной:
|
"""Возвращает actionable карты для менеджера на главной:
|
||||||
завершённые замеры где ещё не зафиксировано решение про подбор."""
|
завершённые замеры где ещё не зафиксировано решение про подбор."""
|
||||||
|
|||||||
@ -1242,19 +1242,56 @@ const Clients = (function () {
|
|||||||
|
|
||||||
const openings = m.openings || {};
|
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
|
// Шапка + кнопка печати/PDF
|
||||||
root.appendChild(el(`
|
root.appendChild(el(`
|
||||||
<div class="measurement-detail-head">
|
<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>
|
<h2 class="display-title">${escHtml(layoutLabel(m.layout))}</h2>
|
||||||
<div class="measurement-detail-meta">
|
<div class="measurement-detail-meta">
|
||||||
<span>📅 ${formatDate(m.created_at)}</span>
|
<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.area_m2 ? `<span>📐 ${escHtml(m.area_m2)} м²</span>` : ""}
|
||||||
${m.ceiling_mm ? `<span>📏 потолок ${escHtml(m.ceiling_mm)} мм</span>` : ""}
|
${m.ceiling_mm ? `<span>📏 потолок ${escHtml(m.ceiling_mm)} мм</span>` : ""}
|
||||||
|
${m.address ? `<span>📍 ${escHtml(m.address)}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</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>`);
|
const printBtn = el(`<button class="report-print-btn">🖨️ Скачать PDF / Печать</button>`);
|
||||||
printBtn.addEventListener("click", () => window.print());
|
printBtn.addEventListener("click", () => window.print());
|
||||||
root.appendChild(printBtn);
|
root.appendChild(printBtn);
|
||||||
@ -1293,6 +1330,40 @@ const Clients = (function () {
|
|||||||
root.appendChild(renderDesignFilesBlock(m));
|
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 ===================== */
|
/* ===================== Чертежи / DWG ===================== */
|
||||||
|
|
||||||
function renderDesignFilesBlock(measurement) {
|
function renderDesignFilesBlock(measurement) {
|
||||||
|
|||||||
@ -3412,6 +3412,23 @@
|
|||||||
.geo-ok { color: #27AE60; }
|
.geo-ok { color: #27AE60; }
|
||||||
.geo-warn { color: #C0392B; }
|
.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; }
|
.client-picker-wrap { margin-bottom: 2px; }
|
||||||
.picker-open-btn {
|
.picker-open-btn {
|
||||||
|
|||||||
@ -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=20260514n">
|
<link rel="stylesheet" href="assets/styles.css?v=20260514o">
|
||||||
<link rel="stylesheet" href="assets/podbor.css?v=20260514n">
|
<link rel="stylesheet" href="assets/podbor.css?v=20260514o">
|
||||||
</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=20260514n" alt="@wasrusgen1">
|
<img class="brand-logo" src="assets/wasrusgen-logo.svg?v=20260514o" 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=20260514n"></script>
|
<script src="assets/icons.js?v=20260514o"></script>
|
||||||
<script src="assets/podbor.config.js?v=20260514n"></script>
|
<script src="assets/podbor.config.js?v=20260514o"></script>
|
||||||
<script src="assets/podbor.picts.js?v=20260514n"></script>
|
<script src="assets/podbor.picts.js?v=20260514o"></script>
|
||||||
<script src="assets/podbor.js?v=20260514n"></script>
|
<script src="assets/podbor.js?v=20260514o"></script>
|
||||||
<script src="assets/clients.js?v=20260514n"></script>
|
<script src="assets/clients.js?v=20260514o"></script>
|
||||||
<script src="assets/zamer-picts.js?v=20260514n"></script>
|
<script src="assets/zamer-picts.js?v=20260514o"></script>
|
||||||
<script src="assets/measurements.js?v=20260514n"></script>
|
<script src="assets/measurements.js?v=20260514o"></script>
|
||||||
<script src="assets/request.js?v=20260514n"></script>
|
<script src="assets/request.js?v=20260514o"></script>
|
||||||
<script src="assets/assembly.js?v=20260514n"></script>
|
<script src="assets/assembly.js?v=20260514o"></script>
|
||||||
<script src="assets/app.js?v=20260514n"></script>
|
<script src="assets/app.js?v=20260514o"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user