/* ============================================================ Клиенты — список + история подборов ============================================================ */ 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 (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 address = (form.querySelector("#ad").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(); // Валидация на клиенте 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 (address && address.length < 5) { form.querySelector("#errAddr").textContent = "Адрес слишком короткий — нужны улица + дом"; return; } btn.disabled = true; 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, }), }); 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 = "Завести клиента"; } }); } 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 }; } function setupVoiceMicForField(micBtn, textarea, statusEl) { 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 rec = null, recording = false; let baseText = ""; // текст до начала записи let confirmedFinal = ""; // финальные части накопленные в этой сессии записи micBtn.addEventListener("click", () => { if (recording) { rec?.stop(); return; } try { rec = new SR(); rec.lang = "ru-RU"; rec.continuous = true; rec.interimResults = true; } catch (e) { if (statusEl) statusEl.textContent = "Микрофон недоступен"; return; } baseText = (textarea.value || "").trim(); confirmedFinal = ""; rec.onstart = () => { recording = true; micBtn.classList.add("rec"); micBtn.textContent = "⏹ Стоп"; if (statusEl) statusEl.textContent = "Слушаю..."; haptic && haptic("impact"); }; rec.onresult = (ev) => { // Пересчитываем ВСЕ финальные и interim с нуля каждый раз — гарантия от дублей let finalAll = ""; let interim = ""; for (let i = 0; i < ev.results.length; i++) { const t = ev.results[i][0].transcript; if (ev.results[i].isFinal) finalAll += t; else interim += t; } confirmedFinal = finalAll.trim(); const finalPart = confirmedFinal ? (baseText ? " " : "") + confirmedFinal : ""; const interimPart = interim.trim() ? ((baseText || confirmedFinal) ? " " : "") + interim.trim() : ""; textarea.value = baseText + finalPart + interimPart; }; rec.onerror = (ev) => { if (statusEl) statusEl.textContent = "Ошибка: " + (ev.error || ""); recording = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; }; rec.onend = () => { recording = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; // Фиксируем итоговый текст: baseText + final if (confirmedFinal) { baseText = (baseText + (baseText ? " " : "") + confirmedFinal).trim(); textarea.value = baseText; } if (statusEl && statusEl.textContent === "Слушаю...") statusEl.textContent = ""; haptic && haptic("impact"); }; try { rec.start(); } catch (e) { if (statusEl) statusEl.textContent = "Не запустить: " + e.message; } }); } /* ===================== Список клиентов ===================== */ async function renderList() { root.innerHTML = ""; root.appendChild(headerEl("Клиенты", null)); // Большая кнопка «Новый клиент» const addBtn = el(`
`); addBtn.querySelector("#addClientBtn").addEventListener("click", () => { haptic && haptic("impact"); location.hash = "#/clients/new"; }); root.appendChild(addBtn); 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(); if (!data.clients || !data.clients.length) { root.appendChild(el(`

Пока нет клиентов.
Заведите первого — кнопка выше.

`)); return; } const meta = el(`
${data.count} ${pluralize(data.count, "клиент", "клиента", "клиентов")} · ${countLeads(data.clients)} ${pluralize(countLeads(data.clients), "подбор", "подбора", "подборов")}
`); root.appendChild(meta); const list = el(`
`); for (const c of data.clients) { list.appendChild(renderClientCard(c)); } root.appendChild(list); } 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 addressTag = client.address ? `
📍 ${escHtml(client.address)}
` : ""; 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 ? `📞` : ""}
`)); // Быстрые действия для менеджера const actionsRow = el(`
`); actionsRow.querySelectorAll(".qa-btn").forEach(btn => { btn.addEventListener("click", () => { haptic && haptic("impact"); const act = btn.dataset.act; if (act === "podbor") { location.hash = `#/podbor?client_name=${encodeURIComponent(client.client_name || "")}&client_phone=${encodeURIComponent(client.client_phone || "")}`; } 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(`
`); root.appendChild(timelinePlaceholder); root.appendChild(filesPlaceholder); root.appendChild(detailsPlaceholder); 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)); // Управление карточкой клиента — редактировать + (условно) удалить root.appendChild(renderClientManagement(client)); } /* ===================== Управление карточкой (edit / delete) ===================== */ 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 result = wrap.querySelector("#manageResult"); btn.disabled = true; btn.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; btn.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; btn.textContent = "🗑 Удалить клиента"; } }); return wrap; } /* ===================== Форма редактирования клиента ===================== */ function renderEditClient(client) { root.innerHTML = ""; root.appendChild(headerEl("Редактировать клиента", "#/clients")); 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 addr = form.querySelector("#ed_addr").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 result = form.querySelector("#ed_result"); errName.textContent = ""; errPhone.textContent = ""; result.innerHTML = ""; if (!fn || fn.length < 2) { errName.textContent = "Имя слишком короткое"; return; } const norm = normalizePhone(ph); if (!norm.ok) { errPhone.textContent = "Телефон в формате +7XXXXXXXXXX"; return; } if (addr && addr.length < 5) { result.innerHTML = `Адрес слишком короткий`; return; } const btn = form.querySelector("#ed_save"); btn.disabled = true; 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: addr, contract_no: cno, contract_date: cdate, }), }); 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 || {}; // Шапка + кнопка печати/PDF root.appendChild(el(`
Замер #${(m.id || "").slice(0, 8)}

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

