From 10bcc75b1330ff31b7d44bf56f14f1a918dee825 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Tue, 12 May 2026 17:41:01 +0300 Subject: [PATCH] measurements: kitchen layout wizard + 5 layout pictograms + profile integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 LAYOUT PICTOGRAMS (podbor.picts.js): - linear: одна стена с гарнитуром - l_shape: Г-образная, две стены с подсвеченным углом - u_shape: П-образная, три стены - island: линейный гарнитур + отдельный остров посередине - peninsula: Г-образная + барная стойка-полуостров Все в стиле D · top-down view, walnut stroke, теплые градиенты MEASUREMENTS.JS WIZARD (5 шагов): 1. client_info — имя + телефон (валидация) 2. layout — pict-карточки 5 типов 3. size — длины стен (1-3 по layout), площадь, потолок (мм) 4. openings — окно / дверь / коммуникации / заметки 5. summary — обзор + Сохранить → POST /api/measurement BACKEND (main.py): - New /api/measurements (POST) для списка замеров менеджера с опц. фильтрами по client_tg_id - _handle_measurement теперь дописывает имя+телефон клиента в notes (если client_tg_id не зарегистрирован — это новый клиент без аккаунта) - handlers dispatcher: 'measurements' route added ROUTING (app.js): - Quick-action 'Новый замер' wired to '#/measure' - routeByHash: Measurements.mount on #/measure CLIENT PROFILE (clients.js): - New section 'Замеры · N' on client history page - fetchMeasurements() filters by client_tg_id or name match in notes - layoutLabel() shows Russian label (Прямая / Угловая Г / etc.) - Cache bump v=20260512c --- backend-py/app/main.py | 73 +++++- miniapp/assets/app.js | 10 +- miniapp/assets/clients.js | 48 ++++ miniapp/assets/measurements.js | 455 +++++++++++++++++++++++++++++++++ miniapp/assets/podbor.picts.js | 111 ++++++++ miniapp/index.html | 17 +- 6 files changed, 703 insertions(+), 11 deletions(-) create mode 100644 miniapp/assets/measurements.js diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 1adb9a3..40af6fc 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -86,6 +86,7 @@ async def _dispatch_post(request: Request): handlers = { "me": _handle_me, "measurement": _handle_measurement, + "measurements": _handle_measurements_list, "podbor": _handle_podbor, "clients": _handle_clients, "lead": _handle_lead, @@ -149,6 +150,12 @@ async def api_lead(request: Request): return _handle_lead(body) +@app.post("/api/measurements") +async def api_measurements(request: Request): + body = await _safe_json(request) + return _handle_measurements_list(body) + + @app.get("/api/test_ai") async def api_test_ai(): return _handle_test_ai() @@ -408,6 +415,16 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: sheets.find_row("Clients", "tg_id", tg_id) or {} ).get("manager_tg_id", "") + # Прикрепляем имя/телефон клиента к notes если client_tg_id нет (новый клиент) + notes_full = m.get("notes", "") + extras = [] + if m.get("client_name"): + extras.append(f"Клиент: {m['client_name']}") + if m.get("client_phone"): + extras.append(f"Тел: {m['client_phone']}") + if extras: + notes_full = " · ".join(extras) + ("\n" + notes_full if notes_full else "") + sheets.append_row("Measurements", [ measurement_id, _now_iso(), client_tg_id or "", manager_tg_id or "", filled_by, @@ -417,7 +434,7 @@ def _handle_measurement(body: dict[str, Any]) -> dict[str, Any]: json.dumps(m.get("infra") or {}, ensure_ascii=False), json.dumps(m.get("niches") or {}, ensure_ascii=False), ",".join(m.get("photos") or []), - m.get("notes", ""), + notes_full, "submitted", ]) @@ -661,6 +678,60 @@ def _handle_lead(body: dict[str, Any]) -> dict[str, Any]: } +def _handle_measurements_list(body: dict[str, Any]) -> dict[str, Any]: + """Список замеров менеджера, опционально отфильтрованный по client_tg_id / client_name.""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or user.get("role") != "manager": + return {"error": "only_manager"} + + client_tg_id = body.get("client_tg_id") or "" + client_name = (body.get("client_name") or "").strip().lower() + + try: + ws = sheets.sheet("Measurements") + rows = ws.get_all_values() + except Exception as e: + log.warning("Failed to read Measurements: %s", e) + return {"ok": True, "measurements": []} + + if not rows or len(rows) < 2: + return {"ok": True, "measurements": []} + + headers = rows[0] + out = [] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * (len(headers) - len(r)))) + if str(row.get("manager_tg_id", "")) != str(tg_id): + continue + # Опциональные фильтры по клиенту + if client_tg_id and str(row.get("client_tg_id", "")) != str(client_tg_id): + continue + # Из notes / других JSON-полей вытащим client_name если был передан в measurement + # (он не сохраняется в отдельной колонке — только в JSON-обвязке) + # Для MVP — фильтр по имени делаем после парсинга JSON-полей + out.append({ + "id": row.get("id", ""), + "created_at": row.get("ts") or row.get("created_at", ""), + "client_tg_id": row.get("client_tg_id", ""), + "manager_tg_id": row.get("manager_tg_id", ""), + "filled_by": row.get("filled_by", ""), + "layout": row.get("layout", ""), + "area_m2": row.get("area_m2", ""), + "ceiling_mm": row.get("ceiling_mm", ""), + "notes": row.get("notes", ""), + "status": row.get("status", ""), + }) + + # Сортируем по дате desc + out.sort(key=lambda x: x.get("created_at", ""), reverse=True) + return {"ok": True, "count": len(out), "measurements": out} + + def _handle_test_ai() -> dict[str, Any]: cfg = get_config() res = ai.call_ai("Скажи одной фразой: что за фабрика ЗОВ?", diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index d3a21fb..49af5e7 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -179,8 +179,8 @@ function renderManagerHome(me) { const quickActions = [ { icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" }, { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, - { icon: "camera", title: "Новый замер", subtitle: "С фото", href: null }, - { icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null }, + { icon: "camera", title: "Новый замер", subtitle: "Кухня клиента", href: "#/measure" }, + { icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); @@ -370,6 +370,10 @@ async function init() { Clients.mount(app); return; } + if (location.hash.startsWith("#/measure")) { + Measurements.mount(app); + return; + } if (me.role === "manager") renderManager(me); else renderClient(me); } catch (e) { @@ -383,6 +387,8 @@ function routeByHash() { Podbor.mount(app); } else if (location.hash.startsWith("#/clients")) { Clients.mount(app); + } else if (location.hash.startsWith("#/measure")) { + Measurements.mount(app); } else { // Главный экран по роли const me = window.__zovMe; diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js index 9397126..762baf6 100644 --- a/miniapp/assets/clients.js +++ b/miniapp/assets/clients.js @@ -153,6 +153,45 @@ const Clients = (function () { leadsList.appendChild(item); } root.appendChild(leadsList); + + // Замеры этого клиента (если есть) + try { + const ms = await fetchMeasurements({ client_tg_id: client.client_tg_id || "" }); + const myMeasurements = (ms.measurements || []).filter(m => { + // Если client_tg_id зарегистрирован — фильтруем по нему + if (client.client_tg_id) return String(m.client_tg_id) === String(client.client_tg_id); + // Иначе — ищем имя клиента в notes (упрощённая логика для новых клиентов) + return (m.notes || "").toLowerCase().includes((client.client_name || "").toLowerCase()); + }); + if (myMeasurements.length) { + root.appendChild(el(`
Замеры · ${myMeasurements.length}
`)); + const mList = el(`
`); + for (const m of myMeasurements) { + const item = el(` +
+
${formatDate(m.created_at)}
+
${escHtml(layoutLabel(m.layout))}
+
${m.area_m2 ? m.area_m2 + " м²" : "—"}
+
+
+ `); + mList.appendChild(item); + } + root.appendChild(mList); + } + } catch (e) { + // Игнорируем — секция замеров просто не покажется + } + } + + function layoutLabel(key) { + return ({ + linear: "Прямая", + l_shape: "Угловая Г", + u_shape: "П-образная", + island: "С островом", + peninsula: "Полуостров", + }[key]) || (key || "—"); } /* ===================== Детали лида (re-render отчёта) ===================== */ @@ -245,6 +284,15 @@ const Clients = (function () { return await res.json(); } + async function fetchMeasurements(filters = {}) { + if (!BACKEND_URL) throw new Error("BACKEND_URL не задан"); + const res = await fetch(`${BACKEND_URL}/api/measurements`, { + method: "POST", + body: JSON.stringify({ initData: tg?.initData || "", ...filters }), + }); + return await res.json(); + } + function initial(name) { return ((name || "?").trim()[0] || "?").toUpperCase(); } diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js new file mode 100644 index 0000000..02d8720 --- /dev/null +++ b/miniapp/assets/measurements.js @@ -0,0 +1,455 @@ +/* ============================================================ + Замеры кухни — wizard для менеджера + ============================================================ */ + +const Measurements = (function () { + const STORAGE_KEY = "zov-measurement-draft"; + const STEPS = ["client", "layout", "size", "openings", "summary"]; + const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Готово"]; + + const LAYOUTS = [ + { key: "linear", label: "Прямая", hint: "одна стена", pict: "layout_linear" }, + { key: "l_shape", label: "Угловая Г", hint: "две стены, угол", pict: "layout_l_shape" }, + { key: "u_shape", label: "П-образная", hint: "три стены", pict: "layout_u_shape" }, + { key: "island", label: "С островом", hint: "линейная + блок", pict: "layout_island" }, + { key: "peninsula", label: "Полуостров", hint: "Г + барная", pict: "layout_peninsula" }, + ]; + + let state = loadState(); + let root = null; + let currentStep = "client"; + + function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw); + } catch (e) {} + return defaultState(); + } + + function defaultState() { + return { + client_name: "", + client_phone: "", + client_tg_id: "", + layout: "", + area_m2: "", + ceiling_mm: "", + walls: {}, // { wall1: 3200, wall2: 4100, ... } — мм + openings: { + window: "", // расположение окна + door: "", // расположение двери + }, + notes: "", + }; + } + + function saveState() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} + } + + function update(patch) { + state = { ...state, ...patch }; + saveState(); + } + + function reset() { + state = defaultState(); + saveState(); + } + + /* ===================== Mount + Render ===================== */ + + function mount(container) { + root = container; + document.body.classList.remove("has-bottom-nav"); + const oldNav = document.getElementById("bottom-nav"); + if (oldNav) oldNav.remove(); + currentStep = "client"; + render(); + } + + function go(step) { + if (!STEPS.includes(step)) return; + currentStep = step; + render(); + window.scrollTo({ top: 0, behavior: "smooth" }); + haptic && haptic("impact"); + } + + function render() { + if (!root) return; + root.innerHTML = ""; + root.appendChild(renderHeader()); + root.appendChild(renderProgress()); + const screen = el(`
`); + root.appendChild(screen); + + switch (currentStep) { + case "client": screen.appendChild(renderClient()); break; + case "layout": screen.appendChild(renderLayout()); break; + case "size": screen.appendChild(renderSize()); break; + case "openings": screen.appendChild(renderOpenings()); break; + case "summary": screen.appendChild(renderSummary()); break; + } + } + + function renderHeader() { + const h = el(` +
+ +
Новый замер
+
+
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { + const idx = STEPS.indexOf(currentStep); + if (idx <= 0) { + location.hash = ""; + location.reload(); + } else { + go(STEPS[idx - 1]); + } + }); + return h; + } + + function renderProgress() { + const idx = STEPS.indexOf(currentStep); + const pct = Math.round(((idx + 1) / STEPS.length) * 100); + return el(` +
+
+
+ ${STEP_LABELS[idx]}${idx + 1}/${STEPS.length} +
+
+ `); + } + + /* ===================== Шаг 1: Клиент ===================== */ + + function renderClient() { + const node = el(` +
+

Для какого
клиента?

+

Имя и телефон клиента, для которого делается замер.

+ +
+ +
+
+ +
+ +
+ +
+
+ `); + + bindInputs(node); + + node.querySelector("#next").addEventListener("click", () => { + const name = (state.client_name || "").trim(); + const phone = (state.client_phone || "").trim(); + if (!name) { + node.querySelector("#nameError").textContent = "Укажите имя"; + return; + } + // Используем нормализацию из podbor + if (phone && window.Podbor && typeof normalizePhoneShared === "function") { + // not exposed — поэтому минимальная локальная проверка + } + if (phone && phone.replace(/\D/g, "").length < 10) { + node.querySelector("#phoneError").textContent = "Слишком короткий номер"; + return; + } + go("layout"); + }); + return node; + } + + /* ===================== Шаг 2: Форма ===================== */ + + function renderLayout() { + const cur = state.layout || ""; + const cards = LAYOUTS.map(o => { + const isOn = cur === o.key; + const pict = PODBOR_PICTS[o.pict] || ""; + return ` + + `; + }).join(""); + + const node = el(` +
+

Форма кухни

+

Как расположены гарнитуры?

+
${cards}
+
+ +
+
+ `); + node.querySelectorAll(".wiz-card").forEach(card => { + card.addEventListener("click", () => { + update({ layout: card.dataset.val }); + haptic && haptic("impact"); + go("size"); + }); + }); + node.querySelector("#back").addEventListener("click", () => go("client")); + return node; + } + + /* ===================== Шаг 3: Размеры ===================== */ + + function renderSize() { + // По выбранной планировке — определяем сколько стен + const wallsCount = { + linear: 1, l_shape: 2, u_shape: 3, island: 1, peninsula: 2, + }[state.layout] || 1; + + const wallInputs = []; + for (let i = 1; i <= wallsCount; i++) { + const v = (state.walls && state.walls[`wall${i}`]) || ""; + const label = wallsCount === 1 ? "Длина стены, мм" + : `Стена ${i} (${i === 1 ? "основная" : "доп."}), мм`; + wallInputs.push(` +
+ +
+ `); + } + + const node = el(` +
+

Размеры
кухни

+

Длины стен в миллиметрах + высота потолка.

+ + ${wallInputs.join("")} + +
+ + +
+ +
+ + +
+
+ `); + + bindInputs(node); + // Wall inputs — пишем в state.walls + node.querySelectorAll("[data-wall]").forEach(inp => { + inp.addEventListener("input", e => { + const w = { ...(state.walls || {}), [e.target.dataset.wall]: e.target.value }; + update({ walls: w }); + }); + }); + + node.querySelector("#back").addEventListener("click", () => go("layout")); + node.querySelector("#next").addEventListener("click", () => go("openings")); + return node; + } + + /* ===================== Шаг 4: Окна и двери ===================== */ + + function renderOpenings() { + const o = state.openings || {}; + const node = el(` +
+

Окна
и двери

+

Опиши расположение — где окно, откуда вход, есть ли коммуникации.

+ +
+ +
+
+ +
+ +
+ +
+ +
+ + +
+
+ `); + + bindInputs(node); + node.querySelectorAll("[data-open]").forEach(inp => { + inp.addEventListener("input", e => { + update({ openings: { ...(state.openings || {}), [e.target.dataset.open]: e.target.value } }); + }); + }); + node.querySelector("#back").addEventListener("click", () => go("size")); + node.querySelector("#next").addEventListener("click", () => go("summary")); + return node; + } + + /* ===================== Шаг 5: Готово + Submit ===================== */ + + function renderSummary() { + const layout = LAYOUTS.find(l => l.key === state.layout); + const wallsText = Object.entries(state.walls || {}) + .map(([k, v]) => v ? `${k.replace("wall", "стена ")}: ${v} мм` : "") + .filter(Boolean).join(" · "); + + const node = el(` +
+

Готово
к сохранению

+

Проверьте и сохраните замер.

+
+
Клиент${escHtml(state.client_name)}
+ ${state.client_phone ? `
Телефон${escHtml(state.client_phone)}
` : ""} +
Форма${layout?.label || "—"}
+ ${wallsText ? `
Стены${escHtml(wallsText)}
` : ""} + ${state.area_m2 ? `
Площадь${escHtml(state.area_m2)} м²
` : ""} + ${state.ceiling_mm ? `
Потолок${escHtml(state.ceiling_mm)} мм
` : ""} + ${(state.openings || {}).window ? `
Окно${escHtml(state.openings.window)}
` : ""} + ${(state.openings || {}).door ? `
Дверь${escHtml(state.openings.door)}
` : ""} + ${state.notes ? `
Заметки${escHtml(state.notes)}
` : ""} +
+ +
+ + +
+ +
+
+ `); + node.querySelector("#back").addEventListener("click", () => go("openings")); + node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); + return node; + } + + async function onSubmit(node) { + const btn = node.querySelector("#submitBtn"); + const result = node.querySelector("#submitResult"); + btn.disabled = true; + btn.innerHTML = ' сохраняем...'; + result.innerHTML = ""; + + if (!BACKEND_URL) { + result.innerHTML = `
BACKEND_URL не настроен.
`; + btn.disabled = false; + btn.textContent = "Сохранить замер"; + return; + } + + const measurement = { + layout: state.layout, + area_m2: state.area_m2, + ceiling_mm: state.ceiling_mm, + walls: state.walls, + openings: state.openings, + infra: {}, + niches: {}, + photos: [], + notes: state.notes, + // Контакт клиента — заносим в заметки если он не зарегистрирован в системе + client_name: state.client_name, + client_phone: state.client_phone, + }; + + try { + const res = await fetch(`${BACKEND_URL}/api/measurement`, { + method: "POST", + body: JSON.stringify({ + initData: tg?.initData || "", + measurement, + }), + }); + const data = await res.json(); + if (data.error) { + result.innerHTML = `
Ошибка: ${data.error}
`; + } else { + result.innerHTML = ` +
+
${ICONS.check}
+
+
Замер сохранён
+
ID #${(data.id || "").slice(0, 6)}
+
+
+
+ + +
+ `; + haptic && haptic("success"); + reset(); // сбрасываем форму для следующего замера + node.querySelector("#newOne")?.addEventListener("click", () => { mount(root); }); + node.querySelector("#toHome")?.addEventListener("click", () => { + location.hash = ""; + location.reload(); + }); + } + } catch (e) { + result.innerHTML = `
Сеть: ${e.message}
`; + } + btn.disabled = false; + btn.textContent = "Сохранить ещё раз"; + } + + /* ===================== Helpers ===================== */ + + function bindInputs(node) { + node.querySelectorAll("[data-bind]").forEach(inp => { + inp.addEventListener("input", e => { + update({ [e.target.dataset.bind]: e.target.value }); + }); + }); + } + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + function escAttr(s) { return escHtml(s); } + + return { mount, reset }; +})(); diff --git a/miniapp/assets/podbor.picts.js b/miniapp/assets/podbor.picts.js index fe8ca49..11b81e8 100644 --- a/miniapp/assets/podbor.picts.js +++ b/miniapp/assets/podbor.picts.js @@ -921,6 +921,117 @@ const PODBOR_PICTS = { `, + /* ===== Планировка кухни · 5 типов (top-down вид) ===== */ + + layout_linear: ` + + + + + ПРЯМАЯ + + + + + + + + + + + L · одна стена + + `, + + layout_l_shape: ` + + + Г-ОБРАЗНАЯ + + + + + + + + + + + + + + две стены · угол + + `, + + layout_u_shape: ` + + + П-ОБРАЗНАЯ + + + + + + + + + + + + + + + + + + + + три стены + + `, + + layout_island: ` + + + С ОСТРОВОМ + + + + + + + + + + + ОСТРОВ + отдельный блок + + `, + + layout_peninsula: ` + + + ПОЛУОСТРОВ + + + + + + + + + + + + + + + Г + барная стойка + + `, + washer_install_freestanding: ` diff --git a/miniapp/index.html b/miniapp/index.html index b05c5ab..3d9d867 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + +
@@ -21,11 +21,12 @@
- - - - - - + + + + + + +