From c4f3016b56e81bfb50cf111b642a7c42fd35c6ec Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Tue, 12 May 2026 07:20:54 +0300 Subject: [PATCH] =?UTF-8?q?miniapp:=20client=20profile=20tab=20=E2=80=94?= =?UTF-8?q?=20list=20+=20history=20+=20lead=20detail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEW FILE assets/clients.js: - Clients.mount(container) — hash-routed view - #/clients — list of all clients (cards: avatar, name, phone, leads count, last date) - #/clients/client/ — single client history (all leads as items) - #/clients/lead/ — full lead detail with re-rendered report UI: - Card style: avatar with initial, name + phone, footer with N подборов + дата - Pluralization for Russian (1 подбор / 2 подбора / 5 подборов) - Date format: 'сегодня · 14:30' or 'DD.MM.YYYY' - Status pills: new / sent / viewed / ordered PODBOR.JS: - Exposed renderSavedReport(ai, leadId) for Clients module reuse - Same renderer as live podbor — same matrix, pros/cons, links APP.JS: - Quick action 'Клиенты' added (icon: user) - Hash router: #/clients → Clients.mount() INDEX.HTML: - clients.js script added - Cache bumped to v=20260512a CSS: - .client-list, .client-card with avatar+meta+footer - .client-detail-head (big card with avatar 56px) - .leads-list with .lead-item (grid: date | id | status | arrow) - .loader-inline for async fetch - .ai-text-fallback for legacy text-only responses --- miniapp/assets/app.js | 10 +- miniapp/assets/clients.js | 301 ++++++++++++++++++++++++++++++++++++++ miniapp/assets/podbor.css | 199 +++++++++++++++++++++++++ miniapp/assets/podbor.js | 9 +- miniapp/index.html | 15 +- 5 files changed, 524 insertions(+), 10 deletions(-) create mode 100644 miniapp/assets/clients.js diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index dbea0f9..d3a21fb 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -177,10 +177,10 @@ function renderManagerHome(me) { // Quick actions const quickActions = [ + { icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" }, + { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "camera", title: "Новый замер", subtitle: "С фото", href: null }, { icon: "cube", title: "3D просмотр", subtitle: "Проекты", href: null }, - { icon: "bolt", title: "Коммуникации", subtitle: "Чек-лист", href: null }, - { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); @@ -366,6 +366,10 @@ async function init() { Podbor.mount(app); return; } + if (location.hash.startsWith("#/clients")) { + Clients.mount(app); + return; + } if (me.role === "manager") renderManager(me); else renderClient(me); } catch (e) { @@ -377,6 +381,8 @@ async function init() { function routeByHash() { if (location.hash.startsWith("#/podbor")) { Podbor.mount(app); + } else if (location.hash.startsWith("#/clients")) { + Clients.mount(app); } else { // Главный экран по роли const me = window.__zovMe; diff --git a/miniapp/assets/clients.js b/miniapp/assets/clients.js new file mode 100644 index 0000000..9397126 --- /dev/null +++ b/miniapp/assets/clients.js @@ -0,0 +1,301 @@ +/* ============================================================ + Клиенты — список + история подборов + ============================================================ */ + +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("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)}
+
+
${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; + } + + 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); + } + + /* ===================== Детали лида (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-ответа.
`)); + } + } + + /* ===================== 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(); + } + + 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 }; +})(); diff --git a/miniapp/assets/podbor.css b/miniapp/assets/podbor.css index c4423d7..b3f1c30 100644 --- a/miniapp/assets/podbor.css +++ b/miniapp/assets/podbor.css @@ -1789,3 +1789,202 @@ margin-top: 8px; line-height: 1.4; } + +/* ============================================================ + Клиенты — список + история + ============================================================ */ + +.client-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.client-card { + background: #fff; + border: 1px solid var(--line); + border-radius: 14px; + padding: 14px; + cursor: pointer; + transition: all 0.12s; +} +.client-card:active { transform: scale(0.99); background: var(--warm); } + +.client-card-head { + display: flex; + align-items: center; + gap: 12px; +} + +.client-avatar { + width: 40px; + height: 40px; + border-radius: var(--r-pill); + background: var(--warm); + color: var(--accent-2); + display: grid; + place-items: center; + font-family: var(--font-display); + font-style: italic; + font-size: 18px; + font-weight: 600; + flex-shrink: 0; +} +.client-avatar.lg { + width: 56px; + height: 56px; + font-size: 24px; +} + +.client-meta { + flex: 1; + min-width: 0; +} +.client-name { + font-family: var(--font-sans); + font-size: 15px; + font-weight: 600; + color: var(--ink); + margin-bottom: 2px; + line-height: 1.2; +} +.client-phone { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.04em; + color: var(--muted); +} + +.client-arrow { + color: var(--muted); + font-size: 20px; + flex-shrink: 0; +} + +.client-footer { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.client-footer .leads-count { + color: var(--accent-2); + font-weight: 500; +} +.client-footer .muted { color: var(--muted); } + +/* Детальный экран клиента */ +.client-detail-head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 24px; + padding: 14px; + background: #fff; + border: 1px solid var(--line); + border-radius: 14px; +} +.client-detail-name { + font-family: var(--font-display); + font-style: italic; + font-size: 22px; + font-weight: 400; + color: var(--ink); + margin: 0 0 4px; +} +.client-detail-phone { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.04em; + color: var(--muted); +} + +.leads-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; +} + +.lead-item { + display: grid; + grid-template-columns: 1fr auto auto 20px; + gap: 10px; + align-items: center; + background: #fff; + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px 14px; + cursor: pointer; + text-align: left; +} +.lead-item:active { background: var(--warm); } + +.lead-date { + font-family: var(--font-sans); + font-size: 13.5px; + font-weight: 500; + color: var(--ink); +} +.lead-id { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--muted); +} +.lead-status { + font-family: var(--font-mono); + font-size: 9px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 3px 8px; + border-radius: var(--r-pill); + background: var(--warm); + color: var(--accent-2); +} +.lead-status.status-new { background: var(--warm); color: var(--accent-2); } +.lead-status.status-sent { background: rgba(42,107,63,0.12); color: #2A6B3F; } +.lead-arrow { + color: var(--muted); + font-size: 18px; +} + +/* Заглушка-loader */ +.loader-inline { + display: flex; + justify-content: center; + padding: 40px; +} +.loader-inline .spinner { + width: 26px; + height: 26px; + border: 2.5px solid var(--line); + border-top-color: var(--accent-2); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.lead-detail-head { + margin-bottom: 18px; +} + +.ai-text-fallback { + white-space: pre-wrap; + font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.5; + color: var(--ink-2); + background: var(--warm); + padding: 12px; + border-radius: 8px; + overflow-x: auto; +} diff --git a/miniapp/assets/podbor.js b/miniapp/assets/podbor.js index 6434bf4..a204fc5 100644 --- a/miniapp/assets/podbor.js +++ b/miniapp/assets/podbor.js @@ -1780,5 +1780,12 @@ ${reportEl.outerHTML} }); } - return { mount, go, getState: () => state, reset: () => { state = defaultState(); saveState(); render(); } }; + return { + mount, + go, + getState: () => state, + reset: () => { state = defaultState(); saveState(); render(); }, + // Внешний API для рендеринга сохранённого отчёта (используется в Clients) + renderSavedReport: (ai, leadId) => renderReport(ai, leadId || ""), + }; })(); diff --git a/miniapp/index.html b/miniapp/index.html index a2e764e..b3ff960 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,8 +12,8 @@ - - + +
@@ -21,10 +21,11 @@
- - - - - + + + + + +