/* Раздел «Заявки»: заявки с форм сайта, статус и комментарий администратора. Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */ const Leads = { offset: 0, limit: 30, total: 0, loaded: false, visible: [], async load() { const status = $('#ld-status').value; const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset }); if ($('#ld-from').value) params.set('from', $('#ld-from').value); if ($('#ld-to').value) params.set('to', $('#ld-to').value); let r; try { r = await api(`/admin/leads?${params}`); } catch (e) { toast(e.message, true); return; } Leads.total = r.meta?.total || 0; const counts = r.meta?.counts || {}; for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0], ['ld-progress', counts.in_progress || 0], ['ld-progress-badge', counts.in_progress || 0], ['ld-today', r.meta?.today || 0], ['ld-today-badge', r.meta?.today || 0]]) $('#' + id).textContent = value; for (const o of $('#ld-status').options) { const n = o.value ? counts[o.value] || 0 : Object.values(counts).reduce((a, b) => a + b, 0); o.textContent = o.textContent.replace(/ \(\d+\)$/, '') + ` (${n})`; } const badge = $('#ld-badge'); if (badge) { badge.textContent = counts.new || ''; badge.hidden = !counts.new; } $('#ld-count').textContent = `Показано ${r.data?.length || 0} из ${Leads.total} ${plural(Leads.total, 'заявки', 'заявок', 'заявок')}`; const tb = $('#ld-body'); tb.textContent = ''; const list = r.data || []; Leads.visible = list; if (!list.length) { const tr = el('tr'); const td = el('td', { colSpan: 7, className: 'ld-empty-cell' }); const empty = el('div', { className: 'ld-empty' }); empty.innerHTML = ``; const initialEmpty = Leads.total === 0 && !$('#ld-q').value && !$('#ld-from').value && !$('#ld-to').value && status === 'new'; empty.append(el('h2', { textContent: initialEmpty ? 'Пока нет заявок' : 'Заявки не найдены' })); if (!initialEmpty) empty.append(el('p', { textContent: 'Попробуйте изменить статус, даты или поисковый запрос.' })); td.append(empty); tr.append(td); tb.append(tr); } const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); const tf = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }); for (const l of list) { const tr = el('tr'); const when = el('td'); when.append(df.format(new Date(l.created_at)), el('small', { className: 'muted', textContent: ' ' + tf.format(new Date(l.created_at)) })); const [label, cls] = LEAD_STATUS[l.status] || [l.status, 'grey']; const st = el('td'); st.append(el('span', { className: 'pill ' + cls, textContent: label })); const phone = el('td'); phone.append(el('a', { href: 'tel:' + l.phone.replace(/[^\d+]/g, ''), textContent: l.phone, onclick: (e) => e.stopPropagation() })); tr.append(when, el('td', { textContent: l.name || '—' }), phone, el('td', { className: 'muted', textContent: l.form_title || '—' }), el('td', { className: 'muted rv-text', textContent: l.message || l.doctor_name || l.service_name || '—' }), st); const act = el('td', { className: 'right' }); const e = el('button', { className: 'btn ghost', textContent: 'Открыть' }); e.onclick = (ev) => { ev.stopPropagation(); Leads.open(l); }; act.append(e); tr.append(act); tr.style.cursor = 'pointer'; tr.onclick = () => Leads.open(l); tb.append(tr); } const from = Leads.total ? Leads.offset + 1 : 0; $('#ld-page').textContent = `${from}–${Math.min(Leads.offset + Leads.limit, Leads.total)} из ${Leads.total}`; $('#ld-prev').disabled = Leads.offset === 0; $('#ld-next').disabled = Leads.offset + Leads.limit >= Leads.total; $('#ld-export').disabled = list.length === 0; }, exportVisible() { if (!Leads.visible.length) return; const columns = ['Дата', 'Имя', 'Телефон', 'Форма', 'Сообщение', 'Статус']; const safe = (value) => { let text = String(value ?? '').replace(/\r?\n/g, ' '); if (/^[=+\-@\t]/.test(text)) text = "'" + text; return '"' + text.replace(/"/g, '""') + '"'; }; const rows = Leads.visible.map((l) => [new Date(l.created_at).toLocaleString('ru-RU'), l.name, l.phone, l.form_title, l.message, LEAD_STATUS[l.status]?.[0] || l.status]); const blob = new Blob(['\ufeff' + [columns, ...rows].map((row) => row.map(safe).join(';')).join('\r\n')], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = `dana-leads-page-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }, // карточка заявки: слева — когда и кто, справа — к кому и откуда; ниже статус и комментарий open(l) { const df = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); const left = [['Когда', 'calendar', df.format(new Date(l.created_at)).replace(', ', ' в ')], ['Форма', 'doc', l.form_title], ['Имя', 'user', l.name], ['Телефон', 'phone', l.phone], ['E-mail', 'mail', l.email]]; // желаемые дата и время из формы записи (payload): «2026-10-20» и «10:00» или старое «Утро, 08:00–12:00» const p = l.payload || {}; const wishDate = typeof p.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(p.date) ? p.date.split('-').reverse().join('.') : ''; const wish = [wishDate, typeof p.time === 'string' ? p.time : ''].filter(Boolean).join(', '); const right = [['Врач', 'doctor', l.doctor_name], ['Желаемое время', 'calendar', wish], ['Услуга', 'heart', l.service_name], ['Сообщение', 'chat', l.message], ['Страница', 'link', l.page_url]]; for (const [box, rows] of [[$('#ldd-left'), left], [$('#ldd-right'), right]]) { box.textContent = ''; for (const [label, icon, value] of rows.filter((r) => r[2])) { const tile = el('div', { className: 'ldd__tile' + (icon === 'chat' ? ' ldd__tile--msg' : '') }); tile.innerHTML = `${lddIcon(icon)}
`; tile.querySelector('small').textContent = label; const v = tile.querySelector('b'); if (icon === 'phone') v.append(el('a', { href: 'tel:+' + String(value).replace(/\D/g, ''), textContent: value })); else if (icon === 'mail') v.append(el('a', { href: 'mailto:' + value, textContent: value })); else if (icon === 'link') v.append(el('a', { href: value, target: '_blank', rel: 'noopener', textContent: value.replace(/^https?:\/\/[^/]+/, '') || value })); else v.textContent = value; box.append(tile); } } const sel = $('#ldd-status'); sel.textContent = ''; for (const [value, [label]] of Object.entries(LEAD_STATUS)) sel.append(el('option', { value, textContent: label })); sel.value = l.status; const dot = () => { $('#ldd-dot').dataset.status = sel.value; }; sel.onchange = dot; dot(); $('#ldd-comment').value = l.comment || ''; const dlg = $('#ld-dlg'); $('#ldd-save').onclick = async (e) => { e.preventDefault(); const b = $('#ldd-save'); b.disabled = true; try { await api('/admin/leads/' + l.id, { method: 'PUT', body: JSON.stringify({ status: sel.value, comment: $('#ldd-comment').value }) }); toast('Заявка сохранена'); dlg.close(); Leads.load(); } catch (err) { toast(err.message, true); } b.disabled = false; }; dlg.showModal(); }, }; const LDD_ICONS = { doc: '', close: '', user: '', doctor: '', gear: '', calendar: '', phone: '', mail: '', heart: '', chat: '', link: '', save: '', }; const lddIcon = (n) => ``; document.querySelectorAll('[data-ldd-icon]').forEach((e) => { e.innerHTML = lddIcon(e.dataset.lddIcon); }); $('#ldd-close').onclick = () => $('#ld-dlg').close(); $('#ldd-cancel').onclick = () => $('#ld-dlg').close(); $('#ld-dlg').addEventListener('click', (e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }); $('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); }; $('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300)); for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); }; $('#ld-refresh').onclick = () => Leads.load(); $('#ld-export').onclick = () => Leads.exportVisible(); $('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); }; $('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); };