diff --git a/miniapp/assets/measurements.js b/miniapp/assets/measurements.js index e83d899..da46ef1 100644 --- a/miniapp/assets/measurements.js +++ b/miniapp/assets/measurements.js @@ -1,27 +1,18 @@ /* ============================================================ - Замеры кухни — wizard для менеджера + Замер — упрощённая версия: только фото + заметки. + DWG-чертёж потом делается отдельным процессом из фото. ============================================================ */ const Measurements = (function () { const STORAGE_KEY = "zov-measurement-draft"; - const STEPS = ["client", "layout", "size", "openings", "photos", "summary"]; - const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Фото", "Готово"]; - // Фото держим только в памяти (data-URL'ы тяжёлые, localStorage не годится) - let photos = []; // Array<{ name: string, dataUrl: string, size: number }> - - 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" }, - ]; + // Фото держим только в памяти — data-URL'ы тяжёлые + let photos = []; // Array<{ name, dataUrl }> let state = loadState(); let root = null; - let currentStep = "client"; - let measurementId = ""; // если задан — wizard работает в update-mode (закрывает заявку) + let measurementId = ""; // если задан — update-mode (закрытие существующей заявки) + let prefilledClient = null; // данные клиента из заявки в update-mode function loadState() { try { @@ -35,15 +26,7 @@ const Measurements = (function () { return { client_name: "", client_phone: "", - client_tg_id: "", - layout: "", - area_m2: "", - ceiling_mm: "", - walls: {}, // { wall1: 3200, wall2: 4100, ... } — мм - openings: { - window: "", // расположение окна - door: "", // расположение двери - }, + address: "", notes: "", }; } @@ -52,15 +35,11 @@ const Measurements = (function () { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} } - function update(patch) { - state = { ...state, ...patch }; - saveState(); - } - function reset() { state = defaultState(); saveState(); photos = []; + prefilledClient = null; } /* ===================== Mount + Render ===================== */ @@ -70,12 +49,12 @@ const Measurements = (function () { document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); - currentStep = "client"; - photos = []; // на старте нового замера — чистый список - // Если URL содержит ?id= или fragment #/measure?id=... — это update-mode: - // wizard закрывает существующую заявку. Подтягиваем данные заявки и сразу прыгаем на layout. + photos = []; measurementId = ""; + prefilledClient = null; + + // ?id= → update-mode (замерщик закрывает заявку) const hashMatch = (location.hash.split("?")[1] || ""); const fragQp = new URLSearchParams(hashMatch); const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || ""; @@ -88,318 +67,162 @@ const Measurements = (function () { } async function loadRequestAndStart() { - // Загружаем существующую заявку и пред-заполняем client_name/phone/address root.innerHTML = ""; + root.appendChild(renderHeader("Закрыть заявку")); root.appendChild(el(`
`)); try { const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, { method: "POST", - body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }), + body: JSON.stringify({ + initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, + measurement_id: measurementId, + }), }); const data = await res.json(); if (data.error) { - root.innerHTML = `
${data.error}
`; + root.innerHTML = ""; + root.appendChild(renderHeader("Ошибка")); + root.appendChild(el(`
${data.error}
`)); return; } - // Pre-fill state с данными из заявки - state = { - ...defaultState(), - client_name: data.client_name || "", - client_phone: data.client_phone || "", + prefilledClient = { + name: data.client_name || "", + phone: data.client_phone || "", + address: data.address || "", }; - saveState(); - // Сразу прыгаем на форму (client уже заполнен) - currentStep = "layout"; + // Не показываем форму клиента — она read-only + state.notes = state.notes || ""; render(); } catch (e) { - root.innerHTML = `
Сеть: ${e.message}
`; + root.innerHTML = ""; + root.appendChild(renderHeader("Ошибка")); + root.appendChild(el(`
Сеть: ${e.message}
`)); } } - 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()); + root.appendChild(renderHeader(measurementId ? "Закрыть заявку" : "Новый замер")); 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 "photos": screen.appendChild(renderPhotos()); break; - case "summary": screen.appendChild(renderSummary()); break; - } + screen.appendChild(renderForm()); } - function renderHeader() { + function renderHeader(title) { const h = el(`
-
Новый замер
+
${escHtml(title)}
`); h.querySelector(".podbor-back").addEventListener("click", () => { - const idx = STEPS.indexOf(currentStep); - if (idx <= 0) { - location.hash = ""; - location.reload(); - } else { - go(STEPS[idx - 1]); - } + location.hash = ""; + location.reload(); }); 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} + /* ===================== Главный экран — всё на одной странице ===================== */ + + function renderForm() { + const isUpdate = !!measurementId && prefilledClient; + const clientBlock = isUpdate ? renderClientReadOnly() : renderClientInputs(); + + const node = el(` +
+

