dana-medical-site/internal/api/adminui/leads.js
2026-10-14 13:33:13 +00:00

155 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Раздел «Заявки»: заявки с форм сайта, статус и комментарий администратора.
Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */
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 = `<svg class="ld-empty__art" viewBox="0 0 260 150" aria-hidden="true"><path d="M52 122c-25-3-36-20-34-46 16 6 28 20 34 46Zm153 0c26-5 37-20 37-43-18 7-29 20-37 43Z" fill="#dcecff"/><rect x="73" y="20" width="103" height="120" rx="13" fill="#e3efff"/><rect x="84" y="28" width="81" height="104" rx="7" fill="#fff"/><rect x="103" y="13" width="44" height="18" rx="5" fill="#9aacc4"/><path d="M104 49h41M104 66h41M104 83h31M104 100h41" stroke="#a8cbfa" stroke-width="4" stroke-linecap="round"/><circle cx="193" cy="58" r="30" fill="#e6f1ff"/><path d="M182 48c2 12 11 21 21 23" fill="none" stroke="#3184ee" stroke-width="5" stroke-linecap="round"/><path d="M199 79c-4-5-13-6-17 0-6 9 3 18 17 28 14-10 23-19 17-28-4-6-13-5-17 0Z" fill="#9dc9ff"/><path d="M199 84v14m-7-7h14" stroke="#fff" stroke-width="5" stroke-linecap="round"/></svg>`;
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 = `<span class="ldd__ticon">${lddIcon(icon)}</span><div><small></small><b></b></div>`;
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: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5M9.5 13h6M9.5 16.5h6"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
user: '<circle cx="12" cy="8.5" r="3.6"/><path d="M5 20a7 7 0 0 1 14 0"/>',
doctor: '<circle cx="12" cy="7.5" r="3.4"/><path d="M5 20a7 7 0 0 1 14 0"/><path d="M12 14.5v3M10.5 16h3"/>',
gear: '<circle cx="12" cy="12" r="3.1"/><path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18 6l-1.6 1.6M7.6 16.4 6 18M18 18l-1.6-1.6M7.6 7.6 6 6"/>',
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16M8.5 14h2M13.5 14h2"/>',
phone: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/>',
mail: '<rect x="3.5" y="5.5" width="17" height="13" rx="2.5"/><path d="m4.5 7 7.5 6 7.5-6"/>',
heart: '<path d="M20.4 5.9a4.9 4.9 0 0 0-6.9 0L12 7.4l-1.5-1.5a4.9 4.9 0 1 0-6.9 6.9l8.4 8.4 8.4-8.4a4.9 4.9 0 0 0 0-6.9z"/>',
chat: '<path d="M20.5 11.6a8 8 0 0 1-11.7 7.1L4 19.8l1.1-4.4a8 8 0 1 1 15.4-3.8z"/><path d="M8.5 11.5h.01M12 11.5h.01M15.5 11.5h.01"/>',
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3-3a4.5 4.5 0 0 0-6.4-6.4l-1.2 1.2"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3 3a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
save: '<path d="M5 3.5h11l3.5 3.5v12a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 19V5A1.5 1.5 0 0 1 5 3.5z"/><path d="M7.5 3.5v5h8v-5M7.5 20.5v-6h9v6"/>',
};
const lddIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${LDD_ICONS[n]}</svg>`;
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(); };