diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 90d629d..6351077 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -111,6 +111,7 @@ async def _dispatch_post(request: Request): "measurement_request": _handle_measurement_request, "measurement_inbox": _handle_measurement_inbox, "measurement_schedule": _handle_measurement_schedule, + "measurement_next_no": _handle_measurement_next_no, "ping": lambda b: {"pong": True, "time": _now_iso()}, "seed_admin": lambda b: _handle_seed_admin(), "test_ai": lambda b: _handle_test_ai(), @@ -201,6 +202,12 @@ async def api_measurement_schedule(request: Request): return _handle_measurement_schedule(body) +@app.post("/api/measurement_next_no") +async def api_measurement_next_no(request: Request): + body = await _safe_json(request) + return _handle_measurement_next_no(body) + + @app.post("/api/grant_role") async def api_grant_role(request: Request): """Админ выдаёт роль другому пользователю. @@ -1254,6 +1261,42 @@ def _handle_measurement_schedule(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "id": measurement_id, "status": "scheduled", "scheduled_at": scheduled_at} +def _handle_measurement_next_no(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает следующий свободный номер замера (max существующих + 1). + Если в Sheets ничего нет — стартуем с 1. Менеджер может скорректировать вручную + (например первый раз поставить 158, если до этого замеры были вне системы).""" + 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 not (isinstance(unsafe, dict) and unsafe.get("user", {}).get("id")): + return {"error": "invalid_init_data"} + + _ensure_measurements_sheet() + try: + ws = sheets.sheet("Measurements") + rows = ws.get_all_values() + except Exception: + return {"ok": True, "next_no": 1} + if not rows or len(rows) < 2: + return {"ok": True, "next_no": 1} + headers = rows[0] + if "zamer_no" not in headers: + return {"ok": True, "next_no": 1} + idx = headers.index("zamer_no") + max_n = 0 + for r in rows[1:]: + if idx >= len(r): + continue + try: + n = int(str(r[idx]).strip()) + if n > max_n: + max_n = n + except (ValueError, TypeError): + pass + return {"ok": True, "next_no": max_n + 1} + + def _format_date_human(iso: str) -> str: """ISO datetime → '15.05.2026 14:00' для уведомлений.""" if not iso: diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js index f0258e5..1115589 100644 --- a/miniapp/assets/measurements.js +++ b/miniapp/assets/measurements.js @@ -44,10 +44,10 @@ const Measurements = (function () { client_phone: "", address: "", notes: "", - // Общая инфа замера (по чек-листу) + // Общая инфа замера. zamer_no подгружается из бэка автоматически, + // floor_base убран — он на самих фото с замером. zamer_no: "", zamer_date: todayStr, - floor_base: "0,000 = +88 мм над плитой", }; } @@ -170,19 +170,14 @@ const Measurements = (function () {
-
- -
📷 Фото замера @@ -224,9 +219,44 @@ const Measurements = (function () { location.hash = "#/measure/checklist"; }); node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); + + // Подгружаем следующий № замера если поле пустое + if (!state.zamer_no) { + fetchNextZamerNo(node); + } else { + const hint = node.querySelector("#zamerNoHint"); + if (hint) hint.textContent = "Можно переписать вручную"; + } + return node; } + async function fetchNextZamerNo(node) { + try { + const res = await fetch(`${BACKEND_URL}/api/measurement_next_no`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + }), + }); + const data = await res.json(); + const hint = node.querySelector("#zamerNoHint"); + const input = node.querySelector("#zamerNoInput"); + if (data.ok && data.next_no && input && !state.zamer_no) { + input.value = String(data.next_no); + state.zamer_no = String(data.next_no); + saveState(); + if (hint) hint.textContent = "Подобран автоматически — можно изменить"; + } else if (hint) { + hint.textContent = "Введите номер вручную"; + } + } catch (e) { + const hint = node.querySelector("#zamerNoHint"); + if (hint) hint.textContent = "Введите номер вручную"; + } + } + function renderClientReadOnly() { return el(`
@@ -377,17 +407,31 @@ const Measurements = (function () { /* ===================== Чек-лист — отдельный экран ===================== */ + // Состояние галочек хранится в localStorage по measurement_id (или draft) + function checklistKey() { + return `zov-checklist-${measurementId || "draft"}`; + } + function loadChecklistState() { + try { return JSON.parse(localStorage.getItem(checklistKey()) || "{}"); } + catch (e) { return {}; } + } + function saveChecklistState(s) { + try { localStorage.setItem(checklistKey(), JSON.stringify(s)); } catch (e) {} + } + function resetChecklistDraft() { + try { localStorage.removeItem(`zov-checklist-draft`); } catch (e) {} + } + async function renderChecklist() { root.innerHTML = ""; root.appendChild(el(`
Чек-лист замера
-
+
`)); root.querySelector(".podbor-back").addEventListener("click", () => { - // Возврат к мастеру (если был открыт через #/measure?id=X) if (measurementId) location.hash = `#/measure?id=${measurementId}`; else location.hash = "#/measure"; }); @@ -399,14 +443,63 @@ const Measurements = (function () { try { const res = await fetch("./assets/zamer-checklist.md", { cache: "no-cache" }); const md = await res.text(); - wrap.innerHTML = `
${renderMarkdown(md)}
`; + const clState = loadChecklistState(); + wrap.innerHTML = ` +
+
${renderMarkdown(md, clState)}
+ `; + bindChecklistInteractions(wrap, clState); + updateChecklistProgress(wrap, clState); + + root.querySelector("#resetCl").addEventListener("click", () => { + if (!confirm("Сбросить все галочки?")) return; + const empty = {}; + saveChecklistState(empty); + renderChecklist(); + }); } catch (e) { wrap.innerHTML = `
Не удалось загрузить чек-лист: ${e.message}
`; } } - /* Минимальный markdown → HTML: заголовки, списки, таблицы, code */ - function renderMarkdown(md) { + function bindChecklistInteractions(wrap, clState) { + wrap.querySelectorAll(".cl-item").forEach(item => { + item.addEventListener("click", () => { + const key = item.dataset.key; + if (!key) return; + const isChecked = item.classList.contains("checked"); + const checkSpan = item.querySelector(".cl-check"); + if (isChecked) { + item.classList.remove("checked"); + if (checkSpan) checkSpan.textContent = "☐"; + delete clState[key]; + } else { + item.classList.add("checked"); + if (checkSpan) checkSpan.textContent = "☑"; + clState[key] = true; + } + saveChecklistState(clState); + updateChecklistProgress(wrap, clState); + haptic && haptic("impact"); + }); + }); + } + + function updateChecklistProgress(wrap, clState) { + const total = wrap.querySelectorAll(".cl-item").length; + const done = Object.keys(clState).filter(k => clState[k]).length; + const pct = total ? Math.round((done / total) * 100) : 0; + const bar = wrap.querySelector("#clProgress"); + if (bar) { + bar.innerHTML = ` +
+
${done} из ${total} · ${pct}%
+ `; + } + } + + /* Минимальный markdown → HTML: заголовки, списки, таблицы, code, чекбоксы */ + function renderMarkdown(md, clState = {}) { const lines = md.split("\n"); const out = []; let inList = false; @@ -461,11 +554,17 @@ const Measurements = (function () { } else if (line.startsWith("- ") || line.startsWith("* ")) { if (!inList) { out.push("
- - - - - - - - + + + + + + + +