📅 ${formatDate(m.created_at)} ${m.area_m2 ? `📐 ${escHtml(m.area_m2)} м²` : ""} ${m.ceiling_mm ? `📏 потолок ${escHtml(m.ceiling_mm)} мм` : ""}
`)); 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); } // Чертежи / DWG root.appendChild(renderDesignFilesBlock(m)); } /* ===================== Чертежи / 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 textarea = section.querySelector("#noteText"); const meta = section.querySelector("#noteMeta"); const status = section.querySelector("#noteStatus"); // Загружаем сохранённую заметку fetchClientNote(client).then(data => { if (data?.note) textarea.value = data.note; if (data?.updated_at) { meta.textContent = "обновлено " + formatDate(data.updated_at); } }).catch(() => {}); // Сохранение section.querySelector("#noteSave").addEventListener("click", async () => { const btn = section.querySelector("#noteSave"); btn.disabled = true; btn.textContent = "Сохраняем..."; try { const data = await saveClientNote(client, textarea.value); if (data?.ok) { status.textContent = "✓ сохранено"; status.className = "note-status ok"; if (data.updated_at) meta.textContent = "обновлено " + formatDate(data.updated_at); setTimeout(() => { status.textContent = ""; }, 2500); } else { status.textContent = "Ошибка: " + (data?.error || "не сохранилось"); status.className = "note-status err"; } } catch (e) { status.textContent = "Сеть: " + e.message; status.className = "note-status err"; } btn.disabled = false; btn.textContent = "Сохранить"; }); // Голосовой ввод через Web Speech API const micBtn = section.querySelector("#noteMic"); setupVoiceInput(micBtn, 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) { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) { micBtn.disabled = true; micBtn.title = "Браузер не поддерживает голосовой ввод"; micBtn.style.opacity = "0.5"; return; } let rec = null; let recording = false; let baseText = ""; // текст до начала записи — чтобы не перетирать micBtn.addEventListener("click", () => { if (recording) { rec?.stop(); return; } try { rec = new SR(); rec.lang = "ru-RU"; rec.continuous = true; rec.interimResults = true; } catch (e) { status.textContent = "Микрофон недоступен: " + e.message; status.className = "note-status err"; return; } baseText = (textarea.value || "").trim(); const sep = baseText ? "\n" : ""; rec.onstart = () => { recording = true; micBtn.classList.add("rec"); micBtn.textContent = "⏹ Стоп"; status.textContent = "Слушаю..."; status.className = "note-status"; haptic && haptic("impact"); }; rec.onresult = (ev) => { let interim = ""; let final = ""; for (let i = ev.resultIndex; i < ev.results.length; i++) { const t = ev.results[i][0].transcript; if (ev.results[i].isFinal) final += t; else interim += t; } if (final) { baseText = (baseText + sep + final).trim(); textarea.value = baseText; } else if (interim) { textarea.value = baseText + sep + interim; } }; rec.onerror = (ev) => { status.textContent = "Ошибка распознавания: " + (ev.error || "неизвестно"); status.className = "note-status err"; recording = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; }; rec.onend = () => { recording = false; micBtn.classList.remove("rec"); micBtn.textContent = "🎤 Диктовать"; if (status.textContent === "Слушаю...") status.textContent = ""; haptic && haptic("impact"); }; try { rec.start(); } catch (e) { status.textContent = "Не запустить: " + e.message; status.className = "note-status err"; } }); } /* ===================== 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 || "" }), }); 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 }; })();