zov-tech/miniapp/assets/clients.js
wasrusgen 8201fee9f2 client card D1: хронология + файлы + быстрые действия
Карточка клиента (#/clients/client/<key>) переработана:

1. Шапка с большой круглой кнопкой 📞 справа — звонок одним тапом
   через tel: ссылку.

2. Быстрые действия (3 в ряд):
   - 🤖 Подбор техники — переход в #/podbor с pre-fill ФИО/телефона
   - 📐 Заказать замер — переход в #/request с pre-fill (через sessionStorage)
   - 📋 Копировать ФИО+тел — clipboard для пересылки коллеге

3. Примечание менеджера (с голосовым вводом) — уже было.

4. 🕒 Хронология — единая лента событий:
   - Лиды (подборы) с датой создания и статусом
   - Заявки на замер
   - Назначенные замеры (на дату scheduled_at)
   - Завершённые замеры
   События отсортированы по дате desc, рядом крупная точка timeline,
   тап → переход к детали.

5. 📂 Файлы — группы по замерам с миниатюрами фото (до 6 в ряд +
   плашка «+N» если больше). Тап на миниатюру открывает в новом
   окне (фото открывается в полный размер).

6. Свёрнутые секции «Подборы» и «Замеры» внизу — для тех кому
   нужны плоские списки.

Cache bust v=20260513za.
2026-05-13 18:39:25 +03:00

794 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
Клиенты — список + история подборов
============================================================ */
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(`<div class="loader-inline"><div class="spinner"></div></div>`);
root.appendChild(loading);
let data;
try {
data = await fetchClients();
clientsCache = data;
} catch (e) {
loading.remove();
root.appendChild(el(`<div class="error">Не удалось загрузить: ${e.message}</div>`));
return;
}
loading.remove();
if (!data.clients || !data.clients.length) {
root.appendChild(el(`
<div class="empty">
<p class="lede" style="text-align:center;padding:40px 20px;color:var(--muted)">
У тебя пока нет подборов с клиентами.<br>
Сделай первый — в кабинете «Подбор техники».
</p>
</div>
`));
return;
}
const meta = el(`
<div class="kicker" style="margin-bottom:8px;">
${data.count} ${pluralize(data.count, "клиент", "клиента", "клиентов")} · ${countLeads(data.clients)} ${pluralize(countLeads(data.clients), "подбор", "подбора", "подборов")}
</div>
`);
root.appendChild(meta);
const list = el(`<div class="client-list"></div>`);
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(`
<article class="client-card">
<div class="client-card-head">
<div class="client-avatar">${initial(c.client_name)}</div>
<div class="client-meta">
<div class="client-name">${escHtml(c.client_name || "Без имени")}</div>
${c.client_phone ? `<div class="client-phone">${escHtml(c.client_phone)}</div>` : ""}
</div>
<div class="client-arrow">${ICONS.chevron || ""}</div>
</div>
<div class="client-footer">
<span class="leads-count">${c.leads_count} ${pluralize(c.leads_count, "подбор", "подбора", "подборов")}</span>
<span class="muted">${lastAt}</span>
</div>
</article>
`);
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(`<div class="error">${e.message}</div>`));
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(`<div class="empty">Клиент не найден</div>`));
return;
}
// Шапка
const phoneNorm = (client.client_phone || "").replace(/[^\d+]/g, "");
const callHref = phoneNorm ? `tel:${phoneNorm}` : "";
root.appendChild(el(`
<div class="client-detail-head">
<div class="client-avatar lg">${initial(client.client_name)}</div>
<div style="flex:1;min-width:0;">
<h2 class="client-detail-name">${escHtml(client.client_name)}</h2>
${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""}
</div>
${callHref ? `<a class="client-call-btn" href="${callHref}" aria-label="Позвонить">📞</a>` : ""}
</div>
`));
// Быстрые действия для менеджера
const actionsRow = el(`
<div class="client-quick-actions">
<button class="qa-btn" data-act="podbor">🤖<span>Подбор техники</span></button>
<button class="qa-btn" data-act="measure">📐<span>Заказать замер</span></button>
<button class="qa-btn" data-act="copy">📋<span>Копировать ФИО+тел</span></button>
</div>
`);
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 === "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(`<div id="clTimelinePlaceholder"></div>`);
const filesPlaceholder = el(`<div id="clFilesPlaceholder"></div>`);
const detailsPlaceholder = el(`<div id="clDetailsPlaceholder"></div>`);
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));
}
/* ===================== Хронология ===================== */
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;
// Создание заявки / замера
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(`
<section class="block client-timeline-block">
<div class="block-head">🕒 Хронология · ${events.length}</div>
${events.length === 0
? `<div class="empty" style="padding:14px;text-align:center;color:var(--muted);font-size:13px;">Пока нет событий</div>`
: `<div class="timeline">${events.map(ev => `
<a class="tl-item" ${ev.href ? `href="${ev.href}"` : ""}>
<div class="tl-dot"></div>
<div class="tl-content">
<div class="tl-date">${formatDate(ev.ts)}</div>
<div class="tl-title"><span class="tl-icon">${ev.icon}</span>${ev.title}</div>
${ev.sub ? `<div class="tl-sub">${ev.sub}</div>` : ""}
</div>
</a>
`).join("")}</div>`}
</section>
`);
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(`
<section class="block client-files-block">
<div class="block-head">📂 Файлы · ${totalPhotos}</div>
${groups.length === 0
? `<div class="empty" style="padding:14px;text-align:center;color:var(--muted);font-size:13px;">Файлов нет. Появятся после замера и подбора.</div>`
: groups.map(g => `
<div class="file-group">
<div class="file-group-head">
<span>${g.title}</span>
<span class="muted" style="font-size:11px;">${g.sub}</span>
</div>
<div class="file-thumbs" data-mid="${g.measurement_id}">
${g.photos.slice(0, 6).map((fn, i) => `
<a class="file-thumb" href="${BACKEND_URL}/api/photo/${g.measurement_id}/${fn}" target="_blank" rel="noopener">
<img src="${BACKEND_URL}/api/photo/${g.measurement_id}/${fn}" alt="">
</a>
`).join("")}
${g.photos.length > 6 ? `<a class="file-thumb more" href="#/clients/measurement/${g.measurement_id}">+${g.photos.length - 6}</a>` : ""}
</div>
</div>
`).join("")}
</section>
`);
return section;
}
/* ===================== Детальные списки (свёрнутые) ===================== */
function renderClientDetails(client, measurements) {
const wrap = el(`<div class="client-details"></div>`);
// Подборы
if ((client.leads || []).length) {
const detailsLeads = el(`
<details class="client-details-collapse">
<summary>Подборы · ${client.leads_count}</summary>
<div class="leads-list"></div>
</details>
`);
const list = detailsLeads.querySelector(".leads-list");
for (const lead of client.leads) {
const item = el(`
<button class="lead-item">
<div class="lead-date">${formatDate(lead.created_at)}</div>
<div class="lead-id">#${(lead.id || "").slice(0, 8)}</div>
<div class="lead-status status-${lead.status || "new"}">${statusLabel(lead.status)}</div>
<div class="lead-arrow">${ICONS.chevron || ""}</div>
</button>
`);
item.addEventListener("click", () => {
haptic && haptic("impact");
location.hash = `#/clients/lead/${lead.id}`;
});
list.appendChild(item);
}
wrap.appendChild(detailsLeads);
}
// Замеры
if (measurements.length) {
const detailsMs = el(`
<details class="client-details-collapse">
<summary>Замеры · ${measurements.length}</summary>
<div class="leads-list"></div>
</details>
`);
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(`
<button class="lead-item">
<div class="lead-date">${formatDate(m.created_at)}</div>
<div class="lead-id">${escHtml(layoutLabel(m.layout) || (m.status || ""))}</div>
<div class="lead-status">${m.area_m2 ? m.area_m2 + " м²" : "—"}${photoBadge}</div>
<div class="lead-arrow">${ICONS.chevron || ""}</div>
</button>
`);
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(`<div class="loader-inline"><div class="spinner"></div></div>`);
root.appendChild(loading);
let lead;
try {
lead = await fetchLead(leadId);
} catch (e) {
loading.remove();
root.appendChild(el(`<div class="error">${e.message}</div>`));
return;
}
loading.remove();
if (lead.error) {
root.appendChild(el(`<div class="error">${lead.error}</div>`));
return;
}
// Шапка
root.appendChild(el(`
<div class="lead-detail-head">
<div class="kicker">Подбор #${(lead.id || "").slice(0, 8)}</div>
<h2 class="display-title">${escHtml(lead.client_name || "Клиент")}</h2>
<p class="lede">Сохранён ${formatDate(lead.created_at)}</p>
</div>
`));
// Рендерим отчёт через 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(`
<div class="block">
<div class="block-head">AI ответ</div>
<pre class="ai-text-fallback">${escHtml(lead.ai_text)}</pre>
</div>
`));
} else {
root.appendChild(el(`<div class="empty">Для этого лида нет AI-ответа.</div>`));
}
}
/* ===================== Деталь замера ===================== */
async function renderMeasurement(measurementId) {
root.innerHTML = "";
root.appendChild(headerEl("Замер", "back"));
const loading = el(`<div class="loader-inline"><div class="spinner"></div></div>`);
root.appendChild(loading);
let m;
try {
m = await fetchMeasurementDetail(measurementId);
} catch (e) {
loading.remove();
root.appendChild(el(`<div class="error">${e.message}</div>`));
return;
}
loading.remove();
if (m.error) {
root.appendChild(el(`<div class="error">${m.error}</div>`));
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(`
<div class="measurement-detail-head">
<div class="kicker">Замер #${(m.id || "").slice(0, 8)}</div>
<h2 class="display-title">${escHtml(layoutLabel(m.layout))}</h2>
<div class="measurement-detail-meta">
<span>📅 ${formatDate(m.created_at)}</span>
${m.area_m2 ? `<span>📐 ${escHtml(m.area_m2)} м²</span>` : ""}
${m.ceiling_mm ? `<span>📏 потолок ${escHtml(m.ceiling_mm)} мм</span>` : ""}
</div>
</div>
`));
const printBtn = el(`<button class="report-print-btn">🖨️ Скачать PDF / Печать</button>`);
printBtn.addEventListener("click", () => window.print());
root.appendChild(printBtn);
// Основной блок
const detail = el(`
<div class="block summary-block">
<div class="measurement-kv-grid">
${wallsText ? `<div class="k">Стены</div><div class="v">${escHtml(wallsText)}</div>` : ""}
${openings.window ? `<div class="k">Окно</div><div class="v">${escHtml(openings.window)}</div>` : ""}
${openings.door ? `<div class="k">Дверь</div><div class="v">${escHtml(openings.door)}</div>` : ""}
${m.notes ? `<div class="k">Заметки</div><div class="v">${escHtml(m.notes).replace(/\n/g, "<br>")}</div>` : ""}
</div>
</div>
`);
root.appendChild(detail);
// Фото
const photos = (m.photos || []).filter(Boolean);
if (photos.length) {
root.appendChild(el(`<div class="section-head" style="margin-top:18px;"><span class="label">Фото · ${photos.length}</span></div>`));
const list = el(`<div class="photo-list"></div>`);
for (const fn of photos) {
const url = `${BACKEND_URL}/api/photo/${m.id}/${fn}`;
const tile = el(`
<a class="photo-tile static" href="${url}" target="_blank" rel="noopener">
<img src="${url}" alt="">
</a>
`);
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();
}
/* ===================== Примечание по клиенту ===================== */
function renderClientNoteBlock(client) {
const section = el(`
<section class="block client-note-block">
<div class="block-head">
<span>📝 Примечание</span>
<span class="note-meta" id="noteMeta"></span>
</div>
<div class="note-editor">
<textarea id="noteText" rows="3" placeholder="Заметки по клиенту — характер, предпочтения, договорённости, статус..."></textarea>
<div class="note-actions">
<button class="btn-mic" id="noteMic" type="button" title="Голосовой ввод">🎤 Диктовать</button>
<button class="btn-secondary" id="noteSave" type="button">Сохранить</button>
</div>
<div class="note-status" id="noteStatus"></div>
</div>
</section>
`);
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(`
<header class="podbor-header">
<button class="podbor-back" aria-label="Назад">${ICONS.arrow_left || ""}</button>
<div class="podbor-title">${escHtml(title)}</div>
<div style="width:28px"></div>
</header>
`);
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 };
})();