155 lines
11 KiB
JavaScript
155 lines
11 KiB
JavaScript
/* Раздел «Заявки»: заявки с форм сайта, статус и комментарий администратора.
|
||
Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */
|
||
|
||
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(); };
|