From a437b5544745b055ed3da6b7d0e4cc5acf410587 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Wed, 13 May 2026 07:29:18 +0300 Subject: [PATCH] =?UTF-8?q?measurements:=20auto-suggest=20=E2=84=96=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BC=D0=B5=D1=80=D0=B0,=20=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=B8=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=B3=D0=B0=D0=BB=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=87=D0=B5=D0=BA-=D0=BB=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B0,=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D0=B0=20=D1=81=D1=82?= =?UTF-8?q?=D1=8F=D0=B6=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. № замера подбирается автоматически: - POST /api/measurement_next_no возвращает max(zamer_no) + 1 - Wizard при открытии вызывает endpoint и заполняет input - Менеджер может переписать вручную (поле редактируемое) - Подпись «Подобран автоматически — можно изменить» 2. Поле «Стяжка / нулевой пол» удалено из формы: - По логике пользователя — стяжка пишется на самих фото с замером - Backend колонка floor_base остаётся для backward compat (старые записи) 3. Чек-лист стал интерактивным: - Каждый [ ] item теперь .cl-item с cursor:pointer - Тап переключает галочку (☐ ↔ ☑) + страйкаут текста - Состояние сохраняется в localStorage по measurement_id (или draft) - Sticky прогресс-бар сверху: «N из M · X%» + градиентная полоса - Кнопка ↺ в шапке — сбросить все галочки - Hapt-фидбэк на каждый тап Cache bust v=20260513m. --- backend-py/app/main.py | 43 ++++++++++ miniapp/assets/measurements.js | 138 ++++++++++++++++++++++++++++----- miniapp/assets/podbor.css | 51 +++++++++++- miniapp/index.html | 20 ++--- 4 files changed, 220 insertions(+), 32 deletions(-) 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("
    "); inList = true; } let content = line.slice(2); - // [ ] checkbox - if (content.startsWith("[ ] ")) { - out.push(`
  • ${inline(content.slice(4))}
  • `); - } else if (content.startsWith("[x] ") || content.startsWith("[X] ")) { - out.push(`
  • ${inline(content.slice(4))}
  • `); + // [ ] checkbox — делаем интерактивным с уникальным ключом + if (content.startsWith("[ ] ") || content.startsWith("[x] ") || content.startsWith("[X] ")) { + const text = content.slice(4); + // Ключ = первые 60 символов содержимого (для стабильности при edit) + const key = "cl_" + text.replace(/[^\wа-яА-ЯёЁ]+/g, "_").slice(0, 60).toLowerCase(); + const checked = !!clState[key]; + out.push( + `
  • ` + + `${checked ? "☑" : "☐"} ${inline(text)}` + + `
  • ` + ); } else { out.push(`
  • ${inline(content)}
  • `); } @@ -526,7 +625,6 @@ const Measurements = (function () { // Общая инфа замера zamer_no: state.zamer_no || "", zamer_date: state.zamer_date || "", - floor_base: state.floor_base || "", notes: state.notes || "", // Клиент client_name: isUpdate ? prefilledClient.name : state.client_name, diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index c30cba0..87276d6 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -2067,9 +2067,56 @@ .checklist-md ul { margin: 6px 0 6px 6px; padding-left: 14px; } .checklist-md li { margin: 3px 0; list-style: none; position: relative; padding-left: 22px; } .checklist-md li::before { content: ""; position: absolute; left: 6px; top: 9px; width: 6px; height: 6px; background: var(--walnut, #6B4A2B); opacity: 0.45; border-radius: 50%; } -.checklist-md li .cl-check { position: absolute; left: 0; top: 0; font-size: 14px; color: var(--walnut, #6B4A2B); } -.checklist-md li .cl-check.checked { color: var(--accent-1, #003E7E); } +.checklist-md li .cl-check { position: absolute; left: 0; top: 0; font-size: 16px; color: var(--walnut, #6B4A2B); line-height: 1.3; } .checklist-md li:has(.cl-check)::before { display: none; } + +/* Активный чекбокс — кликабельный с подсветкой */ +.checklist-md .cl-item { + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + padding: 4px 6px 4px 22px; + border-radius: 6px; + transition: background 0.12s; +} +.checklist-md .cl-item:active { background: rgba(107, 74, 43, 0.10); } +.checklist-md .cl-item.checked { color: var(--muted, #998877); text-decoration: line-through; } +.checklist-md .cl-item.checked .cl-check { + color: var(--accent-1, #003E7E); + text-decoration: none; + display: inline-block; +} + +/* Прогресс-бар чек-листа */ +.checklist-progress { + position: sticky; + top: 0; + background: var(--paper, #FBF7F0); + padding: 10px 0 12px; + margin: -6px -6px 8px; + z-index: 5; + border-bottom: 1px solid rgba(107, 74, 43, 0.12); +} +.cl-pbar { + height: 3px; + background: rgba(107, 74, 43, 0.12); + border-radius: 2px; + overflow: hidden; +} +.cl-pbar-fill { + height: 100%; + background: linear-gradient(90deg, var(--walnut, #6B4A2B), var(--accent-1, #003E7E)); + transition: width 0.25s ease; +} +.cl-pcount { + font-family: var(--font-mono, "JetBrains Mono", monospace); + font-size: 10.5px; + color: var(--muted, #998877); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-top: 6px; + text-align: right; +} .checklist-md hr { margin: 18px 0; border: none; border-top: 1px dashed rgba(107, 74, 43, 0.25); } .checklist-md code { background: rgba(107, 74, 43, 0.08); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono, "JetBrains Mono", monospace); font-size: 12.5px; } .checklist-md strong { color: var(--ink, #1F1A14); } diff --git a/miniapp/index.html b/miniapp/index.html index 9eaab7c..a56f9f8 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + + @@ -34,13 +34,13 @@
- - - - - - - - + + + + + + + +