/* ============================================================
Клиенты — список + история подборов
============================================================ */
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.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();
}
}
/* ===================== Список клиентов ===================== */
async function renderList() {
root.innerHTML = "";
root.appendChild(headerEl("Клиенты", null));
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)}
${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;
}
root.appendChild(el(`
${initial(client.client_name)}
${escHtml(client.client_name)}
${client.client_phone ? `
${escHtml(client.client_phone)}
` : ""}
`));
root.appendChild(el(`Подборы · ${client.leads_count}
`));
const leadsList = el(``);
for (const lead of client.leads) {
const item = el(`
`);
item.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/clients/lead/${lead.id}`;
});
leadsList.appendChild(item);
}
root.appendChild(leadsList);
// Замеры этого клиента (если есть)
try {
const ms = await fetchMeasurements({ client_tg_id: client.client_tg_id || "" });
const myMeasurements = (ms.measurements || []).filter(m => {
// Если client_tg_id зарегистрирован — фильтруем по нему
if (client.client_tg_id) return String(m.client_tg_id) === String(client.client_tg_id);
// Иначе — ищем имя клиента в notes (упрощённая логика для новых клиентов)
return (m.notes || "").toLowerCase().includes((client.client_name || "").toLowerCase());
});
if (myMeasurements.length) {
root.appendChild(el(`Замеры · ${myMeasurements.length}
`));
const mList = el(``);
for (const m of myMeasurements) {
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}`;
});
mList.appendChild(item);
}
root.appendChild(mList);
}
} catch (e) {
// Игнорируем — секция замеров просто не покажется
}
}
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);
}
}
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();
}
/* ===================== 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 = "";
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 };
})();