/* ============================================================ Замер — структурированная загрузка фото по типам. Типы фото: стена 1-4, план комнаты, общий вид, деталь. ============================================================ */ const Measurements = (function () { const STORAGE_KEY = "zov-measurement-draft-v2"; // Типы фото — в соответствии с чек-листом ЗАМЕРОВ const PHOTO_KINDS = [ { key: "wall1", label: "Стена 1" }, { key: "wall2", label: "Стена 2" }, { key: "wall3", label: "Стена 3" }, { key: "wall4", label: "Стена 4" }, { key: "plan", label: "План комнаты" }, { key: "general", label: "Общий вид" }, { key: "detail", label: "Деталь" }, ]; function kindLabel(k) { return (PHOTO_KINDS.find(p => p.key === k) || {}).label || k; } // Фото держим только в памяти let photos = []; // Array<{ dataUrl, kind }> let state = loadState(); let root = null; let measurementId = ""; // если задан — update-mode (закрытие заявки) let prefilledClient = null; let pickedClient = null; // клиент из пикера (только для нового замера) function loadState() { try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) return { ...defaultState(), ...JSON.parse(raw) }; } catch (e) {} return defaultState(); } function defaultState() { const todayStr = new Date().toISOString().slice(0, 10); return { client_name: "", client_phone: "", address: "", notes: "", // Общая инфа замера. zamer_no подгружается из бэка автоматически, // floor_base убран — он на самих фото с замером. zamer_no: "", zamer_date: todayStr, }; } function saveState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {} } function reset() { state = defaultState(); saveState(); photos = []; prefilledClient = null; pickedClient = null; } /* ===================== Mount + Render ===================== */ function mount(container) { root = container; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); photos = []; measurementId = ""; prefilledClient = null; pickedClient = null; const hashMatch = (location.hash.split("?")[1] || ""); const fragQp = new URLSearchParams(hashMatch); const mid = fragQp.get("id") || new URLSearchParams(location.search).get("measurement_id") || ""; // Спецроут #/measure/checklist — показать чек-лист if (location.hash.startsWith("#/measure/checklist")) { renderChecklist(); return; } if (mid) { measurementId = mid; loadRequestAndStart(); return; } render(); } async function loadRequestAndStart() { 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 || "", initDataUnsafe: tg?.initDataUnsafe || null, measurement_id: measurementId, }), }); const data = await res.json(); if (data.error) { root.innerHTML = ""; root.appendChild(renderHeader("Ошибка")); root.appendChild(el(`
${data.error}
`)); return; } prefilledClient = { name: data.client_name || "", phone: data.client_phone || "", address: data.address || "", }; render(); } catch (e) { root.innerHTML = ""; root.appendChild(renderHeader("Ошибка")); root.appendChild(el(`
Сеть: ${e.message}
`)); } } function render() { if (!root) return; root.innerHTML = ""; root.appendChild(renderHeader(measurementId ? "Закрыть заявку" : "Новый замер")); const screen = el(`
`); root.appendChild(screen); screen.appendChild(renderForm()); } function renderHeader(title) { const h = el(`
${escHtml(title)}
`); h.querySelector(".podbor-back").addEventListener("click", () => { location.hash = ""; location.reload(); }); h.querySelector("#openChecklist").addEventListener("click", () => { location.hash = "#/measure/checklist"; }); return h; } /* ===================== Главный экран ===================== */ function renderForm() { const isUpdate = !!measurementId && prefilledClient; const clientBlock = isUpdate ? renderClientReadOnly() : renderClientPicker(); const node = el(`

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

${isUpdate ? "Загружайте фото по чек-листу — каждая стена отдельно. Чертёж сделаем по фото." : "Заполните клиента, дату и загрузите фото по чек-листу. Откройте 📋 чтобы посмотреть как правильно снимать."}

📐 Общая информация
📷 Фото замера Чек-лист

Для каждого фото выберите тип. По чек-листу: каждая стена отдельно + план + общие виды.

`); node.querySelector("#clientBlock").appendChild(clientBlock); bindInputs(node); bindPhotoInput(node); node.querySelector("#openChecklist2").addEventListener("click", () => { location.hash = "#/measure/checklist"; }); node.querySelector("#submitBtn").addEventListener("click", () => onSubmit(node)); // Голосовой ввод заметок setupVoiceMic( node.querySelector("#zamerMic"), node.querySelector("#zamerNotes"), node.querySelector("#zamerMicStatus"), (text) => { state.notes = text; saveState(); }, ); // Подгружаем следующий № замера если поле пустое if (!state.zamer_no) { fetchNextZamerNo(node); } else { const hint = node.querySelector("#zamerNoHint"); if (hint) hint.textContent = "Можно переписать вручную"; } return node; } /* ===================== Голосовой ввод заметок ===================== */ // continuous=false + авто-рестарт по фразам — исключает дубли на Android/iOS Chrome function setupVoiceMic(micBtn, textarea, statusEl, onChange) { if (!micBtn || !textarea) return; const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) { micBtn.disabled = true; micBtn.title = "Браузер не поддерживает голосовой ввод"; micBtn.style.opacity = "0.5"; if (statusEl) statusEl.textContent = "недоступно в этом браузере"; return; } let active = false; let baseText = ""; let curRec = null; function startPhrase() { let rec; try { rec = new SR(); rec.lang = "ru-RU"; rec.continuous = false; rec.interimResults = true; } catch (e) { if (statusEl) statusEl.textContent = "Микрофон недоступен: " + e.message; active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; return; } curRec = rec; rec.onresult = (ev) => { let fin = "", itr = ""; for (let i = 0; i < ev.results.length; i++) { const t = ev.results[i][0].transcript.trim(); if (!t) continue; if (ev.results[i].isFinal) fin += (fin ? " " : "") + t; else itr += (itr ? " " : "") + t; } const shown = fin || itr; textarea.value = baseText + (baseText && shown ? " " : "") + shown; }; rec.onend = () => { baseText = textarea.value.trim(); if (active) { startPhrase(); } else { micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = ""; if (onChange) onChange(textarea.value || ""); haptic && haptic("impact"); } }; rec.onerror = (ev) => { if (ev.error === "no-speech") return; if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || "неизвестно"); active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; }; try { rec.start(); } catch (e) { if (statusEl) statusEl.textContent = "Не запустить: " + e.message; active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; } } micBtn.addEventListener("click", () => { if (active) { active = false; curRec?.stop(); return; } active = true; baseText = (textarea.value || "").trim(); micBtn.classList.add("rec"); micBtn.textContent = "⏹ Стоп"; if (statusEl) statusEl.textContent = "Слушаю..."; haptic && haptic("impact"); startPhrase(); }); } async function fetchNextZamerNo(node) { try { const res = await fetch(`${BACKEND_URL}/api/measurement_next_no`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, }), }); const data = await res.json(); const hint = node.querySelector("#zamerNoHint"); const input = node.querySelector("#zamerNoInput"); if (data.ok && data.next_no && input && !state.zamer_no) { input.value = String(data.next_no); state.zamer_no = String(data.next_no); saveState(); if (hint) hint.textContent = "Подобран автоматически — можно изменить"; } else if (hint) { hint.textContent = "Введите номер вручную"; } } catch (e) { const hint = node.querySelector("#zamerNoHint"); if (hint) hint.textContent = "Введите номер вручную"; } } function renderClientReadOnly() { return el(`
Клиент ${escHtml(prefilledClient.name || "—")}
${prefilledClient.phone ? `
Телефон ${escHtml(prefilledClient.phone)}
` : ""} ${prefilledClient.address ? `
Адрес ${escHtml(prefilledClient.address)}
` : ""}
`); } /* ===================== Пикер клиента ===================== */ function renderClientPicker() { // Разбивает строку адреса на поля (локальная копия splitAddress из clients.js) function _splitAddr(s) { if (!s) return { city: "Санкт-Петербург", street: "", house: "", apt: "", entrance: "", floor: "" }; s = s.trim(); const grab = (re) => { const m = s.match(re); if (m) { s = s.replace(m[0], ""); return m[1]; } return ""; }; const floor = grab(/,\s*этаж\s+([^\s,]+)/i); const entrance = grab(/,\s*подъезд\s+([^\s,]+)/i); const apt = grab(/,\s*кв\.?\s*([^\s,]+)/i); const house = grab(/,\s*д\.?\s*([^\s,]+)/i); s = s.replace(/,$/, "").trim(); const parts = s.split(",").map(p => p.trim()).filter(Boolean); let city = "", street = ""; if (parts.length >= 2) { city = parts[0]; street = parts.slice(1).join(", "); } else if (parts.length === 1) { city = parts[0]; } if (!city) city = "Санкт-Петербург"; return { city, street, house, apt, entrance, floor }; } const initParts = _splitAddr(state.address || ""); const wrap = el(`
Адрес объекта
`); const choiceRow = wrap.querySelector("#pcChoiceRow"); function readAndSaveAddr() { const city = (wrap.querySelector("#pcCity").value || "").trim(); const street = (wrap.querySelector("#pcStreet").value || "").trim(); const house = (wrap.querySelector("#pcHouse").value || "").trim(); const apt = (wrap.querySelector("#pcApt").value || "").trim(); const entrance = (wrap.querySelector("#pcEntrance").value || "").trim(); const floor = (wrap.querySelector("#pcFloor").value || "").trim(); state.address = [ city, street, house ? "д. " + house : "", apt ? "кв. " + apt : "", entrance ? "подъезд " + entrance : "", floor ? "этаж " + floor : "", ].filter(Boolean).join(", "); saveState(); } function fillAddrFields(address) { const p = _splitAddr(address || ""); wrap.querySelector("#pcCity").value = p.city; wrap.querySelector("#pcStreet").value = p.street; wrap.querySelector("#pcHouse").value = p.house; wrap.querySelector("#pcApt").value = p.apt; wrap.querySelector("#pcEntrance").value = p.entrance; wrap.querySelector("#pcFloor").value = p.floor; readAndSaveAddr(); } ["#pcCity","#pcStreet","#pcHouse","#pcApt","#pcEntrance","#pcFloor"].forEach(sel => { wrap.querySelector(sel).addEventListener("input", readAndSaveAddr); }); function refresh() { choiceRow.innerHTML = ""; if (pickedClient) { const card = el(`
${escHtml(pickedClient.client_name)}
${escHtml(pickedClient.client_phone || "")}${pickedClient.contract_no ? " · дог. " + escHtml(pickedClient.contract_no) : ""}
`); card.querySelector(".picker-change-btn").addEventListener("click", openOverlay); choiceRow.appendChild(card); if (!state.address && pickedClient.address) { fillAddrFields(pickedClient.address); } } else { const empty = el(`
`); empty.querySelector(".picker-open-btn").addEventListener("click", openOverlay); choiceRow.appendChild(empty); } } async function openOverlay() { const overlay = el(`
Выбор клиента
Загружаем список…
`); document.body.appendChild(overlay); requestAnimationFrame(() => overlay.classList.add("open")); const listEl = overlay.querySelector("#pcList"); const searchEl = overlay.querySelector(".picker-search"); function closeOverlay() { overlay.classList.remove("open"); setTimeout(() => overlay.remove(), 220); } overlay.querySelector(".picker-close-btn").addEventListener("click", closeOverlay); overlay.addEventListener("click", e => { if (e.target === overlay) closeOverlay(); }); let clients = []; try { const res = await fetch(`${BACKEND_URL}/api/clients`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, }), }); const data = await res.json(); clients = (data.clients || []).sort((a, b) => (a.client_name || "").localeCompare(b.client_name || "", "ru") ); } catch (e) { listEl.innerHTML = `
Ошибка загрузки: ${escHtml(e.message)}
`; return; } function renderList(q) { q = (q || "").toLowerCase().trim(); const filtered = q ? clients.filter(c => (c.client_name || "").toLowerCase().includes(q) || (c.client_phone || "").replace(/\D/g, "").includes(q.replace(/\D/g, "")) ) : clients; listEl.innerHTML = ""; if (!filtered.length) { listEl.innerHTML = `
${q ? "Ничего не найдено" : "Список клиентов пуст"}
`; return; } filtered.forEach(c => { const row = el(`
${escHtml(c.client_name || "—")}
${c.client_phone ? `${escHtml(c.client_phone)}` : ""} ${c.contract_no ? `дог. ${escHtml(c.contract_no)}` : ""}
`); row.addEventListener("click", () => { pickedClient = { client_name: c.client_name || "", client_phone: c.client_phone || "", address: c.address || "", contract_no: c.contract_no || "", client_no: c.client_no || "", }; closeOverlay(); refresh(); }); listEl.appendChild(row); }); } renderList(""); searchEl.focus(); searchEl.addEventListener("input", () => renderList(searchEl.value)); } refresh(); return wrap; } function bindInputs(node) { node.querySelectorAll("[data-bind]").forEach(inp => { inp.addEventListener("input", e => { state[e.target.dataset.bind] = e.target.value; saveState(); }); }); } function nextKindSuggestion() { // Авто-предложение: сначала Стена 1, потом 2,3,4, затем План, затем Общий const usedWalls = new Set(photos.filter(p => p.kind?.startsWith("wall")).map(p => p.kind)); for (let i = 1; i <= 4; i++) { if (!usedWalls.has(`wall${i}`)) return `wall${i}`; } const hasPlan = photos.some(p => p.kind === "plan"); if (!hasPlan) return "plan"; return "general"; } function bindPhotoInput(node) { const list = node.querySelector("#photoList"); const input = node.querySelector("#photoInput"); function refreshList() { list.innerHTML = ""; if (!photos.length) { list.innerHTML = `
Ещё нет фото
`; return; } photos.forEach((ph, idx) => { const tile = el(`
фото ${idx + 1}
`); tile.querySelector(".photo-rm").addEventListener("click", e => { const i = +e.currentTarget.dataset.idx; photos.splice(i, 1); haptic && haptic("impact"); refreshList(); }); tile.querySelector(".photo-kind").addEventListener("change", e => { const i = +e.target.dataset.idx; photos[i].kind = e.target.value; }); list.appendChild(tile); }); } input.addEventListener("change", async (e) => { const files = Array.from(e.target.files || []); input.value = ""; if (!files.length) return; for (const f of files) { if (photos.length >= 30) break; if (!f.type || !f.type.startsWith("image/")) continue; try { const dataUrl = await compressImage(f, 1800, 0.78); const kind = nextKindSuggestion(); photos.push({ dataUrl, kind }); } catch (err) { console.warn("Не удалось сжать фото", err); } } refreshList(); haptic && haptic("success"); }); refreshList(); } function compressImage(file, maxSide = 1800, quality = 0.78) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = reject; reader.onload = e => { const img = new Image(); img.onerror = reject; img.onload = () => { let { width, height } = img; if (width > maxSide || height > maxSide) { if (width >= height) { height = Math.round(height * maxSide / width); width = maxSide; } else { width = Math.round(width * maxSide / height); height = maxSide; } } const canvas = document.createElement("canvas"); canvas.width = width; canvas.height = height; 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; }; reader.readAsDataURL(file); }); } /* ===================== Чек-лист — отдельный экран ===================== */ // Состояние галочек хранится в localStorage по measurement_id (или draft) function checklistKey() { return `zov-checklist-${measurementId || "draft"}`; } function loadChecklistState() { try { return JSON.parse(localStorage.getItem(checklistKey()) || "{}"); } catch (e) { return {}; } } function saveChecklistState(s) { try { localStorage.setItem(checklistKey(), JSON.stringify(s)); } catch (e) {} } function resetChecklistDraft() { try { localStorage.removeItem(`zov-checklist-draft`); } catch (e) {} } async function renderChecklist() { root.innerHTML = ""; root.appendChild(el(`
Чек-лист замера
`)); root.querySelector(".podbor-back").addEventListener("click", () => { if (measurementId) location.hash = `#/measure?id=${measurementId}`; else location.hash = "#/measure"; }); const wrap = el(`
`); root.appendChild(wrap); wrap.appendChild(el(`
`)); try { const res = await fetch("./assets/zamer-checklist.md", { cache: "no-cache" }); const md = await res.text(); const clState = loadChecklistState(); wrap.innerHTML = `
${renderMarkdown(md, clState)}
`; bindChecklistInteractions(wrap, clState); updateChecklistProgress(wrap, clState); root.querySelector("#resetCl").addEventListener("click", () => { if (!confirm("Сбросить все галочки?")) return; const empty = {}; saveChecklistState(empty); renderChecklist(); }); } catch (e) { wrap.innerHTML = `
Не удалось загрузить чек-лист: ${e.message}
`; } } function bindChecklistInteractions(wrap, clState) { wrap.querySelectorAll(".cl-item").forEach(item => { item.addEventListener("click", () => { const key = item.dataset.key; if (!key) return; const isChecked = item.classList.contains("checked"); const checkSpan = item.querySelector(".cl-check"); if (isChecked) { item.classList.remove("checked"); if (checkSpan) checkSpan.textContent = "☐"; delete clState[key]; } else { item.classList.add("checked"); if (checkSpan) checkSpan.textContent = "☑"; clState[key] = true; } saveChecklistState(clState); updateChecklistProgress(wrap, clState); haptic && haptic("impact"); }); }); } function updateChecklistProgress(wrap, clState) { const total = wrap.querySelectorAll(".cl-item").length; const done = Object.keys(clState).filter(k => clState[k]).length; const pct = total ? Math.round((done / total) * 100) : 0; const bar = wrap.querySelector("#clProgress"); if (bar) { bar.innerHTML = `
${done} из ${total} · ${pct}%
`; } } /* Минимальный markdown → HTML: заголовки, списки, таблицы, code, чекбоксы */ function renderMarkdown(md, clState = {}) { const lines = md.split("\n"); const out = []; let inList = false; let inTable = false; let tableRows = []; function closeList() { if (inList) { out.push(""); inList = false; } } function closeTable() { if (!inTable) return; if (tableRows.length) { const html = [""]; tableRows.forEach((cells, i) => { const tag = i === 0 ? "th" : "td"; if (i === 1 && cells.every(c => /^[-:\s|]+$/.test(c))) return; // skip separator html.push(`${cells.map(c => `<${tag}>${inline(c)}`).join("")}`); }); html.push("
"); out.push(html.join("")); } tableRows = []; inTable = false; } function inline(s) { return escHtml(s) .replace(/`([^`]+)`/g, "$1") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); } for (const raw of lines) { const line = raw.trimEnd(); // Директива @pict:KEY — вставляем SVG-эскиз из ZAMER_PICTS const pictMatch = line.match(/^@pict:([a-z_]+)$/i); if (pictMatch) { closeList(); closeTable(); const key = pictMatch[1].toLowerCase(); const svg = (window.ZAMER_PICTS || {})[key]; if (svg) { out.push(`
${svg}
`); } continue; } // Таблица if (line.includes("|") && line.match(/^\s*\|/)) { if (!inTable) { closeList(); inTable = true; } const cells = line.split("|").slice(1, -1).map(s => s.trim()); tableRows.push(cells); continue; } else if (inTable) { closeTable(); } // Заголовки if (line.startsWith("# ")) { closeList(); out.push(`

${inline(line.slice(2))}

`); } else if (line.startsWith("## ")) { closeList(); out.push(`

${inline(line.slice(3))}

`); } else if (line.startsWith("### ")) { closeList(); out.push(`

${inline(line.slice(4))}

`); } else if (line.startsWith("- ") || line.startsWith("* ")) { if (!inList) { out.push("