diff --git a/ROADMAP.md b/ROADMAP.md index b1a8246..99cb403 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ # ROADMAP — zov-tech CRM MiniApp -> Обновляется агентами автоматически. Последнее обновление: 2026-05-18 +> Обновляется агентами автоматически. Последнее обновление: 2026-05-18 (вечер) --- @@ -29,13 +29,18 @@ Telegram MiniApp для 112 менеджеров салонов ЗОВ: подб - [x] Подбор через AI (GigaChat/Claude) - [x] Приватность клиентских данных (имена/телефоны скрыты визуально) - [x] 4 темы оформления: Default, Foundry, Boardroom, Atelier +- [x] Экран #/me — профиль для всех ролей (менеджер / сотрудник / клиент) +- [x] Экран #/master — входящие заявки для замерщика/сборщика +- [x] Экран #/inbox — входящие задачи менеджера (решение по подбору) ### Качество - [x] 15-секундный таймаут на все fetch-запросы (все модули) - [x] CSS-линтер (запрещённые паттерны + WCAG-контраст) - [x] Smoke API тесты (12 эндпоинтов) - [x] Полный тест кабинета менеджера (19 сценариев) -- [x] UI Playwright smoke (10 проверок JS-ошибок) +- [x] UI Playwright smoke (15 проверок — все экраны включая #/inbox, #/me) +- [x] CI: smoke-ui.yml — Playwright против GitHub Pages после каждого деплоя +- [x] Docker login на VPS (без 429 при ребилде) --- @@ -50,9 +55,9 @@ Telegram MiniApp для 112 менеджеров салонов ЗОВ: подб ## 📋 Бэклог (приоритизирован) ### Приоритет 1 — Завершение MVP менеджера -- [ ] Экран «Мой статус» для менеджера (роль, активность, дата последней сделки) -- [ ] Входящие задачи менеджера (`/api/manager_pending`) — экран уведомлений -- [ ] Отгрузки и поступления склад (после решения Drive) +- [x] Экран «Мой статус» — #/me реализован +- [x] Входящие задачи менеджера — #/inbox реализован +- [ ] Отгрузки и поступления склада (⏳ блокер: share Drive с zov-backend@zov-sborka.iam.gserviceaccount.com) ### Приоритет 2 — Клиентский кабинет - [ ] Базовый клиентский экран (сейчас только `#/picker`) diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index e3c25bc..07668ed 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -1,4 +1,4 @@ -// ЗОВ MiniApp — главный скрипт. v20260518j +// ЗОВ MiniApp — главный скрипт. v20260518k // На входе: подписанный initData от Telegram. // Ходим на backend → получаем профиль (роль, статус) → рендерим меню. @@ -1719,6 +1719,9 @@ function routeByHash() { } else { app.innerHTML = `
Модуль не загружен
`; } + } else if (location.hash === "#/c/selfmeasure") { + if (typeof SelfMeasureScreen !== "undefined") SelfMeasureScreen.mount(app); + else init(); } else { // Главный экран по роли const me = window.__zovMe; diff --git a/miniapp/assets/cabinet.js b/miniapp/assets/cabinet.js index 9a69d2b..dc621b5 100644 --- a/miniapp/assets/cabinet.js +++ b/miniapp/assets/cabinet.js @@ -184,6 +184,9 @@ const CabinetScreen = (function () { ${renderManagerBlock(me.manager)} ${renderProposalsBlock(proposalsData.proposals || [])} ${renderAssembliesBlock(assembliesData.assemblies || [])} +
+ +
`; diff --git a/miniapp/assets/selfmeasure.js b/miniapp/assets/selfmeasure.js new file mode 100644 index 0000000..999640c --- /dev/null +++ b/miniapp/assets/selfmeasure.js @@ -0,0 +1,809 @@ +/* ============================================================ + Самозамер кухни — #/c/selfmeasure + 5-шаговый мастер: тип кухни → стены → коммуникации → фото → контакт + ============================================================ */ + +const SelfMeasureScreen = (function () { + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 15000); + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, ...body }), + }); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Сервер не отвечает"); + throw e; + } finally { clearTimeout(t); } + } + + /* ---- SVG schematics for kitchen types ---- */ + const KITCHEN_SVGS = { + straight: ` + + + + + + + + + + + А + + `, + + l_shape: ` + + + + + + + + + + + + + + + + + А + Б + `, + + u_shape: ` + + + + + + + + + + + + + + + + + + + + + + + А + Б + В + `, + + island: ` + + + + + + + + + + + + + + + + + Остров + + А + Б + `, + }; + + const KITCHEN_LABELS = { + straight: "Прямая", + l_shape: "Угловая Г", + u_shape: "Угловая П", + island: "Островная", + }; + + /* ---- Walls by kitchen type ---- */ + function getWalls(type) { + if (type === "straight") return ["А"]; + if (type === "island") return ["А", "Б"]; + if (type === "l_shape") return ["А", "Б"]; + if (type === "u_shape") return ["А", "Б", "В"]; + return ["А"]; + } + + /* ---- Step 1: Kitchen type ---- */ + function renderStep1(state) { + const wrap = document.createElement("div"); + wrap.innerHTML = ` +
+
Выберите тип кухни
+
+ `; + const grid = document.createElement("div"); + grid.style.cssText = "display:grid;grid-template-columns:1fr 1fr;gap:12px;padding:12px 16px;"; + ["straight", "l_shape", "u_shape", "island"].forEach(type => { + const card = document.createElement("button"); + card.style.cssText = ` + display:flex;flex-direction:column;align-items:center;gap:6px; + padding:12px 8px;border-radius:12px;border:2px solid var(--border); + background:var(--surface);cursor:pointer;transition:border-color 0.2s,background 0.2s; + `; + if (state.kitchenType === type) { + card.style.borderColor = "var(--accent)"; + card.style.background = "var(--accent-faint, rgba(61,122,181,0.08))"; + } + card.innerHTML = ` + ${KITCHEN_SVGS[type]} +
+ ${escHtml(KITCHEN_LABELS[type])} +
+ `; + card.addEventListener("click", () => { + haptic && haptic("impact"); + state.kitchenType = type; + // Re-render step + const parent = wrap.parentNode; + const newStep = renderStep1(state); + parent.replaceChild(newStep, wrap); + }); + grid.appendChild(card); + }); + wrap.appendChild(grid); + return wrap; + } + + /* ---- Step 2: Wall dimensions ---- */ + function renderStep2(state) { + const walls = getWalls(state.kitchenType); + const wrap = document.createElement("div"); + + // SVG diagram + const svgDiagram = buildWallDiagramSVG(state.kitchenType, walls); + + wrap.innerHTML = ` +
+
Размеры стен
+
${svgDiagram}
+
+ Измеряйте каждую стену от угла до угла (в сантиметрах). +
+
+ `; + + const fieldsWrap = document.createElement("div"); + fieldsWrap.style.cssText = "padding:0 16px;display:flex;flex-direction:column;gap:10px;"; + + if (!state.walls) state.walls = {}; + + walls.forEach(w => { + const row = document.createElement("div"); + row.innerHTML = ` + + + `; + const inp = row.querySelector("input"); + inp.addEventListener("input", () => { + state.walls[w] = inp.value.trim(); + }); + fieldsWrap.appendChild(row); + }); + + if (state.kitchenType === "island") { + const note = document.createElement("div"); + note.style.cssText = "font-size:12px;color:var(--muted);padding:4px 0;"; + note.textContent = "Для островной кухни укажите длину основной рабочей зоны (стены А и Б). Размеры острова уточним отдельно."; + fieldsWrap.appendChild(note); + } + + wrap.appendChild(fieldsWrap); + return wrap; + } + + function buildWallDiagramSVG(type, walls) { + const w = 200, h = 140; + const common = `viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" fill="none" xmlns="http://www.w3.org/2000/svg"`; + + if (type === "straight") { + return ` + + + + + + + + А + `; + } + if (type === "l_shape") { + return ` + + + + + + + + + + + А + + + + + Б + `; + } + if (type === "u_shape") { + return ` + + + + + + + + + + + + + А + + + + + Б + + + + + В + `; + } + // island + return ` + + + + + + + + Остров + + А + + Б + `; + } + + /* ---- Step 3: Communications ---- */ + function renderStep3(state) { + const walls = getWalls(state.kitchenType); + if (!state.comms) state.comms = { water: {}, gas: {}, electric: {} }; + if (state.commsSkipped === undefined) state.commsSkipped = false; + + const wrap = document.createElement("div"); + + // Skip button + const skipBtn = document.createElement("button"); + skipBtn.className = "btn-secondary"; + skipBtn.style.cssText = "margin:16px 16px 0;width:calc(100% - 32px);display:block;"; + skipBtn.textContent = "⏭ Пропустить — только предварительный расчёт"; + skipBtn.addEventListener("click", () => { + haptic && haptic("impact"); + state.commsSkipped = true; + refreshStep3(wrap, state, walls); + }); + wrap.appendChild(skipBtn); + + const contentDiv = document.createElement("div"); + contentDiv.id = "step3-content"; + wrap.appendChild(contentDiv); + + refreshStep3(wrap, state, walls); + return wrap; + } + + function refreshStep3(wrap, state, walls) { + const contentDiv = wrap.querySelector("#step3-content"); + contentDiv.innerHTML = ""; + + if (state.commsSkipped) { + // Disclaimer + const disc = document.createElement("div"); + disc.className = "block"; + disc.style.cssText = "margin:12px 16px 0;"; + disc.innerHTML = ` +
Пропустить коммуникации
+ + + `; + disc.querySelector("#commsSkipCheck").addEventListener("change", e => { + state.commsSkipConfirmed = e.target.checked; + }); + disc.querySelector("#commsUnskipBtn").addEventListener("click", () => { + haptic && haptic("impact"); + state.commsSkipped = false; + state.commsSkipConfirmed = false; + refreshStep3(wrap, state, walls); + }); + contentDiv.appendChild(disc); + } else { + // Full comms form + const block = document.createElement("div"); + block.className = "block"; + block.style.cssText = "margin:12px 16px 0;"; + block.innerHTML = ` +
Коммуникации
+
+ Укажите расположение коммуникаций. Измеряйте расстояние от левого угла стены (смотря на стену лицом). +
+
+ ${buildCommsHintSVG()} +
+ `; + contentDiv.appendChild(block); + + // Water (always shown) + contentDiv.appendChild(buildCommsSection("Вода 🚿", "water", state, walls, true)); + // Gas + contentDiv.appendChild(buildCommsSectionGas(state, walls)); + // Electric + contentDiv.appendChild(buildCommsSection("Электрика ⚡", "electric", state, walls, true)); + } + } + + function buildCommsHintSVG() { + return ` + + + + + + + от левого угла → + `; + } + + function buildCommsSection(title, key, state, walls, alwaysShow) { + if (!state.comms[key]) state.comms[key] = {}; + const section = document.createElement("div"); + section.className = "block"; + section.style.cssText = "margin:8px 16px 0;"; + section.innerHTML = `
${escHtml(title)}
`; + + const wallOpts = walls.map(w => ``).join(""); + + const posOpts = ["Левый угол", "Центр", "Правый угол"].map(p => + `` + ).join(""); + + const form = document.createElement("div"); + form.style.cssText = "display:flex;flex-direction:column;gap:8px;"; + form.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+ `; + form.querySelectorAll("[data-field]").forEach(inp => { + const ev = inp.tagName === "SELECT" ? "change" : "input"; + inp.addEventListener(ev, () => { + state.comms[key][inp.dataset.field] = inp.value; + }); + // Init state + if (!state.comms[key][inp.dataset.field] && inp.tagName === "SELECT") { + state.comms[key][inp.dataset.field] = inp.value; + } + }); + section.appendChild(form); + return section; + } + + function buildCommsSectionGas(state, walls) { + if (!state.comms.gas) state.comms.gas = {}; + const section = document.createElement("div"); + section.className = "block"; + section.style.cssText = "margin:8px 16px 0;"; + + const hasGas = !!state.comms.gas.enabled; + + section.innerHTML = ` + + `; + + const gasFields = document.createElement("div"); + gasFields.id = "gas-fields"; + gasFields.style.display = hasGas ? "block" : "none"; + section.appendChild(gasFields); + + if (hasGas) { + const inner = buildCommsSection("", "gas", state, walls, false); + inner.style.margin = "0"; + inner.querySelector(".block-head") && (inner.querySelector(".block-head").style.display = "none"); + gasFields.appendChild(inner); + } + + section.querySelector("#gasCheck").addEventListener("change", e => { + state.comms.gas.enabled = e.target.checked; + if (e.target.checked) { + gasFields.style.display = "block"; + gasFields.innerHTML = ""; + const inner = buildCommsSection("", "gas", state, walls, false); + inner.style.margin = "0"; + const bh = inner.querySelector(".block-head"); + if (bh) bh.style.display = "none"; + gasFields.appendChild(inner); + } else { + gasFields.style.display = "none"; + } + }); + + return section; + } + + /* ---- Step 4: Photos ---- */ + function renderStep4() { + const wrap = document.createElement("div"); + wrap.innerHTML = ` +
+
Фотографии
+
📸
+
+ Сфотографируйте каждую стену с рулеткой. +
+
+ 📤 Загрузка фото — только через Telegram. После отправки замера прикрепите фото ответным сообщением в чате с менеджером. +
+
+ `; + return wrap; + } + + /* ---- Step 5: Contact + submit ---- */ + function renderStep5(state, onSubmit) { + if (!state.contact) state.contact = {}; + const wrap = document.createElement("div"); + + wrap.innerHTML = ` +
+
Контактные данные
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ `; + + wrap.querySelector("#sm-name").addEventListener("input", e => state.contact.name = e.target.value.trim()); + wrap.querySelector("#sm-phone").addEventListener("input", e => state.contact.phone = e.target.value.trim()); + wrap.querySelector("#sm-address").addEventListener("input", e => state.contact.address = e.target.value.trim()); + + return wrap; + } + + /* ---- Validation ---- */ + function validateStep(step, state) { + if (step === 1) { + return !!state.kitchenType; + } + if (step === 2) { + const walls = getWalls(state.kitchenType); + return walls.every(w => state.walls && parseInt(state.walls[w]) > 0); + } + if (step === 3) { + if (state.commsSkipped) return !!state.commsSkipConfirmed; + return true; // comms are optional when not skipped + } + if (step === 4) { + return true; // photos always optional + } + if (step === 5) { + const c = state.contact || {}; + return !!(c.name && c.phone && c.address); + } + return true; + } + + function getValidationMessage(step, state) { + if (step === 1) return "Выберите тип кухни"; + if (step === 2) { + const walls = getWalls(state.kitchenType); + const missing = walls.filter(w => !state.walls || !parseInt(state.walls[w])); + return `Введите длину стены: ${missing.join(", ")}`; + } + if (step === 3 && state.commsSkipped && !state.commsSkipConfirmed) { + return "Подтвердите понимание или введите коммуникации"; + } + if (step === 5) { + const c = state.contact || {}; + if (!c.name) return "Введите имя"; + if (!c.phone) return "Введите телефон"; + if (!c.address) return "Введите адрес"; + } + return ""; + } + + const STEP_TITLES = [ + "Тип кухни", + "Размеры стен", + "Коммуникации", + "Фотографии", + "Контакт", + ]; + + /* ---- Main mount ---- */ + async function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + const oldNav = document.getElementById("bottom-nav"); + if (oldNav) oldNav.remove(); + + // State + const state = { + kitchenType: null, + walls: {}, + commsSkipped: false, + commsSkipConfirmed: false, + comms: { water: {}, gas: {}, electric: {} }, + contact: {}, + step: 1, + }; + + // Try pre-fill contact from /api/me + try { + const me = await _api("me"); + if (me && !me.error && me.user) { + state.contact.name = me.user.full_name || ""; + state.contact.phone = me.user.phone || ""; + } + } catch (e) { /* ignore */ } + + // Header + const header = document.createElement("header"); + header.className = "podbor-header"; + header.innerHTML = ` + +
Шаг 1 / 5 — ${escHtml(STEP_TITLES[0])}
+
+ `; + header.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + if (state.step > 1) { + state.step--; + renderCurrentStep(); + } else { + history.back(); + } + }); + container.appendChild(header); + + // Progress bar + const progressBar = document.createElement("div"); + progressBar.style.cssText = "height:3px;background:var(--border);margin:0;"; + const progressFill = document.createElement("div"); + progressFill.style.cssText = "height:3px;background:var(--accent);transition:width 0.3s;"; + progressBar.appendChild(progressFill); + container.appendChild(progressBar); + + // Screen + const screen = document.createElement("div"); + screen.className = "podbor-screen"; + screen.style.cssText = "padding-bottom:80px;"; + container.appendChild(screen); + + // Bottom navigation + const bottomNav = document.createElement("div"); + bottomNav.style.cssText = ` + position:fixed;bottom:0;left:0;right:0;z-index:100; + padding:12px 16px;padding-bottom:calc(12px + env(safe-area-inset-bottom)); + background:var(--bg);border-top:1px solid var(--border); + display:flex;gap:8px; + `; + bottomNav.innerHTML = ` + + + `; + container.appendChild(bottomNav); + + const errorDiv = document.createElement("div"); + errorDiv.style.cssText = "color:#C0392B;font-size:13px;text-align:center;padding:4px 16px;min-height:20px;"; + container.insertBefore(errorDiv, bottomNav); + + function renderCurrentStep() { + screen.innerHTML = ""; + errorDiv.textContent = ""; + + // Update header + const titleEl = container.querySelector("#sm-header-title"); + if (titleEl) titleEl.textContent = `Шаг ${state.step} / 5 — ${STEP_TITLES[state.step - 1]}`; + + // Progress + progressFill.style.width = `${(state.step / 5) * 100}%`; + + // Back button + const backBtn = container.querySelector("#sm-back-btn"); + const nextBtn = container.querySelector("#sm-next-btn"); + + if (state.step === 5) { + nextBtn.textContent = "Отправить замер"; + } else { + nextBtn.textContent = "Далее →"; + } + + backBtn.style.display = state.step === 1 ? "none" : ""; + + // Render step content + let stepEl; + if (state.step === 1) stepEl = renderStep1(state); + else if (state.step === 2) stepEl = renderStep2(state); + else if (state.step === 3) stepEl = renderStep3(state); + else if (state.step === 4) stepEl = renderStep4(); + else if (state.step === 5) stepEl = renderStep5(state); + if (stepEl) screen.appendChild(stepEl); + } + + // Next button handler + container.querySelector("#sm-next-btn").addEventListener("click", async () => { + haptic && haptic("impact"); + errorDiv.textContent = ""; + + if (!validateStep(state.step, state)) { + errorDiv.textContent = getValidationMessage(state.step, state); + return; + } + + if (state.step === 5) { + await doSubmit(); + return; + } + + state.step++; + renderCurrentStep(); + }); + + // Back button handler + container.querySelector("#sm-back-btn").addEventListener("click", () => { + haptic && haptic("impact"); + if (state.step > 1) { + state.step--; + renderCurrentStep(); + } + }); + + async function doSubmit() { + const nextBtn = container.querySelector("#sm-next-btn"); + nextBtn.disabled = true; + nextBtn.textContent = "Отправляем…"; + errorDiv.textContent = ""; + + const payload = { + kitchen_type: state.kitchenType, + walls: state.walls, + comms_skipped: state.commsSkipped, + comms_skip_confirmed: state.commsSkipConfirmed, + communications: state.commsSkipped ? null : state.comms, + client_name: state.contact.name, + client_phone: state.contact.phone, + address: state.contact.address, + }; + + try { + const res = await _api("self_measure_submit", payload); + if (res.error) throw new Error(res.error); + + // Success screen + screen.innerHTML = ""; + container.querySelector("#sm-back-btn").style.display = "none"; + nextBtn.style.display = "none"; + errorDiv.textContent = ""; + + const success = document.createElement("div"); + success.style.cssText = "text-align:center;padding:40px 24px;"; + success.innerHTML = ` +
+
Замер отправлен!
+
+ Менеджер свяжется с вами в ближайшее время. +
+ + `; + success.querySelector("#sm-done-btn").addEventListener("click", () => { + haptic && haptic("success"); + location.hash = "#/c/cabinet"; + }); + screen.appendChild(success); + haptic && haptic("success"); + } catch (e) { + errorDiv.textContent = "Ошибка: " + e.message; + nextBtn.disabled = false; + nextBtn.textContent = "Отправить замер"; + } + } + + renderCurrentStep(); + } + + return { mount }; +})(); diff --git a/miniapp/index.html b/miniapp/index.html index b46e50f..8768361 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -48,6 +48,7 @@ - + +