/* ============================================================ Клиенты — список + история подборов ============================================================ */ const Clients = (function () { let root = null; let clientsCache = null; /* ===================== Mount ===================== */ function mount(container) { root = container; document.body.classList.remove("has-bottom-nav"); const oldNav = document.getElementById("bottom-nav"); if (oldNav) oldNav.remove(); const sub = location.hash.replace(/^#\/clients\/?/, ""); if (sub === "new" || sub.startsWith("new")) { renderNewClient(); } else if (sub.startsWith("lead/")) { const leadId = sub.slice(5); renderLead(leadId); } else if (sub.startsWith("measurement/")) { const measurementId = sub.slice(12); renderMeasurement(measurementId); } else if (/^client\/[^/]+\/proposals/.test(sub)) { // #/clients/client/{key}/proposals — менеджерский редактор подборки const clientKey = decodeURIComponent(sub.slice(7, sub.indexOf("/proposals"))); renderClientProposalsPage(clientKey); } else if (sub.startsWith("client/")) { const clientKey = decodeURIComponent(sub.slice(7)); renderClientHistory(clientKey); } else { renderList(); } } /* ===================== Заведение нового клиента ===================== */ function renderNewClient() { root.innerHTML = ""; root.appendChild(headerEl("Новый клиент", "#/clients")); const form = el(`

Заводим
клиента

Карточка клиента появится в списке. Замер и подбор техники можно заказать позже из его карточки.

Адрес *
`); root.appendChild(form); // Авто-нормализация телефона при потере фокуса const phoneInput = form.querySelector("#ph"); phoneInput.addEventListener("blur", () => { const normalized = normalizePhone(phoneInput.value); if (normalized.ok) phoneInput.value = normalized.value; }); // Голосовой ввод setupVoiceMicForField( form.querySelector("#newMic"), form.querySelector("#nt"), form.querySelector("#newMicStatus"), ); form.querySelector("#saveBtn").addEventListener("click", async () => { const btn = form.querySelector("#saveBtn"); const cta = form.querySelector("#saveCta"); const result = form.querySelector("#result"); ["errName", "errPhone", "errAddr"].forEach(id => { const e = form.querySelector("#" + id); if (e) e.textContent = ""; }); const name = (form.querySelector("#fn").value || "").trim(); const phoneRaw = (form.querySelector("#ph").value || "").trim(); const adCity = (form.querySelector("#ad_city").value || "").trim(); const adStreet = (form.querySelector("#ad_street").value || "").trim(); const adHouse = (form.querySelector("#ad_house").value || "").trim(); const adApt = (form.querySelector("#ad_apt").value || "").trim(); const adEntrance = (form.querySelector("#ad_entrance").value|| "").trim(); const adFloor = (form.querySelector("#ad_floor").value || "").trim(); const note = (form.querySelector("#nt").value || "").trim(); const contract_no = (form.querySelector("#cn").value || "").trim(); const contract_date = (form.querySelector("#cd").value || "").trim(); // Собираем адрес из полей const address = [ adCity, adStreet, adHouse ? "д. " + adHouse : "", adApt ? "кв. " + adApt : "", adEntrance ? "подъезд " + adEntrance : "", adFloor ? "этаж " + adFloor : "", ].filter(Boolean).join(", "); // Валидация if (!name || name.length < 2) { form.querySelector("#errName").textContent = "Имя обязательно (минимум 2 символа)"; return; } const norm = normalizePhone(phoneRaw); if (!norm.ok) { form.querySelector("#errPhone").textContent = "Введите корректный российский номер (+7XXXXXXXXXX или 8XXXXXXXXXX)"; return; } if (!adCity || !adStreet || !adHouse) { form.querySelector("#errAddr").textContent = "Укажите город, улицу и номер дома"; return; } btn.disabled = true; btn.textContent = "Проверяем адрес…"; // Геокодирование — проверяем адрес, продолжаем даже при неудаче let gps_lat = null, gps_lng = null; const geoEl = form.querySelector("#geoStatus"); try { const geoRes = await fetch(`${BACKEND_URL}/api/geocode`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, address: `${adCity}, ${adStreet}, д. ${adHouse}`, city: adCity, }), }); const geoData = await geoRes.json(); if (geoData.ok && geoData.result) { const kind = (geoData.result.kind || "").toLowerCase(); const precise = ["house", "street", "entrance", "building"].includes(kind); if (precise) { gps_lat = geoData.result.lat; gps_lng = geoData.result.lng; geoEl.innerHTML = `✓ ${escHtml(geoData.result.formatted || address)}`; } else { geoEl.innerHTML = `⚠ Улица не найдена — геокодер вернул «${escHtml(geoData.result.formatted || "")}». Проверьте написание улицы. Сохраняем без координат.`; } } else { geoEl.innerHTML = `⚠ Адрес не найден в геокодере — проверьте написание. Сохраняем без координат.`; } } catch (_) { geoEl.innerHTML = `⚠ Геокодер недоступен. Сохраняем без координат.`; } btn.textContent = "Сохраняем..."; try { const res = await fetch(`${BACKEND_URL}/api/client_create`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, full_name: name, phone: norm.value, address, note, contract_no, contract_date, gps_lat, gps_lng, }), }); const data = await res.json(); if (data.error) { const fieldErr = data.field ? form.querySelector("#err" + data.field[0].toUpperCase() + data.field.slice(1)) : null; if (fieldErr) fieldErr.textContent = data.msg || data.error; else result.innerHTML = `
Ошибка: ${escHtml(data.msg || data.error)}
`; btn.disabled = false; btn.textContent = "Завести клиента"; return; } haptic && haptic("success"); // Прячем CTA с «Сохраняем...» и показываем success + кнопки cta.style.display = "none"; result.innerHTML = `
${ICONS.check}
Клиент #${data.client_no || "—"} заведён
${escHtml(name)} · ${escHtml(norm.value)}
`; const ckey = data.client_key || name.toLowerCase(); clientsCache = null; // сброс кэша // ВАЖНО: обработчики ищем В RESULT, не в form (где их нет) result.querySelector("#another")?.addEventListener("click", () => renderNewClient()); result.querySelector("#openCard")?.addEventListener("click", () => { location.hash = `#/clients/client/${encodeURIComponent(ckey)}`; }); } catch (e) { result.innerHTML = `
Сеть: ${escHtml(e.message)}
`; btn.disabled = false; btn.textContent = "Завести клиента"; } }); } // Разбирает сохранённый адрес «Город, Улица, д. NN, кв. MM, подъезд P, этаж F» обратно в поля. function splitAddress(combined) { if (!combined) return { city: "Санкт-Петербург", street: "", house: "", apt: "", entrance: "", floor: "" }; let s = combined.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 }; } function normalizePhone(raw) { if (!raw) return { ok: false, value: "" }; const digits = String(raw).replace(/\D/g, ""); let normalized = digits; if (normalized.length === 11 && normalized.startsWith("8")) { normalized = "7" + normalized.slice(1); } if (normalized.length === 10) normalized = "7" + normalized; if (normalized.length !== 11 || !normalized.startsWith("7")) { return { ok: false, value: raw }; } return { ok: true, value: "+" + normalized }; } // Единая фабрика голосового ввода. // continuous=false + авто-рестарт по фразам — исключает дубли, стабильно на Android/iOS. function _buildVoiceEngine(micBtn, textarea, opts) { // opts: { statusEl, statusClass, onChange } const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) { micBtn.disabled = true; micBtn.title = "Браузер не поддерживает голос"; micBtn.style.opacity = "0.5"; if (opts.statusEl) opts.statusEl.textContent = "недоступно"; return; } let active = false; // пользователь включил микрофон let baseText = ""; // подтверждённый текст (растёт по фразам) let curRec = null; function _setStatus(txt, cls) { if (!opts.statusEl) return; opts.statusEl.textContent = txt; if (opts.statusClass && cls) opts.statusEl.className = opts.statusClass + (cls !== "ok" ? " " + cls : ""); } function startPhrase() { let rec; try { rec = new SR(); rec.lang = "ru-RU"; rec.continuous = false; // одна фраза — один сеанс, нет накопленных results rec.interimResults = true; } catch (e) { _setStatus("Микрофон недоступен", "err"); active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; return; } curRec = rec; rec.onresult = (ev) => { // Только результаты ЭТОЙ фразы — ev.results всегда свежий (continuous=false) 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 = () => { // Зафиксировать текущий текст как base и запустить следующую фразу (если active) baseText = textarea.value.trim(); if (active) { startPhrase(); } else { micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; _setStatus("", "ok"); if (opts.onChange) opts.onChange(textarea.value || ""); haptic && haptic("impact"); } }; rec.onerror = (ev) => { if (ev.error === "no-speech") return; // тишина — onend сработает, авто-перезапуск _setStatus("Ошибка: " + (ev.error || ""), "err"); active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; }; try { rec.start(); } catch (e) { _setStatus("Не запустить: " + e.message, "err"); active = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; } } micBtn.addEventListener("click", () => { if (active) { active = false; curRec?.stop(); // onend → видит active=false → сбросит кнопку return; } active = true; baseText = (textarea.value || "").trim(); micBtn.classList.add("rec"); micBtn.textContent = "⏹ Стоп"; _setStatus("Слушаю...", "ok"); haptic && haptic("impact"); startPhrase(); }); } function setupVoiceMicForField(micBtn, textarea, statusEl) { _buildVoiceEngine(micBtn, textarea, { statusEl }); } /* ===================== Подборка техники — отдельная страница менеджера ===================== */ async function renderClientProposalsPage(clientKey) { root.innerHTML = ""; const backHref = `#/clients/client/${encodeURIComponent(clientKey)}`; root.appendChild(headerEl("Подбор техники", backHref)); // Ищем клиента в кеше, чтобы знать client_tg_id let clientTgId = ""; let clientName = clientKey; const cached = clientsCache?.clients; if (cached) { const found = cached.find(c => (c.client_tg_id && c.client_tg_id === clientKey) || (c.client_name && c.client_name.toLowerCase() === clientKey) ); if (found) { clientTgId = found.client_tg_id || ""; clientName = found.client_name || clientKey; } } root.appendChild(el(`
${(clientName[0] || "?").toUpperCase()}

${escHtml(clientName.length > 3 ? clientName : clientKey)}

Подборка техники · редактор
`)); const container = el(`
`); root.appendChild(container); if (typeof Proposals !== "undefined") { await Proposals.mountManager(container, clientKey, clientTgId); } else { container.innerHTML = `
Модуль подбора не загружен
`; } } /* ===================== Список клиентов ===================== */ async function renderList() { root.innerHTML = ""; root.appendChild(headerEl("Клиенты", null)); // Поиск (рендерится сразу, до загрузки) const searchWrap = el(`
`); root.appendChild(searchWrap); const searchInput = searchWrap.querySelector(".client-search"); // FAB «Новый клиент» — плавающая кнопка, всегда видна поверх списка const fab = el(``); fab.addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/clients/new"; }); root.appendChild(fab); const loading = el(`
`); root.appendChild(loading); let data; try { data = await fetchClients(); clientsCache = data; } catch (e) { loading.remove(); root.appendChild(el(`
Не удалось загрузить: ${e.message}
`)); return; } loading.remove(); // API вернул ошибку — показываем её явно (вместо пустого списка) if (data.error) { root.appendChild(el(`
Ошибка загрузки клиентов: ${escHtml(data.error)} ${data.error === "invalid_init_data" ? "
Попробуйте перезапустить бот или открыть приложение заново." : ""}
`)); return; } if (!data.clients || !data.clients.length) { root.appendChild(el(`

Пока нет клиентов.
Нажмите  чтобы завести первого.

`)); return; } const metaEl = el(`
`); root.appendChild(metaEl); const list = el(`
`); root.appendChild(list); function renderFiltered(q) { q = (q || "").trim().toLowerCase(); const qDigits = q.replace(/\D/g, ""); const filtered = q ? data.clients.filter(c => { const nameMatch = (c.client_name || "").toLowerCase().includes(q); const phoneMatch = qDigits && (c.client_phone || "").replace(/\D/g, "").includes(qDigits); const contractMatch = (c.contract_no || "").toLowerCase().includes(q); return nameMatch || phoneMatch || contractMatch; }) : data.clients; const n = filtered.length; const total = data.clients.length; metaEl.textContent = q ? `Найдено: ${n} из ${total}` : `${total} ${pluralize(total, "клиент", "клиента", "клиентов")} · ${countLeads(data.clients)} ${pluralize(countLeads(data.clients), "подбор", "подбора", "подборов")}`; list.innerHTML = ""; if (!filtered.length) { list.innerHTML = `
Ничего не найдено
`; return; } for (const c of filtered) { list.appendChild(renderClientCard(c)); } } searchInput.addEventListener("input", () => renderFiltered(searchInput.value)); renderFiltered(""); } function renderClientCard(c) { const lastAt = formatDate(c.last_lead_at); const card = el(`
${initial(c.client_name)}
${escHtml(c.client_name || "Без имени")}
${c.client_phone ? `
${escHtml(c.client_phone)}
` : ""}
${ICONS.chevron || "›"}
`); card.addEventListener("click", () => { haptic && haptic("impact"); const key = c.client_tg_id || c.client_name.toLowerCase(); location.hash = `#/clients/client/${encodeURIComponent(key)}`; }); return card; } /* ===================== История клиента ===================== */ async function renderClientHistory(clientKey) { root.innerHTML = ""; root.appendChild(headerEl("История подборов", "#/clients")); // Берём из кеша если есть let clients = clientsCache?.clients; if (!clients) { try { const data = await fetchClients(); clients = data.clients; clientsCache = data; } catch (e) { root.appendChild(el(`
${e.message}
`)); return; } } const client = clients.find(c => (c.client_tg_id && c.client_tg_id === clientKey) || (c.client_name && c.client_name.toLowerCase() === clientKey) ); if (!client) { root.appendChild(el(`
Клиент не найден
`)); return; } // Шапка const phoneNorm = (client.client_phone || "").replace(/[^\d+]/g, ""); const callHref = phoneNorm ? `tel:${phoneNorm}` : ""; const noTag = client.client_no ? `#${escHtml(client.client_no)}` : ""; const contractTag = client.contract_no ? `
📋 договор ${escHtml(client.contract_no)}${client.contract_date ? ` · ${escHtml(client.contract_date)}` : ""}
` : ""; const mapUrl = (client.gps_lat && client.gps_lng) ? `https://yandex.ru/maps/?ll=${client.gps_lng},${client.gps_lat}&z=17&pt=${client.gps_lng},${client.gps_lat},pm2rdm` : ""; const addressTag = client.address ? `
📍 ${escHtml(client.address)}${mapUrl ? `🗺 Карта` : ""}
` : ""; const statusTag = client.in_work ? "" : `
● ещё не в работе
`; root.appendChild(el(`
${initial(client.client_name)}

${escHtml(client.client_name)} ${noTag}

${client.client_phone ? `
${escHtml(client.client_phone)}
` : ""} ${addressTag} ${contractTag} ${statusTag}
${callHref ? `📞` : ""}
`)); // Управление карточкой — кнопки прямо под шапкой root.appendChild(renderClientManagement(client)); // Быстрые действия для менеджера — кастомные SVG-иконки в орехе const QA_ICON_PODBOR = ` `; const QA_ICON_RULER = ` `; const QA_ICON_WRENCH = ` `; const QA_ICON_COPY = ` `; const actionsRow = el(`
`); actionsRow.querySelectorAll(".qa-btn").forEach(btn => { btn.addEventListener("click", () => { haptic && haptic("impact"); const act = btn.dataset.act; if (act === "podbor") { const propKey = encodeURIComponent(client.client_tg_id || client.client_name.toLowerCase()); location.hash = `#/clients/client/${propKey}/proposals`; } else if (act === "measure") { // Pre-fill request with client info sessionStorage.setItem("prefillClient", JSON.stringify({ name: client.client_name, phone: client.client_phone, })); location.hash = "#/request"; } else if (act === "assembly") { // Pre-fill assembly with client info + address из последнего замера sessionStorage.setItem("prefillAssembly", JSON.stringify({ name: client.client_name, phone: client.client_phone, address: (myMeasurements[0] && myMeasurements[0].address) || "", measurement_id: (myMeasurements[0] && myMeasurements[0].id) || "", })); location.hash = "#/assembly/new"; } else if (act === "copy") { const txt = `${client.client_name || ""} ${client.client_phone || ""}`.trim(); (navigator.clipboard?.writeText(txt) || Promise.resolve()) .then(() => tg?.showAlert?.("Скопировано")); } }); }); root.appendChild(actionsRow); // Примечание менеджера с голосовым вводом root.appendChild(renderClientNoteBlock(client)); // Хронология + Файлы — собираются после загрузки замеров const timelinePlaceholder = el(`
`); const filesPlaceholder = el(`
`); const detailsPlaceholder = el(`
`); const proposalPlaceholder = el(`
`); root.appendChild(timelinePlaceholder); root.appendChild(filesPlaceholder); root.appendChild(detailsPlaceholder); root.appendChild(proposalPlaceholder); let myMeasurements = []; try { const ms = await fetchMeasurements({ client_tg_id: client.client_tg_id || "" }); myMeasurements = (ms.measurements || []).filter(m => { if (client.client_tg_id) return String(m.client_tg_id) === String(client.client_tg_id); return (m.notes || "").toLowerCase().includes((client.client_name || "").toLowerCase()); }); } catch (e) { /* пусто */ } // Хронология timelinePlaceholder.replaceWith(renderClientTimeline(client, myMeasurements)); // Файлы filesPlaceholder.replaceWith(renderClientFiles(client, myMeasurements)); // Детальные списки внизу (свёрнуты) detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements)); // Подбор техники (Proposals) — секция для менеджера if (typeof Proposals !== "undefined") { const clientKey = (client.client_tg_id || client.client_name || "").toLowerCase(); const propWrapper = el(`
🛍 Подбор техники Открыть →
`); proposalPlaceholder.replaceWith(propWrapper); const propContainer = propWrapper.querySelector("#propInlineContainer"); Proposals.mountManager(propContainer, clientKey, client.client_tg_id || "") .catch(() => { propContainer.innerHTML = `
Не удалось загрузить подборку.
`; }); } else { proposalPlaceholder.remove(); } // (управление перенесено наверх — сразу под шапку) } /* ===================== Управление карточкой (edit / delete) ===================== */ // Кастомные SVG-иконки в брендовом монолинейном стиле (stroke-width 1.7) const ICON_EDIT_SVG = ` `; const ICON_TRASH_SVG = ` `; function renderClientManagement(client) { const inWork = !!client.in_work; const wrap = el(`
${inWork ? "" : ` `}
${inWork ? "В работе — данные можно править" : "Не в работе — можно править или удалить"}
`); wrap.querySelector("#editClient")?.addEventListener("click", () => { haptic && haptic("impact"); renderEditClient(client); }); wrap.querySelector("#deleteClient")?.addEventListener("click", async () => { const confirmed = await confirmDialog(`Удалить клиента ${client.client_name}? Это нельзя отменить из бота.`); if (!confirmed) return; const btn = wrap.querySelector("#deleteClient"); const labelEl = btn.querySelector(".ct-label"); const result = wrap.querySelector("#manageResult"); btn.disabled = true; if (labelEl) labelEl.textContent = "Удаляем…"; try { const res = await fetch(`${BACKEND_URL}/api/client_delete`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, client_key: (client.client_name || "").toLowerCase(), }), }); const data = await res.json(); if (data.error) { const msg = data.msg || data.error; result.innerHTML = `${escHtml(msg)}`; btn.disabled = false; if (labelEl) labelEl.textContent = "Удалить"; return; } haptic && haptic("success"); clientsCache = null; result.innerHTML = `Архивировано ${data.archived} записей. Возвращаемся в список…`; setTimeout(() => { location.hash = "#/clients"; window.location.reload(); }, 1200); } catch (e) { result.innerHTML = `Сеть: ${escHtml(e.message)}`; btn.disabled = false; if (labelEl) labelEl.textContent = "Удалить"; } }); return wrap; } /* ===================== Форма редактирования клиента ===================== */ function renderEditClient(client) { root.innerHTML = ""; root.appendChild(headerEl("Редактировать клиента", "#/clients")); const addrParts = splitAddress(client.address || ""); const form = el(`

Редактируем
клиента

Изменения применятся ко всем заявкам и замерам этого клиента.

Адрес
`); root.appendChild(form); form.querySelector("#ed_cancel").addEventListener("click", () => { const key = client.client_tg_id || (client.client_name || "").toLowerCase(); location.hash = `#/clients/client/${encodeURIComponent(key)}`; }); form.querySelector("#ed_save").addEventListener("click", async () => { const fn = form.querySelector("#ed_fn").value.trim(); const ph = form.querySelector("#ed_ph").value.trim(); const edCity = (form.querySelector("#ed_city").value || "").trim(); const edStreet = (form.querySelector("#ed_street").value || "").trim(); const edHouse = (form.querySelector("#ed_house").value || "").trim(); const edApt = (form.querySelector("#ed_apt").value || "").trim(); const edEntrance = (form.querySelector("#ed_entrance").value || "").trim(); const edFloor = (form.querySelector("#ed_floor").value || "").trim(); const cno = form.querySelector("#ed_cno").value.trim(); const cdate = form.querySelector("#ed_cdate").value.trim(); const errName = form.querySelector("#ed_errName"); const errPhone = form.querySelector("#ed_errPhone"); const errAddr = form.querySelector("#ed_errAddr"); const result = form.querySelector("#ed_result"); errName.textContent = ""; errPhone.textContent = ""; errAddr.textContent = ""; result.innerHTML = ""; if (!fn || fn.length < 2) { errName.textContent = "Имя слишком короткое"; return; } const norm = normalizePhone(ph); if (!norm.ok) { errPhone.textContent = "Телефон в формате +7XXXXXXXXXX"; return; } if (!edCity || !edStreet || !edHouse) { errAddr.textContent = "Укажите город, улицу и номер дома"; return; } const address = [ edCity, edStreet, edHouse ? "д. " + edHouse : "", edApt ? "кв. " + edApt : "", edEntrance ? "подъезд " + edEntrance : "", edFloor ? "этаж " + edFloor : "", ].filter(Boolean).join(", "); const btn = form.querySelector("#ed_save"); btn.disabled = true; btn.textContent = "Проверяем адрес…"; // Геокодирование — необязательно, продолжаем даже при неудаче let gps_lat = null, gps_lng = null; const geoEl = form.querySelector("#ed_geoStatus"); try { const geoRes = await fetch(`${BACKEND_URL}/api/geocode`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, address: `${edCity}, ${edStreet}, д. ${edHouse}`, city: edCity, }), }); const geoData = await geoRes.json(); if (geoData.ok && geoData.result) { const kind = (geoData.result.kind || "").toLowerCase(); const precise = ["house", "street", "entrance", "building"].includes(kind); if (precise) { gps_lat = geoData.result.lat; gps_lng = geoData.result.lng; geoEl.innerHTML = `✓ ${escHtml(geoData.result.formatted || address)}`; } else { geoEl.innerHTML = `⚠ Улица не найдена — геокодер вернул «${escHtml(geoData.result.formatted || "")}». Проверьте написание улицы. Сохраняем без координат.`; } } else { geoEl.innerHTML = `⚠ Адрес не найден — сохраняем без координат.`; } } catch (_) { geoEl.innerHTML = `⚠ Геокодер недоступен. Сохраняем без координат.`; } btn.textContent = "Сохраняем..."; try { const res = await fetch(`${BACKEND_URL}/api/client_update`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, client_key: (client.client_name || "").toLowerCase(), full_name: fn, phone: norm.value, address, contract_no: cno, contract_date: cdate, gps_lat, gps_lng, }), }); const data = await res.json(); if (data.error) { result.innerHTML = `${escHtml(data.msg || data.error)}`; btn.disabled = false; btn.textContent = "Сохранить"; return; } haptic && haptic("success"); clientsCache = null; const newKey = data.client_key || fn.toLowerCase(); result.innerHTML = `✓ обновлено ${data.updated} запис(ей). Открываем карточку...`; setTimeout(() => { location.hash = `#/clients/client/${encodeURIComponent(newKey)}`; window.location.reload(); }, 800); } catch (e) { result.innerHTML = `Сеть: ${escHtml(e.message)}`; btn.disabled = false; btn.textContent = "Сохранить"; } }); } function confirmDialog(msg) { return new Promise((resolve) => { if (window.Telegram?.WebApp?.showConfirm) { window.Telegram.WebApp.showConfirm(msg, (ok) => resolve(!!ok)); } else { resolve(window.confirm(msg)); } }); } /* ===================== Хронология ===================== */ function renderClientTimeline(client, measurements) { // Собираем события из лидов и замеров const events = []; for (const lead of client.leads || []) { events.push({ ts: lead.created_at, icon: "🤖", title: "Подбор техники", sub: `#${(lead.id || "").slice(0, 8)} · ${statusLabel(lead.status)}`, href: `#/clients/lead/${lead.id}`, }); } for (const m of measurements) { const photoCount = m.photo_count || (m.photos || []).length; // Скрываем draft-карточки из таймлайна — это пустая «техническая» строка, // которая создаётся при заведении клиента. В таймлайн попадают только реальные события. if (m.status === "draft") continue; // Создание заявки / замера events.push({ ts: m.created_at, icon: m.status === "requested" ? "📋" : "📐", title: m.status === "requested" ? "Заявка на замер" : "Замер создан", sub: m.address ? escHtml(m.address) : (m.status === "requested" ? "ожидает согласования" : ""), href: `#/clients/measurement/${m.id}`, }); // Если назначен — отдельное событие на момент scheduled_at if (m.scheduled_at) { events.push({ ts: m.scheduled_at, icon: "📅", title: "Замер назначен", sub: formatDate(m.scheduled_at) + (m.address ? " · " + escHtml(m.address) : ""), href: `#/clients/measurement/${m.id}`, }); } // Если завершён — отдельное событие if (m.status === "completed") { events.push({ ts: m.created_at, // нет updated_at, используем created icon: "✅", title: "Замер выполнен", sub: `${photoCount} фото` + (m.area_m2 ? ` · ${m.area_m2} м²` : ""), href: `#/clients/measurement/${m.id}`, }); } } events.sort((a, b) => (b.ts || "").localeCompare(a.ts || "")); const section = el(`
🕒 Хронология · ${events.length} ${events.length === 0 ? `
Пока нет событий
` : `
${events.map(ev => `
${formatDate(ev.ts)}
${ev.icon}${ev.title}
${ev.sub ? `
${ev.sub}
` : ""}
`).join("")}
`}
`); return section; } /* ===================== Файлы клиента ===================== */ function renderClientFiles(client, measurements) { const groups = []; for (const m of measurements) { const photos = m.photos || []; if (photos.length) { groups.push({ title: `📐 Замер от ${formatDate(m.created_at)}`, sub: `${photos.length} фото` + (m.area_m2 ? ` · ${m.area_m2} м²` : ""), photos, measurement_id: m.id, }); } } const totalPhotos = groups.reduce((s, g) => s + g.photos.length, 0); const section = el(`
📂 Файлы · ${totalPhotos}
${groups.length === 0 ? `
Файлов нет. Появятся после замера и подбора.
` : groups.map(g => `
${g.title} ${g.sub}
${g.photos.slice(0, 6).map((fn, i) => ` `).join("")} ${g.photos.length > 6 ? `+${g.photos.length - 6}` : ""}
`).join("")}
`); return section; } /* ===================== Детальные списки (свёрнутые) ===================== */ function renderClientDetails(client, measurements) { const wrap = el(`
`); // Подборы if ((client.leads || []).length) { const detailsLeads = el(`
Подборы · ${client.leads_count}
`); const list = detailsLeads.querySelector(".leads-list"); for (const lead of client.leads) { const item = el(` `); item.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/clients/lead/${lead.id}`; }); list.appendChild(item); } wrap.appendChild(detailsLeads); } // Замеры if (measurements.length) { const detailsMs = el(`
Замеры · ${measurements.length}
`); const list = detailsMs.querySelector(".leads-list"); for (const m of measurements) { const photoCount = m.photo_count || (m.photos || []).length; const photoBadge = photoCount ? ` · 📷 ${photoCount}` : ""; const item = el(` `); item.addEventListener("click", () => { haptic && haptic("impact"); location.hash = `#/clients/measurement/${m.id}`; }); list.appendChild(item); } wrap.appendChild(detailsMs); } return wrap; } function layoutLabel(key) { return ({ linear: "Прямая", l_shape: "Угловая Г", u_shape: "П-образная", island: "С островом", peninsula: "Полуостров", }[key]) || (key || "—"); } /* ===================== Детали лида (re-render отчёта) ===================== */ async function renderLead(leadId) { root.innerHTML = ""; root.appendChild(headerEl("Подбор", "back")); const loading = el(`
`); root.appendChild(loading); let lead; try { lead = await fetchLead(leadId); } catch (e) { loading.remove(); root.appendChild(el(`
${e.message}
`)); return; } loading.remove(); if (lead.error) { root.appendChild(el(`
${lead.error}
`)); return; } // Шапка root.appendChild(el(`
Подбор #${(lead.id || "").slice(0, 8)}

${escHtml(lead.client_name || "Клиент")}

Сохранён ${formatDate(lead.created_at)}

`)); // Рендерим отчёт через Podbor.renderReport если ai-json есть if (lead.ai && typeof window.Podbor?.renderSavedReport === "function") { const reportNode = window.Podbor.renderSavedReport(lead.ai, lead.id); root.appendChild(reportNode); } else if (lead.ai_text) { // Fallback — AI вернул plain text root.appendChild(el(`
AI ответ
${escHtml(lead.ai_text)}
`)); } else { root.appendChild(el(`
Для этого лида нет AI-ответа.
`)); } } /* ===================== Деталь замера ===================== */ async function renderMeasurement(measurementId) { root.innerHTML = ""; root.appendChild(headerEl("Замер", "back")); const loading = el(`
`); root.appendChild(loading); let m; try { m = await fetchMeasurementDetail(measurementId); } catch (e) { loading.remove(); root.appendChild(el(`
${e.message}
`)); return; } loading.remove(); if (m.error) { root.appendChild(el(`
${m.error}
`)); return; } const walls = m.walls || {}; const wallsText = Object.entries(walls) .filter(([_, v]) => v) .map(([k, v]) => `${k.replace("wall", "стена ")}: ${v} мм`) .join(" · "); const openings = m.openings || {}; // Статусные метки и цвета const STATUS_LABEL = { draft: "Карточка", requested: "Заявка", scheduled: "Назначен", completed: "Выполнен", cancelled: "Отменён", }; const STATUS_COLOR = { draft: "var(--muted,#998877)", requested: "#E67E22", scheduled: "#2980B9", completed: "#27AE60", cancelled: "#C0392B", }; const statusLabel = STATUS_LABEL[m.status] || m.status || "—"; const statusColor = STATUS_COLOR[m.status] || "var(--muted)"; // Шапка + кнопка печати/PDF root.appendChild(el(`
Замер #${(m.id || "").slice(0, 8)} ● ${escHtml(statusLabel)}

${escHtml(layoutLabel(m.layout))}

📅 ${formatDate(m.created_at)} ${m.scheduled_at ? `🗓 ${formatDate(m.scheduled_at)}` : ""} ${m.area_m2 ? `📐 ${escHtml(m.area_m2)} м²` : ""} ${m.ceiling_mm ? `📏 потолок ${escHtml(m.ceiling_mm)} мм` : ""} ${m.address ? `📍 ${escHtml(m.address)}` : ""}
`)); // Кнопки смены статуса (только для requested / scheduled) if (m.status === "requested" || m.status === "scheduled") { const statusRow = el(`
`); if (m.status === "requested") { const btnDone = el(``); btnDone.addEventListener("click", () => setMeasurementStatus(measurementId, "completed", statusRow)); statusRow.appendChild(btnDone); } const btnCancel = el(``); btnCancel.addEventListener("click", () => setMeasurementStatus(measurementId, "cancelled", statusRow)); statusRow.appendChild(btnCancel); root.appendChild(statusRow); } const printBtn = el(``); printBtn.addEventListener("click", () => window.print()); root.appendChild(printBtn); // Основной блок const detail = el(`
${wallsText ? `
Стены
${escHtml(wallsText)}
` : ""} ${openings.window ? `
Окно
${escHtml(openings.window)}
` : ""} ${openings.door ? `
Дверь
${escHtml(openings.door)}
` : ""} ${m.notes ? `
Заметки
${escHtml(m.notes).replace(/\n/g, "
")}
` : ""}
`); root.appendChild(detail); // Фото const photos = (m.photos || []).filter(Boolean); if (photos.length) { root.appendChild(el(`
Фото · ${photos.length}
`)); const list = el(`
`); for (const fn of photos) { const url = `${BACKEND_URL}/api/photo/${m.id}/${fn}`; const tile = el(` `); list.appendChild(tile); } root.appendChild(list); } // Фото: загрузка дополнительных фото root.appendChild(renderPhotoUploadBlock(m)); // Чертежи / DWG root.appendChild(renderDesignFilesBlock(m)); } /* ===================== Загрузка фото замера ===================== */ function renderPhotoUploadBlock(m) { const section = el(`
📷 Фото${m.photos && m.photos.length ? ` · уже ${m.photos.length} шт.` : ""}
`); const previewGrid = section.querySelector("#photoPreviewGrid"); const fileInput = section.querySelector("#photoFileInput"); const actionsRow = section.querySelector("#photoUploadActions"); const uploadBtn = section.querySelector("#photoUploadBtn"); const clearBtn = section.querySelector("#photoClearBtn"); const statusEl = section.querySelector("#photoUploadStatus"); let pendingFiles = []; fileInput.addEventListener("change", () => { pendingFiles = Array.from(fileInput.files || []); previewGrid.innerHTML = ""; if (!pendingFiles.length) { actionsRow.style.display = "none"; return; } actionsRow.style.display = ""; pendingFiles.forEach(f => { const url = URL.createObjectURL(f); const tile = el(`
`); previewGrid.appendChild(tile); }); }); clearBtn.addEventListener("click", () => { pendingFiles = []; fileInput.value = ""; previewGrid.innerHTML = ""; actionsRow.style.display = "none"; statusEl.textContent = ""; }); uploadBtn.addEventListener("click", async () => { if (!pendingFiles.length) return; uploadBtn.disabled = true; uploadBtn.textContent = `Загружаем (0/${pendingFiles.length})…`; statusEl.textContent = ""; const photos = []; for (let i = 0; i < pendingFiles.length; i++) { const f = pendingFiles[i]; const dataUrl = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(f); }); photos.push({ data_url: dataUrl, label: "extra" }); uploadBtn.textContent = `Загружаем (${i + 1}/${pendingFiles.length})…`; } try { const res = await fetch(`${BACKEND_URL}/api/measurement_add_photos`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, measurement_id: m.id, photos, }), }); const data = await res.json(); if (data.ok) { haptic && haptic("success"); statusEl.innerHTML = `✓ Загружено ${data.saved.length} фото. Всего: ${data.total}`; pendingFiles = []; fileInput.value = ""; previewGrid.innerHTML = ""; actionsRow.style.display = "none"; uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото"; // Обновляем заголовок блока section.querySelector(".block-head").textContent = `📷 Фото · всего ${data.total} шт.`; } else { statusEl.innerHTML = `Ошибка: ${escHtml(data.msg || data.error)}`; uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото"; } } catch (e) { statusEl.innerHTML = `Сеть: ${escHtml(e.message)}`; uploadBtn.disabled = false; uploadBtn.textContent = "Загрузить фото"; } }); return section; } /* ===================== Смена статуса замера ===================== */ async function setMeasurementStatus(measurementId, newStatus, container) { const confirmed = await confirmDialog( newStatus === "cancelled" ? "Отменить этот замер? Действие необратимо." : "Отметить замер выполненным?" ); if (!confirmed) return; container.innerHTML = `Сохраняем...`; try { const res = await fetch(`${BACKEND_URL}/api/measurement_set_status`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, measurement_id: measurementId, status: newStatus, }), }); const data = await res.json(); if (data.ok) { haptic && haptic("success"); container.innerHTML = `✓ Статус обновлён. Перезагружаем...`; setTimeout(() => window.location.reload(), 900); } else { container.innerHTML = `Ошибка: ${escHtml(data.msg || data.error)}`; } } catch (e) { container.innerHTML = `Сеть: ${escHtml(e.message)}`; } } /* ===================== Чертежи / DWG ===================== */ function renderDesignFilesBlock(measurement) { const section = el(`
📐 Чертёж / DWG
`); const list = section.querySelector("#designFilesList"); const input = section.querySelector("#designFilesInput"); const status = section.querySelector("#designUploadStatus"); function refreshList(files) { list.innerHTML = ""; const arr = (files || measurement.design_files || []).filter(Boolean); if (!arr.length) { list.innerHTML = `
Чертежей пока нет
`; return; } for (const fn of arr) { const url = `${BACKEND_URL}/api/photo/${measurement.id}/${fn}`; const ext = (fn.split(".").pop() || "").toLowerCase(); const icon = (ext === "dwg" || ext === "dxf") ? "📐" : (ext === "pdf") ? "📄" : "🖼️"; const item = el(` ${icon} ${escHtml(fn)} ${ext.toUpperCase()} `); list.appendChild(item); } } refreshList(); input.addEventListener("change", async (ev) => { const files = Array.from(ev.target.files || []); ev.target.value = ""; if (!files.length) return; status.textContent = `Загружаем ${files.length} файл(а/ов)…`; try { // Читаем по одному в base64 data URL const payload = []; for (const f of files) { if (f.size > 30 * 1024 * 1024) { status.textContent = `Файл ${f.name} больше 30 МБ — пропустили`; continue; } const dataUrl = await new Promise((resolve, reject) => { const r = new FileReader(); r.onerror = reject; r.onload = () => resolve(r.result); r.readAsDataURL(f); }); payload.push({ name: f.name, data_url: dataUrl }); if (payload.length >= 10) break; } if (!payload.length) { status.textContent = "Нет подходящих файлов"; return; } const res = await fetch(`${BACKEND_URL}/api/measurement_design_upload`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, measurement_id: measurement.id, files: payload, }), }); const data = await res.json(); if (data.error) { status.textContent = "Ошибка: " + data.error; return; } haptic && haptic("success"); measurement.design_files = data.design_files || []; refreshList(measurement.design_files); status.textContent = `✓ загружено ${payload.length}`; setTimeout(() => { status.textContent = ""; }, 3000); } catch (e) { status.textContent = "Сеть: " + e.message; } }); return section; } async function fetchMeasurementDetail(measurementId) { if (!BACKEND_URL) throw new Error("BACKEND_URL не задан"); const res = await fetch(`${BACKEND_URL}/api/measurement_detail`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", measurement_id: measurementId }), }); return await res.json(); } /* ===================== Примечание по клиенту ===================== */ function renderClientNoteBlock(client) { const section = el(`
📝 Примечания
Загружаем...
`); const editor = section.querySelector("#noteEditor"); const history = section.querySelector("#noteHistory"); const textarea = section.querySelector("#noteText"); const addBtn = section.querySelector("#noteAddBtn"); const status = section.querySelector("#noteStatus"); function renderFeed(notes) { history.innerHTML = ""; if (!notes || !notes.length) { history.innerHTML = `
Примечаний пока нет
`; return; } notes.forEach(n => { const entry = el(`

${escHtml(n.note)}

${n.updated_at ? `${escHtml(formatDate(n.updated_at))}` : ""}
`); history.appendChild(entry); }); } function openEditor() { textarea.value = ""; status.textContent = ""; status.className = "note-status"; editor.style.display = ""; addBtn.textContent = "Свернуть"; textarea.focus(); } function closeEditor() { editor.style.display = "none"; addBtn.textContent = "+ Добавить"; } // Загружаем историю fetchClientNote(client) .then(data => renderFeed(data?.notes || [])) .catch(() => renderFeed([])); addBtn.addEventListener("click", () => { if (editor.style.display === "none") openEditor(); else closeEditor(); }); section.querySelector("#noteCancel").addEventListener("click", closeEditor); section.querySelector("#noteSave").addEventListener("click", async () => { const txt = (textarea.value || "").trim(); if (!txt) { status.textContent = "Напишите заметку"; return; } const btn = section.querySelector("#noteSave"); btn.disabled = true; btn.textContent = "Сохраняем..."; status.textContent = ""; status.className = "note-status"; try { const data = await saveClientNote(client, txt); if (data?.ok) { haptic && haptic("success"); closeEditor(); renderFeed(data.notes || []); } else { status.textContent = "Ошибка: " + (data?.error || "не сохранилось"); status.className = "note-status err"; btn.disabled = false; btn.textContent = "Сохранить"; } } catch (e) { status.textContent = "Сеть: " + e.message; status.className = "note-status err"; btn.disabled = false; btn.textContent = "Сохранить"; } }); setupVoiceInput(section.querySelector("#noteMic"), textarea, status); return section; } async function fetchClientNote(client) { const res = await fetch(`${BACKEND_URL}/api/client_note`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, client_name: client.client_name || "", client_phone: client.client_phone || "", }), }); return await res.json(); } async function saveClientNote(client, note) { const res = await fetch(`${BACKEND_URL}/api/client_note`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, client_name: client.client_name || "", client_phone: client.client_phone || "", note: note || "", }), }); return await res.json(); } function setupVoiceInput(micBtn, textarea, status) { _buildVoiceEngine(micBtn, textarea, { statusEl: status, statusClass: "note-status", }); } /* ===================== Helpers ===================== */ function headerEl(title, backHref) { const h = el(`
${escHtml(title)}
`); h.querySelector(".podbor-back").addEventListener("click", () => { if (backHref === "back") { history.back(); } else if (backHref) { location.hash = backHref; } else { location.hash = ""; location.reload(); } }); return h; } async function fetchClients() { if (!BACKEND_URL) throw new Error("BACKEND_URL не задан"); const res = await fetch(`${BACKEND_URL}/api/clients`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null, }), }); return await res.json(); } async function fetchLead(leadId) { if (!BACKEND_URL) throw new Error("BACKEND_URL не задан"); const res = await fetch(`${BACKEND_URL}/api/lead`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", lead_id: leadId }), }); return await res.json(); } async function fetchMeasurements(filters = {}) { if (!BACKEND_URL) throw new Error("BACKEND_URL не задан"); const res = await fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify({ initData: tg?.initData || "", ...filters }), }); return await res.json(); } function initial(name) { return ((name || "?").trim()[0] || "?").toUpperCase(); } function escHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function formatDate(iso) { if (!iso) return "—"; try { const d = new Date(iso); const now = new Date(); const sameDay = d.toDateString() === now.toDateString(); const dd = String(d.getDate()).padStart(2, "0"); const mm = String(d.getMonth() + 1).padStart(2, "0"); const yy = d.getFullYear(); const hh = String(d.getHours()).padStart(2, "0"); const mi = String(d.getMinutes()).padStart(2, "0"); if (sameDay) return `сегодня · ${hh}:${mi}`; return `${dd}.${mm}.${yy}`; } catch (e) { return iso.slice(0, 10); } } function pluralize(n, one, few, many) { const last = n % 10, lastTwo = n % 100; if (lastTwo >= 11 && lastTwo <= 14) return many; if (last === 1) return one; if (last >= 2 && last <= 4) return few; return many; } function countLeads(clients) { return clients.reduce((s, c) => s + (c.leads_count || 0), 0); } function statusLabel(s) { const map = { "new": "Новый", "sent": "Отправлен", "viewed": "Просмотрен", "ordered": "Оформлен", }; return map[s] || s || "—"; } return { mount }; })();