measurements: упрощение — только фото + заметки (DWG потом)

По логике пользователя: задача замера в боте — только сфоткать
рукописные эскизы с размерами + общие фото помещения. Перевод
в DWG-чертёж происходит отдельным процессом (вручную технологом
или через AI vision позже).

Что убрано:
- Шаг «Форма кухни» (linear / l_shape / u_shape / island / peninsula)
- Шаг «Размеры стен + потолок + площадь»
- Шаг «Окна и двери»

Что осталось:
- Клиент (имя/телефон/адрес) — только для прямого ввода менеджером.
  При закрытии заявки замерщиком берётся из заявки read-only.
- Фото (до 20, со сжатием до 1800px на клиенте)
- Заметки опционально

Wizard стал одностраничным: всё на одном экране, единый submit.
В update-mode (#/measure?id=...) wizard сразу подгружает заявку
и не спрашивает данные клиента.

Backend схема не менялась — старые поля (layout, walls и т.п.)
просто остаются пустыми. Существующие замеры с заполненными полями
отрисовываются без изменений.

Cache bust v=20260513k.
This commit is contained in:
wasrusgen 2026-05-13 07:03:45 +03:00
parent 643acd29c5
commit 5c2a5bb335
2 changed files with 181 additions and 405 deletions

View File

@ -1,27 +1,18 @@
/* ============================================================ /* ============================================================
Замеры кухни wizard для менеджера Замер упрощённая версия: только фото + заметки.
DWG-чертёж потом делается отдельным процессом из фото.
============================================================ */ ============================================================ */
const Measurements = (function () { const Measurements = (function () {
const STORAGE_KEY = "zov-measurement-draft"; const STORAGE_KEY = "zov-measurement-draft";
const STEPS = ["client", "layout", "size", "openings", "photos", "summary"];
const STEP_LABELS = ["Клиент", "Форма", "Размеры", "Окна/двери", "Фото", "Готово"];
// Фото держим только в памяти (data-URL'ы тяжёлые, localStorage не годится) // Фото держим только в памяти — data-URL'ы тяжёлые
let photos = []; // Array<{ name: string, dataUrl: string, size: number }> let photos = []; // Array<{ name, dataUrl }>
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 state = loadState();
let root = null; let root = null;
let currentStep = "client"; let measurementId = ""; // если задан — update-mode (закрытие существующей заявки)
let measurementId = ""; // если задан — wizard работает в update-mode (закрывает заявку) let prefilledClient = null; // данные клиента из заявки в update-mode
function loadState() { function loadState() {
try { try {
@ -35,15 +26,7 @@ const Measurements = (function () {
return { return {
client_name: "", client_name: "",
client_phone: "", client_phone: "",
client_tg_id: "", address: "",
layout: "",
area_m2: "",
ceiling_mm: "",
walls: {}, // { wall1: 3200, wall2: 4100, ... } — мм
openings: {
window: "", // расположение окна
door: "", // расположение двери
},
notes: "", notes: "",
}; };
} }
@ -52,15 +35,11 @@ const Measurements = (function () {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
} }
function update(patch) {
state = { ...state, ...patch };
saveState();
}
function reset() { function reset() {
state = defaultState(); state = defaultState();
saveState(); saveState();
photos = []; photos = [];
prefilledClient = null;
} }
/* ===================== Mount + Render ===================== */ /* ===================== Mount + Render ===================== */
@ -70,12 +49,12 @@ const Measurements = (function () {
document.body.classList.remove("has-bottom-nav"); document.body.classList.remove("has-bottom-nav");
const oldNav = document.getElementById("bottom-nav"); const oldNav = document.getElementById("bottom-nav");
if (oldNav) oldNav.remove(); if (oldNav) oldNav.remove();
currentStep = "client";
photos = []; // на старте нового замера — чистый список
// Если URL содержит ?id=<measurement_id> или fragment #/measure?id=... — это update-mode: photos = [];
// wizard закрывает существующую заявку. Подтягиваем данные заявки и сразу прыгаем на layout.
measurementId = ""; measurementId = "";
prefilledClient = null;
// ?id=<measurement_id> → update-mode (замерщик закрывает заявку)
const hashMatch = (location.hash.split("?")[1] || ""); const hashMatch = (location.hash.split("?")[1] || "");
const fragQp = new URLSearchParams(hashMatch); const fragQp = new URLSearchParams(hashMatch);
const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || ""; const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || "";
@ -88,318 +67,162 @@ const Measurements = (function () {
} }
async function loadRequestAndStart() { async function loadRequestAndStart() {
// Загружаем существующую заявку и пред-заполняем client_name/phone/address
root.innerHTML = ""; root.innerHTML = "";
root.appendChild(renderHeader("Закрыть заявку"));
root.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`)); root.appendChild(el(`<div class="loader-inline"><div class="spinner"></div></div>`));
try { try {
const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, { const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, {
method: "POST", 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(); const data = await res.json();
if (data.error) { if (data.error) {
root.innerHTML = `<div class="error">${data.error}</div>`; root.innerHTML = "";
root.appendChild(renderHeader("Ошибка"));
root.appendChild(el(`<div class="error">${data.error}</div>`));
return; return;
} }
// Pre-fill state с данными из заявки prefilledClient = {
state = { name: data.client_name || "",
...defaultState(), phone: data.client_phone || "",
client_name: data.client_name || "", address: data.address || "",
client_phone: data.client_phone || "",
}; };
saveState(); // Не показываем форму клиента — она read-only
// Сразу прыгаем на форму (client уже заполнен) state.notes = state.notes || "";
currentStep = "layout";
render(); render();
} catch (e) { } catch (e) {
root.innerHTML = `<div class="error">Сеть: ${e.message}</div>`; root.innerHTML = "";
root.appendChild(renderHeader("Ошибка"));
root.appendChild(el(`<div class="error">Сеть: ${e.message}</div>`));
} }
} }
function go(step) {
if (!STEPS.includes(step)) return;
currentStep = step;
render();
window.scrollTo({ top: 0, behavior: "smooth" });
haptic && haptic("impact");
}
function render() { function render() {
if (!root) return; if (!root) return;
root.innerHTML = ""; root.innerHTML = "";
root.appendChild(renderHeader()); root.appendChild(renderHeader(measurementId ? "Закрыть заявку" : "Новый замер"));
root.appendChild(renderProgress());
const screen = el(`<div class="podbor-screen"></div>`); const screen = el(`<div class="podbor-screen"></div>`);
root.appendChild(screen); root.appendChild(screen);
screen.appendChild(renderForm());
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;
}
} }
function renderHeader() { function renderHeader(title) {
const h = el(` const h = el(`
<header class="podbor-header"> <header class="podbor-header">
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || ""}</button> <button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || ""}</button>
<div class="podbor-title">Новый замер</div> <div class="podbor-title">${escHtml(title)}</div>
<div style="width:28px"></div> <div style="width:28px"></div>
</header> </header>
`); `);
h.querySelector(".podbor-back").addEventListener("click", () => { h.querySelector(".podbor-back").addEventListener("click", () => {
const idx = STEPS.indexOf(currentStep); location.hash = "";
if (idx <= 0) { location.reload();
location.hash = "";
location.reload();
} else {
go(STEPS[idx - 1]);
}
}); });
return h; return h;
} }
function renderProgress() { /* ===================== Главный экран — всё на одной странице ===================== */
const idx = STEPS.indexOf(currentStep);
const pct = Math.round(((idx + 1) / STEPS.length) * 100); function renderForm() {
return el(` const isUpdate = !!measurementId && prefilledClient;
<div class="podbor-progress"> const clientBlock = isUpdate ? renderClientReadOnly() : renderClientInputs();
<div class="podbor-progress-bar"><div class="bar" style="width:${pct}%"></div></div>
<div class="podbor-progress-meta"> const node = el(`
<span>${STEP_LABELS[idx]}</span><span class="num">${idx + 1}/${STEPS.length}</span> <section class="podbor-step">
<h2 class="display-title">${isUpdate ? "Фото<br><span class=\"accent\">с замера</span>" : "Новый<br><span class=\"accent\">замер</span>"}</h2>
<p class="lede">${isUpdate
? "Загрузите фото от руки нарисованных замеров (а также общие фото помещения). Чертёж сделаем по ним отдельно."
: "Заполните данные клиента и загрузите фото замера. Можно фотать рукописные эскизы — главное чтобы были видны размеры."}
</p>
<div id="clientBlock"></div>
<div class="section-head" style="margin-top:18px;"><span class="label">📷 Фото замера</span></div>
<div class="photo-uploader">
<label class="photo-add-btn" for="photoInput">
<span class="photo-add-ico"></span>
<span class="photo-add-label">Добавить фото</span>
<span class="photo-add-hint">камера или галерея · до 20 шт</span>
</label>
<input id="photoInput" type="file" accept="image/*" capture="environment" multiple hidden>
</div> </div>
<div class="photo-list" id="photoList"></div>
<div class="form-row" style="margin-top:18px;">
<label class="field">
<span class="field-label">Заметки (опционально)</span>
<textarea data-bind="notes" rows="3" placeholder="что важно отметить — газ/электро, особые условия, размеры которые сложно прочесть на фото">${escHtml(state.notes || "")}</textarea>
</label>
</div>
<div class="podbor-cta-row" style="margin-top:20px;">
<button class="btn-primary" id="submitBtn">${isUpdate ? "Закрыть заявку" : "Сохранить замер"}</button>
</div>
<div id="submitResult" class="submit-result"></div>
</section>
`);
node.querySelector("#clientBlock").appendChild(clientBlock);
bindInputs(node);
bindPhotoInput(node);
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
return node;
}
function renderClientReadOnly() {
return el(`
<div class="block">
<div class="kv"><span>Клиент</span>&nbsp;<strong>${escHtml(prefilledClient.name || "")}</strong></div>
${prefilledClient.phone ? `<div class="kv"><span>Телефон</span>&nbsp;<strong>${escHtml(prefilledClient.phone)}</strong></div>` : ""}
${prefilledClient.address ? `<div class="kv"><span>Адрес</span>&nbsp;<strong>${escHtml(prefilledClient.address)}</strong></div>` : ""}
</div> </div>
`); `);
} }
/* ===================== Шаг 1: Клиент ===================== */ function renderClientInputs() {
const block = el(`
function renderClient() { <div>
const node = el(`
<section class="podbor-step">
<h2 class="display-title">Для какого<br><span class="accent">клиента?</span></h2>
<p class="lede">Имя и телефон клиента, для которого делается замер.</p>
<div class="form-row"> <div class="form-row">
<label class="field"> <label class="field">
<span class="field-label">Имя клиента</span> <span class="field-label">ФИО клиента *</span>
<input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Например: А. Пестова"> <input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Иванов Иван Иванович">
<span class="field-error" id="nameError"></span> <span class="field-error" id="nameError"></span>
</label> </label>
</div> </div>
<div class="form-row"> <div class="form-row">
<label class="field"> <label class="field">
<span class="field-label">Телефон</span> <span class="field-label">Телефон *</span>
<input type="tel" data-bind="client_phone" value="${escAttr(state.client_phone)}" placeholder="+7 900 123-45-67"> <input type="tel" data-bind="client_phone" value="${escAttr(state.client_phone)}" placeholder="+7 921 555-12-34">
<span class="field-hint">Можно без +7, нормализуем</span>
<span class="field-error" id="phoneError"></span> <span class="field-error" id="phoneError"></span>
</label> </label>
</div> </div>
<div class="podbor-cta-row">
<button class="btn-primary" id="next">Дальше</button>
</div>
</section>
`);
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 `
<button class="wiz-card${isOn ? " on" : ""}" data-val="${o.key}">
<div class="wiz-pict">${pict}</div>
<div class="wiz-label">${o.label}</div>
${o.hint ? `<div class="wiz-hint">${o.hint}</div>` : ""}
${isOn ? `<div class="wiz-tick">${ICONS.check}</div>` : ""}
</button>
`;
}).join("");
const node = el(`
<section class="podbor-step podbor-wizard">
<h3 class="wiz-title">Форма кухни</h3>
<p class="lede" style="margin:0 0 8px;">Как расположены гарнитуры?</p>
<div class="wiz-grid wiz-grid--cards">${cards}</div>
<div class="podbor-cta-row">
<button class="btn-secondary" id="back">Назад</button>
</div>
</section>
`);
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(`
<div class="form-row"> <div class="form-row">
<label class="field"> <label class="field">
<span class="field-label">${label}</span> <span class="field-label">Адрес</span>
<input type="number" inputmode="numeric" data-wall="wall${i}" value="${v}" placeholder="например 3200"> <input type="text" data-bind="address" value="${escAttr(state.address)}" placeholder="СПб, Просвещения 87, кв. 12">
</label> </label>
</div> </div>
`); </div>
}
const node = el(`
<section class="podbor-step">
<h2 class="display-title">Размеры<br><span class="accent">кухни</span></h2>
<p class="lede">Длины стен в миллиметрах + высота потолка.</p>
${wallInputs.join("")}
<div class="form-row two-col">
<label class="field">
<span class="field-label">Площадь, м²</span>
<input type="number" inputmode="decimal" step="0.1" data-bind="area_m2" value="${state.area_m2}" placeholder="12.5">
</label>
<label class="field">
<span class="field-label">Потолок, мм</span>
<input type="number" inputmode="numeric" data-bind="ceiling_mm" value="${state.ceiling_mm}" placeholder="2700">
</label>
</div>
<div class="podbor-cta-row">
<button class="btn-secondary" id="back">Назад</button>
<button class="btn-primary" id="next">Дальше</button>
</div>
</section>
`); `);
return block;
}
bindInputs(node); function bindInputs(node) {
// Wall inputs — пишем в state.walls node.querySelectorAll("[data-bind]").forEach(inp => {
node.querySelectorAll("[data-wall]").forEach(inp => {
inp.addEventListener("input", e => { inp.addEventListener("input", e => {
const w = { ...(state.walls || {}), [e.target.dataset.wall]: e.target.value }; state[e.target.dataset.bind] = e.target.value;
update({ walls: w }); saveState();
}); });
}); });
node.querySelector("#back").addEventListener("click", () => go("layout"));
node.querySelector("#next").addEventListener("click", () => go("openings"));
return node;
} }
/* ===================== Шаг 4: Окна и двери ===================== */ function bindPhotoInput(node) {
function renderOpenings() {
const o = state.openings || {};
const node = el(`
<section class="podbor-step">
<h2 class="display-title">Окна<br><span class="accent">и двери</span></h2>
<p class="lede">Опиши расположение где окно, откуда вход, есть ли коммуникации.</p>
<div class="form-row">
<label class="field">
<span class="field-label">Окно</span>
<textarea data-open="window" rows="2" placeholder="например: на стене 1, отступ 1200 от угла, ширина 1500">${escHtml(o.window || "")}</textarea>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Дверь / вход</span>
<textarea data-open="door" rows="2" placeholder="например: вход со стороны коридора, дверь на стене 3">${escHtml(o.door || "")}</textarea>
</label>
</div>
<div class="form-row">
<label class="field">
<span class="field-label">Заметки</span>
<textarea data-bind="notes" rows="3" placeholder="газ/электро, вентшахта, ниши под технику, особые пожелания">${escHtml(state.notes || "")}</textarea>
</label>
</div>
<div class="podbor-cta-row">
<button class="btn-secondary" id="back">Назад</button>
<button class="btn-primary" id="next">Дальше</button>
</div>
</section>
`);
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(`
<section class="podbor-step">
<h2 class="display-title">Фото<br><span class="accent">кухни</span></h2>
<p class="lede">Сними помещение со всех углов. Минимум: общий вид, окно/дверь, ниши и коммуникации.</p>
<div class="photo-uploader">
<label class="photo-add-btn" for="photoInput">
<span class="photo-add-ico"></span>
<span class="photo-add-label">Добавить фото</span>
<span class="photo-add-hint">камера или галерея · до 12 шт</span>
</label>
<input id="photoInput" type="file" accept="image/*" capture="environment" multiple hidden>
</div>
<div class="photo-list" id="photoList"></div>
<div class="podbor-cta-row">
<button class="btn-secondary" id="back">Назад</button>
<button class="btn-primary" id="next">Дальше</button>
</div>
</section>
`);
const list = node.querySelector("#photoList"); const list = node.querySelector("#photoList");
const input = node.querySelector("#photoInput"); const input = node.querySelector("#photoInput");
@ -424,15 +247,14 @@ const Measurements = (function () {
input.addEventListener("change", async (e) => { input.addEventListener("change", async (e) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
input.value = ""; // позволяем выбрать тот же файл снова input.value = "";
if (!files.length) return; if (!files.length) return;
for (const f of files) { for (const f of files) {
if (photos.length >= 12) break; if (photos.length >= 20) break;
if (!f.type || !f.type.startsWith("image/")) continue; if (!f.type || !f.type.startsWith("image/")) continue;
try { try {
const dataUrl = await compressImage(f, 1600, 0.78); const dataUrl = await compressImage(f, 1800, 0.78);
photos.push({ name: f.name || `photo_${photos.length + 1}`, dataUrl, size: dataUrl.length }); photos.push({ name: f.name || `photo_${photos.length + 1}`, dataUrl });
} catch (err) { } catch (err) {
console.warn("Не удалось сжать фото", err); console.warn("Не удалось сжать фото", err);
} }
@ -442,13 +264,10 @@ const Measurements = (function () {
}); });
refreshList(); refreshList();
node.querySelector("#back").addEventListener("click", () => go("openings"));
node.querySelector("#next").addEventListener("click", () => go("summary"));
return node;
} }
/* Жмём картинку через canvas, возвращаем data-URL jpeg ~75% */ /* Сжатие через canvas */
function compressImage(file, maxSide = 1600, quality = 0.78) { function compressImage(file, maxSide = 1800, quality = 0.78) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onerror = reject; reader.onerror = reject;
@ -469,11 +288,9 @@ const Measurements = (function () {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const ctx = canvas.getContext("2d"); canvas.getContext("2d").drawImage(img, 0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height); try { resolve(canvas.toDataURL("image/jpeg", quality)); }
try { catch (err) { reject(err); }
resolve(canvas.toDataURL("image/jpeg", quality));
} catch (err) { reject(err); }
}; };
img.src = e.target.result; img.src = e.target.result;
}; };
@ -481,49 +298,7 @@ const Measurements = (function () {
}); });
} }
/* ===================== Шаг 6: Готово + Submit ===================== */ /* ===================== 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(`
<section class="podbor-step">
<h2 class="display-title">Готово<br><span class="accent">к сохранению</span></h2>
<p class="lede">Проверьте и сохраните замер.</p>
<div class="block summary-block">
<div class="kv"><span>Клиент</span><strong>${escHtml(state.client_name)}</strong></div>
${state.client_phone ? `<div class="kv"><span>Телефон</span><strong>${escHtml(state.client_phone)}</strong></div>` : ""}
<div class="kv"><span>Форма</span><strong>${layout?.label || ""}</strong></div>
${wallsText ? `<div class="kv"><span>Стены</span><strong>${escHtml(wallsText)}</strong></div>` : ""}
${state.area_m2 ? `<div class="kv"><span>Площадь</span><strong>${escHtml(state.area_m2)} м²</strong></div>` : ""}
${state.ceiling_mm ? `<div class="kv"><span>Потолок</span><strong>${escHtml(state.ceiling_mm)} мм</strong></div>` : ""}
${(state.openings || {}).window ? `<div class="kv"><span>Окно</span><strong>${escHtml(state.openings.window)}</strong></div>` : ""}
${(state.openings || {}).door ? `<div class="kv"><span>Дверь</span><strong>${escHtml(state.openings.door)}</strong></div>` : ""}
${state.notes ? `<div class="kv"><span>Заметки</span><strong>${escHtml(state.notes)}</strong></div>` : ""}
${photos.length ? `<div class="kv"><span>Фото</span><strong>${photos.length} шт</strong></div>` : ""}
</div>
${photos.length ? `
<div class="photo-list">
${photos.map(p => `<div class="photo-tile static"><img src="${p.dataUrl}" alt=""></div>`).join("")}
</div>
` : ""}
<div class="podbor-cta-row">
<button class="btn-secondary" id="back">Назад</button>
<button class="btn-primary" id="submitBtn">Сохранить замер</button>
</div>
<div id="submitResult" class="submit-result"></div>
</section>
`);
node.querySelector("#back").addEventListener("click", () => go("photos"));
node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node));
return node;
}
async function onSubmit(node) { async function onSubmit(node) {
const btn = node.querySelector("#submitBtn"); const btn = node.querySelector("#submitBtn");
@ -532,28 +307,38 @@ const Measurements = (function () {
btn.innerHTML = '<span class="spinner-inline"></span> сохраняем...'; btn.innerHTML = '<span class="spinner-inline"></span> сохраняем...';
result.innerHTML = ""; result.innerHTML = "";
if (!BACKEND_URL) { // Валидация: для новой записи нужны клиент + телефон + хотя бы 1 фото
result.innerHTML = `<div class="error">BACKEND_URL не настроен.</div>`; const isUpdate = !!measurementId && prefilledClient;
btn.disabled = false; if (!isUpdate) {
btn.textContent = "Сохранить замер"; 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 = `<div class="error">Добавьте хотя бы одно фото замера.</div>`;
btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер";
return; return;
} }
const measurement = { 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), photos: photos.map(p => p.dataUrl),
notes: state.notes, notes: state.notes || "",
// Контакт клиента — заносим в заметки если он не зарегистрирован в системе client_name: isUpdate ? prefilledClient.name : state.client_name,
client_name: state.client_name, client_phone: isUpdate ? prefilledClient.phone : state.client_phone,
client_phone: state.client_phone, address: isUpdate ? prefilledClient.address : state.address,
// Если задан — backend обновит существующую заявку (update-mode)
measurement_id: measurementId || undefined, measurement_id: measurementId || undefined,
}; };
@ -562,57 +347,48 @@ const Measurements = (function () {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
initData: tg?.initData || "", initData: tg?.initData || "",
initDataUnsafe: tg?.initDataUnsafe || null,
measurement, measurement,
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (data.error) { if (data.error) {
result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`; result.innerHTML = `<div class="error">Ошибка: ${data.error}</div>`;
} else { btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер";
result.innerHTML = ` return;
<div class="success">
<div class="success-icon">${ICONS.check}</div>
<div>
<div class="success-title">Замер сохранён</div>
<div class="success-sub">ID #${(data.id || "").slice(0, 6)}</div>
</div>
</div>
<div class="podbor-cta-row" style="margin-top:16px;">
<button class="btn-secondary" id="newOne">Ещё замер</button>
<button class="btn-primary" id="toHome">На главную</button>
</div>
`;
haptic && haptic("success");
reset(); // сбрасываем форму для следующего замера
node.querySelector("#newOne")?.addEventListener("click", () => { mount(root); });
node.querySelector("#toHome")?.addEventListener("click", () => {
location.hash = "";
location.reload();
});
} }
haptic && haptic("success");
result.innerHTML = `
<div class="success">
<div class="success-icon">${ICONS.check}</div>
<div>
<div class="success-title">${isUpdate ? "Заявка закрыта" : "Замер сохранён"}</div>
<div class="success-sub">${photos.length} фото · ID #${(data.id || "").slice(0, 6)}</div>
</div>
</div>
<div class="podbor-cta-row" style="margin-top:16px;">
<button class="btn-secondary" id="newOne">Ещё замер</button>
<button class="btn-primary" id="toHome">На главную</button>
</div>
`;
reset();
node.querySelector("#newOne")?.addEventListener("click", () => mount(root));
node.querySelector("#toHome")?.addEventListener("click", () => {
location.hash = "";
location.reload();
});
} catch (e) { } catch (e) {
result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`; result.innerHTML = `<div class="error">Сеть: ${e.message}</div>`;
btn.disabled = false; btn.textContent = isUpdate ? "Закрыть заявку" : "Сохранить замер";
} }
btn.disabled = false;
btn.textContent = "Сохранить ещё раз";
} }
/* ===================== Helpers ===================== */ /* ===================== 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) { function escHtml(s) {
return String(s == null ? "" : s) return String(s == null ? "" : s)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/</g, "&lt;") .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }
function escAttr(s) { return escHtml(s); } function escAttr(s) { return escHtml(s); }

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260513j"> <link rel="stylesheet" href="assets/styles.css?v=20260513k">
<link rel="stylesheet" href="assets/podbor.css?v=20260513j"> <link rel="stylesheet" href="assets/podbor.css?v=20260513k">
</head> </head>
<body> <body>
<!-- Splash — за пределами #app, render-функции его не смывают --> <!-- Splash — за пределами #app, render-функции его не смывают -->
@ -34,13 +34,13 @@
</div> </div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260513j"></script> <script src="assets/icons.js?v=20260513k"></script>
<script src="assets/podbor.config.js?v=20260513j"></script> <script src="assets/podbor.config.js?v=20260513k"></script>
<script src="assets/podbor.picts.js?v=20260513j"></script> <script src="assets/podbor.picts.js?v=20260513k"></script>
<script src="assets/podbor.js?v=20260513j"></script> <script src="assets/podbor.js?v=20260513k"></script>
<script src="assets/clients.js?v=20260513j"></script> <script src="assets/clients.js?v=20260513k"></script>
<script src="assets/measurements.js?v=20260513j"></script> <script src="assets/measurements.js?v=20260513k"></script>
<script src="assets/request.js?v=20260513j"></script> <script src="assets/request.js?v=20260513k"></script>
<script src="assets/app.js?v=20260513j"></script> <script src="assets/app.js?v=20260513k"></script>
</body> </body>
</html> </html>