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 = `
+
+
+
+ `;
+ 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 @@
-
+
+