/* ============================================================
Клиенты — список + история подборов
============================================================ */
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)}
${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
? ``
: "";
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));
// Подбор техники — только ссылка, без инлайн-загрузки всего списка
const clientKeyProp = encodeURIComponent((client.client_tg_id || client.client_name || "").toLowerCase());
const propWrapper = el(`
`);
proposalPlaceholder.replaceWith(propWrapper);
// (управление перенесено наверх — сразу под шапку)
}
/* ===================== Управление карточкой (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(`
`);
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
? `Пока нет событий
`
: ``}
`);
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 => `
`).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(`
`);
h.querySelector(".podbor-back").addEventListener("click", () => {
if (backHref === "back") {
history.back();
} else if (backHref) {
location.hash = backHref;
} else {
// Возврат в главное меню — без перезагрузки страницы (иначе сплэш мигает)
location.hash = "";
if (typeof routeByHash === "function") routeByHash();
}
});
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 };
})();