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)}
+
+
${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(`
+
+ `);
+ 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 @@
-
-
-
-
-
+
+
+
+
+
+