${isUpdate ? "Фото
с замера" : "Новый
замер"}

+

${isUpdate + ? "Загрузите фото от руки нарисованных замеров (а также общие фото помещения). Чертёж сделаем по ним отдельно." + : "Заполните данные клиента и загрузите фото замера. Можно фотать рукописные эскизы — главное чтобы были видны размеры."} +

+ +
+ +
📷 Фото замера
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+ `); + + node.querySelector("#clientBlock").appendChild(clientBlock); + bindInputs(node); + bindPhotoInput(node); + + node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); + return node; + } + + function renderClientReadOnly() { + return el(` +
+
Клиент ${escHtml(prefilledClient.name || "—")}
+ ${prefilledClient.phone ? `
Телефон ${escHtml(prefilledClient.phone)}
` : ""} + ${prefilledClient.address ? `
Адрес ${escHtml(prefilledClient.address)}
` : ""}
`); } - /* ===================== Шаг 1: Клиент ===================== */ - - function renderClient() { - const node = el(` -
-

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

-

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

- + function renderClientInputs() { + const block = 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("")} - -
- - -
- -
- - -
-
+
`); + return block; + } - bindInputs(node); - // Wall inputs — пишем в state.walls - node.querySelectorAll("[data-wall]").forEach(inp => { + function bindInputs(node) { + node.querySelectorAll("[data-bind]").forEach(inp => { inp.addEventListener("input", e => { - const w = { ...(state.walls || {}), [e.target.dataset.wall]: e.target.value }; - update({ walls: w }); + state[e.target.dataset.bind] = e.target.value; + saveState(); }); }); - - 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("photos")); - return node; - } - - /* ===================== Шаг 5: Фото замера ===================== */ - - function renderPhotos() { - const node = el(` -
-

Фото
кухни

-

Сними помещение со всех углов. Минимум: общий вид, окно/дверь, ниши и коммуникации.

- -
- - -
- -
- -
- - -
-
- `); - + function bindPhotoInput(node) { const list = node.querySelector("#photoList"); const input = node.querySelector("#photoInput"); @@ -424,15 +247,14 @@ const Measurements = (function () { input.addEventListener("change", async (e) => { const files = Array.from(e.target.files || []); - input.value = ""; // позволяем выбрать тот же файл снова + input.value = ""; if (!files.length) return; - for (const f of files) { - if (photos.length >= 12) break; + if (photos.length >= 20) break; if (!f.type || !f.type.startsWith("image/")) continue; try { - const dataUrl = await compressImage(f, 1600, 0.78); - photos.push({ name: f.name || `photo_${photos.length + 1}`, dataUrl, size: dataUrl.length }); + const dataUrl = await compressImage(f, 1800, 0.78); + photos.push({ name: f.name || `photo_${photos.length + 1}`, dataUrl }); } catch (err) { console.warn("Не удалось сжать фото", err); } @@ -442,13 +264,10 @@ const Measurements = (function () { }); refreshList(); - node.querySelector("#back").addEventListener("click", () => go("openings")); - node.querySelector("#next").addEventListener("click", () => go("summary")); - return node; } - /* Жмём картинку через canvas, возвращаем data-URL jpeg ~75% */ - function compressImage(file, maxSide = 1600, quality = 0.78) { + /* Сжатие через canvas */ + function compressImage(file, maxSide = 1800, quality = 0.78) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = reject; @@ -469,11 +288,9 @@ const Measurements = (function () { const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; - const ctx = canvas.getContext("2d"); - ctx.drawImage(img, 0, 0, width, height); - try { - resolve(canvas.toDataURL("image/jpeg", quality)); - } catch (err) { reject(err); } + canvas.getContext("2d").drawImage(img, 0, 0, width, height); + try { resolve(canvas.toDataURL("image/jpeg", quality)); } + catch (err) { reject(err); } }; img.src = e.target.result; }; @@ -481,49 +298,7 @@ const Measurements = (function () { }); } - /* ===================== Шаг 6: Готово + 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)}
` : ""} - ${photos.length ? `
Фото${photos.length} шт
` : ""} -
- - ${photos.length ? ` -
- ${photos.map(p => `
`).join("")} -
- ` : ""} - -
- - -
- -
-
- `); - node.querySelector("#back").addEventListener("click", () => go("photos")); - node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); - return node; - } + /* ===================== Submit ===================== */ async function onSubmit(node) { const btn = node.querySelector("#submitBtn"); @@ -532,28 +307,38 @@ const Measurements = (function () { btn.innerHTML = ' сохраняем...'; result.innerHTML = ""; - if (!BACKEND_URL) { - result.innerHTML = `
BACKEND_URL не настроен.
`; - btn.disabled = false; - btn.textContent = "Сохранить замер"; + // Валидация: для новой записи нужны клиент + телефон + хотя бы 1 фото + const isUpdate = !!measurementId && prefilledClient; + if (!isUpdate) { + const name = (state.client_name || "").trim(); + const phone = (state.client_phone || "").trim(); + const nameErr = node.querySelector("#nameError"); + const phoneErr = node.querySelector("#phoneError"); + if (nameErr) nameErr.textContent = ""; + if (phoneErr) phoneErr.textContent = ""; + if (!name) { + if (nameErr) nameErr.textContent = "Укажите имя клиента"; + btn.disabled = false; btn.textContent = "Сохранить замер"; + return; + } + if (phone.replace(/\D/g, "").length < 10) { + if (phoneErr) phoneErr.textContent = "Слишком короткий номер"; + btn.disabled = false; btn.textContent = "Сохранить замер"; + return; + } + } + if (!photos.length) { + result.innerHTML = `
Добавьте хотя бы одно фото замера.
`; + btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер"; return; } const measurement = { - layout: state.layout, - area_m2: state.area_m2, - ceiling_mm: state.ceiling_mm, - walls: state.walls, - openings: state.openings, - infra: {}, - niches: {}, - // Бэкенд раскодирует data-URL → файл и сохранит имена в Sheets photos: photos.map(p => p.dataUrl), - notes: state.notes, - // Контакт клиента — заносим в заметки если он не зарегистрирован в системе - client_name: state.client_name, - client_phone: state.client_phone, - // Если задан — backend обновит существующую заявку (update-mode) + notes: state.notes || "", + client_name: isUpdate ? prefilledClient.name : state.client_name, + client_phone: isUpdate ? prefilledClient.phone : state.client_phone, + address: isUpdate ? prefilledClient.address : state.address, measurement_id: measurementId || undefined, }; @@ -562,57 +347,48 @@ const Measurements = (function () { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", + initDataUnsafe: tg?.initDataUnsafe || null, 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(); - }); + btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер"; + return; } + haptic && haptic("success"); + result.innerHTML = ` +
+
${ICONS.check}
+
+
${isUpdate ? "Заявка закрыта" : "Замер сохранён"}
+
${photos.length} фото · ID #${(data.id || "").slice(0, 6)}
+
+
+
+ + +
+ `; + 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 = isUpdate ? "Закрыть заявку" : "Сохранить замер"; } - 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, """); + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function escAttr(s) { return escHtml(s); } diff --git a/miniapp/index.html b/miniapp/index.html index a1cac8a..d470c44 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + + @@ -34,13 +34,13 @@
- - - - - - - - + + + + + + + +