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.
This commit is contained in:
wasrusgen 2026-05-13 18:39:25 +03:00
parent 366625be66
commit 8201fee9f2
4 changed files with 401 additions and 63 deletions

View File

@ -127,73 +127,252 @@ const Clients = (function () {
return; return;
} }
// Шапка
const phoneNorm = (client.client_phone || "").replace(/[^\d+]/g, "");
const callHref = phoneNorm ? `tel:${phoneNorm}` : "";
root.appendChild(el(` root.appendChild(el(`
<div class="client-detail-head"> <div class="client-detail-head">
<div class="client-avatar lg">${initial(client.client_name)}</div> <div class="client-avatar lg">${initial(client.client_name)}</div>
<div> <div style="flex:1;min-width:0;">
<h2 class="client-detail-name">${escHtml(client.client_name)}</h2> <h2 class="client-detail-name">${escHtml(client.client_name)}</h2>
${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""} ${client.client_phone ? `<div class="client-detail-phone">${escHtml(client.client_phone)}</div>` : ""}
</div> </div>
${callHref ? `<a class="client-call-btn" href="${callHref}" aria-label="Позвонить">📞</a>` : ""}
</div> </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)); root.appendChild(renderClientNoteBlock(client));
root.appendChild(el(`<div class="section-head"><span class="label">Подборы · ${client.leads_count}</span></div>`)); // Хронология + Файлы — собираются после загрузки замеров
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);
const leadsList = el(`<div class="leads-list"></div>`); let myMeasurements = [];
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);
// Замеры этого клиента (если есть)
try { try {
const ms = await fetchMeasurements({ client_tg_id: client.client_tg_id || "" }); const ms = await fetchMeasurements({ client_tg_id: client.client_tg_id || "" });
const myMeasurements = (ms.measurements || []).filter(m => { myMeasurements = (ms.measurements || []).filter(m => {
// Если client_tg_id зарегистрирован — фильтруем по нему
if (client.client_tg_id) return String(m.client_tg_id) === String(client.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()); return (m.notes || "").toLowerCase().includes((client.client_name || "").toLowerCase());
}); });
if (myMeasurements.length) { } catch (e) { /* пусто */ }
root.appendChild(el(`<div class="section-head" style="margin-top:24px;"><span class="label">Замеры · ${myMeasurements.length}</span></div>`));
const mList = el(`<div class="leads-list"></div>`); // Хронология
for (const m of myMeasurements) { timelinePlaceholder.replaceWith(renderClientTimeline(client, myMeasurements));
const photoCount = m.photo_count || (m.photos || []).length; // Файлы
const photoBadge = photoCount ? ` · 📷 ${photoCount}` : ""; filesPlaceholder.replaceWith(renderClientFiles(client, myMeasurements));
const item = el(` // Детальные списки внизу (свёрнуты)
<button class="lead-item"> detailsPlaceholder.replaceWith(renderClientDetails(client, myMeasurements));
<div class="lead-date">${formatDate(m.created_at)}</div> }
<div class="lead-id">${escHtml(layoutLabel(m.layout))}</div>
<div class="lead-status">${m.area_m2 ? m.area_m2 + " м²" : "—"}${photoBadge}</div> /* ===================== Хронология ===================== */
<div class="lead-arrow">${ICONS.chevron || ""}</div>
</button> function renderClientTimeline(client, measurements) {
`); // Собираем события из лидов и замеров
item.addEventListener("click", () => { const events = [];
haptic && haptic("impact");
location.hash = `#/clients/measurement/${m.id}`; for (const lead of client.leads || []) {
}); events.push({
mList.appendChild(item); ts: lead.created_at,
} icon: "🤖",
root.appendChild(mList); title: "Подбор техники",
} sub: `#${(lead.id || "").slice(0, 8)} · ${statusLabel(lead.status)}`,
} catch (e) { 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) { function layoutLabel(key) {

View File

@ -2066,6 +2066,154 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* ===== Карточка клиента: шапка + действия ===== */
.client-detail-head { position: relative; display: flex; align-items: center; gap: 14px; }
.client-call-btn {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--walnut, #6B4A2B);
color: var(--paper, #FBF7F0);
text-decoration: none;
font-size: 20px;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(107, 74, 43, 0.25);
}
.client-call-btn:active { transform: scale(0.95); }
.client-quick-actions {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
margin: 14px 0 18px;
}
.client-quick-actions .qa-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 6px;
background: var(--card, #fff);
border: 1px solid rgba(107, 74, 43, 0.18);
border-radius: 12px;
cursor: pointer;
font-family: inherit;
font-size: 19px;
line-height: 1;
}
.client-quick-actions .qa-btn:active { background: var(--paper-2, #F5EDDC); }
.client-quick-actions .qa-btn span {
font-size: 11px;
font-weight: 500;
color: var(--ink, #1F1A14);
margin-top: 4px;
line-height: 1.2;
text-align: center;
}
/* ===== Хронология клиента ===== */
.client-timeline-block .timeline { padding: 10px 4px 4px; position: relative; }
.client-timeline-block .timeline::before {
content: "";
position: absolute;
left: 13px;
top: 18px;
bottom: 18px;
width: 1px;
background: rgba(107, 74, 43, 0.18);
}
.tl-item {
display: flex;
gap: 14px;
padding: 8px 0;
text-decoration: none;
color: inherit;
position: relative;
cursor: pointer;
}
.tl-item:active { background: rgba(107, 74, 43, 0.04); border-radius: 6px; }
.tl-dot {
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--walnut, #6B4A2B);
flex-shrink: 0;
margin-top: 8px;
z-index: 1;
box-shadow: 0 0 0 3px var(--paper, #FBF7F0);
}
.tl-content { flex: 1; min-width: 0; }
.tl-date {
font-family: var(--font-mono, "JetBrains Mono", monospace);
font-size: 10px;
color: var(--muted, #998877);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 2px;
}
.tl-title { font-size: 14px; font-weight: 500; color: var(--ink, #1F1A14); }
.tl-icon { margin-right: 6px; }
.tl-sub { font-size: 12px; color: var(--muted, #998877); margin-top: 2px; }
/* ===== Файлы клиента ===== */
.client-files-block .file-group { padding: 8px 4px 12px; }
.client-files-block .file-group-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
color: var(--ink, #1F1A14);
}
.file-thumbs {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 6px;
}
.file-thumb {
display: block;
aspect-ratio: 1 / 1;
border-radius: 8px;
overflow: hidden;
background: var(--warm, rgba(107, 74, 43, 0.08));
border: 1px solid rgba(107, 74, 43, 0.15);
}
.file-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.file-thumb.more {
display: grid;
place-items: center;
background: rgba(107, 74, 43, 0.10);
color: var(--walnut, #6B4A2B);
font-weight: 600;
font-size: 14px;
text-decoration: none;
}
/* ===== Свёрнутые детали (подборы / замеры) ===== */
.client-details { margin-top: 14px; }
.client-details-collapse {
background: var(--card, #fff);
border: 1px solid rgba(107, 74, 43, 0.12);
border-radius: 10px;
margin-bottom: 10px;
overflow: hidden;
}
.client-details-collapse summary {
padding: 12px 14px;
font-size: 13px;
font-weight: 600;
color: var(--ink, #1F1A14);
cursor: pointer;
user-select: none;
font-family: inherit;
letter-spacing: 0.02em;
}
.client-details-collapse[open] summary { border-bottom: 1px solid rgba(107, 74, 43, 0.10); }
.client-details-collapse .leads-list { padding: 4px 8px 8px; }
/* ===== Примечание по клиенту ===== */ /* ===== Примечание по клиенту ===== */
.client-note-block .block-head { .client-note-block .block-head {
display: flex; display: flex;

View File

@ -24,6 +24,16 @@ const MeasurementRequest = (function () {
client_name: "", client_phone: "", address: "", assigned_to_tg_id: "", client_name: "", client_phone: "", address: "", assigned_to_tg_id: "",
preferred_note: "", preferred_note: "",
}; };
// Prefill из карточки клиента (sessionStorage перед navigate)
try {
const raw = sessionStorage.getItem("prefillClient");
if (raw) {
const pre = JSON.parse(raw);
if (pre.name) state.client_name = pre.name;
if (pre.phone) state.client_phone = pre.phone;
sessionStorage.removeItem("prefillClient");
}
} catch (e) {}
render(); render();
loadMeasurers(); loadMeasurers();
} }
@ -41,7 +51,7 @@ const MeasurementRequest = (function () {
<div class="form-row"> <div class="form-row">
<label class="field"> <label class="field">
<span class="field-label">ФИО клиента *</span> <span class="field-label">ФИО клиента *</span>
<input type="text" data-bind="client_name" placeholder="Иванов Иван Иванович" autocomplete="name"> <input type="text" data-bind="client_name" value="${escAttr(state.client_name)}" placeholder="Иванов Иван Иванович" autocomplete="name">
<span class="field-error" id="errName"></span> <span class="field-error" id="errName"></span>
</label> </label>
</div> </div>
@ -49,7 +59,7 @@ const MeasurementRequest = (function () {
<div class="form-row"> <div class="form-row">
<label class="field"> <label class="field">
<span class="field-label">Телефон *</span> <span class="field-label">Телефон *</span>
<input type="tel" data-bind="client_phone" placeholder="+7 921 555-12-34" autocomplete="tel"> <input type="tel" data-bind="client_phone" value="${escAttr(state.client_phone)}" placeholder="+7 921 555-12-34" autocomplete="tel">
<span class="field-hint">Минимум 10 цифр</span> <span class="field-hint">Минимум 10 цифр</span>
<span class="field-error" id="errPhone"></span> <span class="field-error" id="errPhone"></span>
</label> </label>
@ -58,7 +68,7 @@ const MeasurementRequest = (function () {
<div class="form-row"> <div class="form-row">
<label class="field"> <label class="field">
<span class="field-label">Адрес замера</span> <span class="field-label">Адрес замера</span>
<input type="text" data-bind="address" placeholder="СПб, Просвещения 87, кв. 12"> <input type="text" data-bind="address" value="${escAttr(state.address)}" placeholder="СПб, Просвещения 87, кв. 12">
</label> </label>
</div> </div>
@ -228,6 +238,7 @@ const MeasurementRequest = (function () {
.replace(/&/g, "&amp;").replace(/</g, "&lt;") .replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;"); .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
} }
function escAttr(s) { return escHtml(s); }
return { mount }; return { mount };
})(); })();

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&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&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&family=Cormorant+Garamond:ital,wght@1,400;1,500;1,600&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=20260513z"> <link rel="stylesheet" href="assets/styles.css?v=20260513za">
<link rel="stylesheet" href="assets/podbor.css?v=20260513z"> <link rel="stylesheet" href="assets/podbor.css?v=20260513za">
</head> </head>
<body> <body>
<!-- Splash — за пределами #app, render-функции его не смывают --> <!-- Splash — за пределами #app, render-функции его не смывают -->
@ -31,14 +31,14 @@
<div class="loader-tagline">Сделано с душой!</div> <div class="loader-tagline">Сделано с душой!</div>
</div> </div>
<main id="app"></main> <main id="app"></main>
<script src="assets/icons.js?v=20260513z"></script> <script src="assets/icons.js?v=20260513za"></script>
<script src="assets/podbor.config.js?v=20260513z"></script> <script src="assets/podbor.config.js?v=20260513za"></script>
<script src="assets/podbor.picts.js?v=20260513z"></script> <script src="assets/podbor.picts.js?v=20260513za"></script>
<script src="assets/podbor.js?v=20260513z"></script> <script src="assets/podbor.js?v=20260513za"></script>
<script src="assets/clients.js?v=20260513z"></script> <script src="assets/clients.js?v=20260513za"></script>
<script src="assets/zamer-picts.js?v=20260513z"></script> <script src="assets/zamer-picts.js?v=20260513za"></script>
<script src="assets/measurements.js?v=20260513z"></script> <script src="assets/measurements.js?v=20260513za"></script>
<script src="assets/request.js?v=20260513z"></script> <script src="assets/request.js?v=20260513za"></script>
<script src="assets/app.js?v=20260513z"></script> <script src="assets/app.js?v=20260513za"></script>
</body> </body>
</html> </html>