miniapp: client profile tab — list + history + lead detail

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/<key> — single client history (all leads as items)
  - #/clients/lead/<id>    — 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
This commit is contained in:
wasrusgen 2026-05-12 07:20:54 +03:00
parent 9a2dcbc3fe
commit c4f3016b56
5 changed files with 524 additions and 10 deletions

View File

@ -177,10 +177,10 @@ function renderManagerHome(me) {
// Quick actions // Quick actions
const quickActions = [ const quickActions = [
{ icon: "user", title: "Клиенты", subtitle: "История подборов", href: "#/clients" },
{ icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" },
{ icon: "camera", title: "Новый замер", subtitle: "С фото", href: null }, { icon: "camera", title: "Новый замер", subtitle: "С фото", href: null },
{ icon: "cube", title: "3D просмотр", 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(`<div class="section-head"><span class="label">Быстрые действия</span></div>`)); app.appendChild(el(`<div class="section-head"><span class="label">Быстрые действия</span></div>`));
const grid = el(`<div class="quick-grid"></div>`); const grid = el(`<div class="quick-grid"></div>`);
@ -366,6 +366,10 @@ async function init() {
Podbor.mount(app); Podbor.mount(app);
return; return;
} }
if (location.hash.startsWith("#/clients")) {
Clients.mount(app);
return;
}
if (me.role === "manager") renderManager(me); if (me.role === "manager") renderManager(me);
else renderClient(me); else renderClient(me);
} catch (e) { } catch (e) {
@ -377,6 +381,8 @@ async function init() {
function routeByHash() { function routeByHash() {
if (location.hash.startsWith("#/podbor")) { if (location.hash.startsWith("#/podbor")) {
Podbor.mount(app); Podbor.mount(app);
} else if (location.hash.startsWith("#/clients")) {
Clients.mount(app);
} else { } else {
// Главный экран по роли // Главный экран по роли
const me = window.__zovMe; const me = window.__zovMe;

301
miniapp/assets/clients.js Normal file
View File

@ -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(`<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;
}
root.appendChild(el(`
<div class="client-detail-head">
<div class="client-avatar lg">${initial(client.client_name)}</div>
<div>
<h2 class="client-detail-name">${escHtml(client.client_name)}</h2>
${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""}
</div>
</div>
`));
root.appendChild(el(`<div class="section-head"><span class="label">Подборы · ${client.leads_count}</span></div>`));
const leadsList = el(`<div class="leads-list"></div>`);
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}`;
});
leadsList.appendChild(item);
}
root.appendChild(leadsList);
}
/* ===================== Детали лида (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>`));
}
}
/* ===================== 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();
}
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 };
})();

View File

@ -1789,3 +1789,202 @@
margin-top: 8px; margin-top: 8px;
line-height: 1.4; 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;
}

View File

@ -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 || ""),
};
})(); })();

View File

@ -12,8 +12,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Geist:wght@400;500;600&family=Newsreader:ital,wght@0,400..600;1,400..600&family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500&display=swap">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<link rel="stylesheet" href="assets/styles.css?v=20260511p"> <link rel="stylesheet" href="assets/styles.css?v=20260512a">
<link rel="stylesheet" href="assets/podbor.css?v=20260511p"> <link rel="stylesheet" href="assets/podbor.css?v=20260512a">
</head> </head>
<body> <body>
<main id="app"> <main id="app">
@ -21,10 +21,11 @@
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
</main> </main>
<script src="assets/icons.js?v=20260511p"></script> <script src="assets/icons.js?v=20260512a"></script>
<script src="assets/podbor.config.js?v=20260511p"></script> <script src="assets/podbor.config.js?v=20260512a"></script>
<script src="assets/podbor.picts.js?v=20260511p"></script> <script src="assets/podbor.picts.js?v=20260512a"></script>
<script src="assets/podbor.js?v=20260511p"></script> <script src="assets/podbor.js?v=20260512a"></script>
<script src="assets/app.js?v=20260511p"></script> <script src="assets/clients.js?v=20260512a"></script>
<script src="assets/app.js?v=20260512a"></script>
</body> </body>
</html> </html>