/* «Статистика»: только то, что сайт действительно сохраняет о заявках.
Звонков, посещений и выручки сайт не знает — таких карточек здесь нет. */
const ST_SOURCES = {
'': 'Прямой заход / без метки', yandex: 'Яндекс', 'ya.ru': 'Яндекс', 'yandex.ru': 'Яндекс', google: 'Google', 'google.com': 'Google',
vk: 'ВКонтакте', 'vk.com': 'ВКонтакте', 'm.vk.com': 'ВКонтакте', telegram: 'Telegram', 't.me': 'Telegram',
instagram: 'Instagram', '2gis': '2ГИС', '2gis.ru': '2ГИС', whatsapp: 'WhatsApp', 'dzen.ru': 'Дзен', prodoctorov: 'ПроДокторов',
'prodoctorov.ru': 'ПроДокторов',
};
const ST_SECTIONS = {
home: 'Главная', services: 'Страницы услуг', doctors: 'Страницы врачей', checkups: 'Чек-апы', promos: 'Акции',
prices: 'Цены', contacts: 'Контакты', vacancy: 'Вакансии', other: 'Другие страницы',
};
const ST_COLORS = ['#4f8ff0', '#e8708a', '#3fb27f', '#a77ef0', '#f0a04b', '#46b8c8', '#b5b8b6'];
const ST_STATUS_COLORS = { new: '#3fb27f', in_progress: '#4f8ff0', done: '#f0c24b', spam: '#e8708a' };
const ST_PRESETS = [['today', 'Сегодня', 0], ['7', '7 дней', 6], ['30', '30 дней', 29], ['90', '3 месяца', 89], ['365', 'Год', 364]];
const ST_ICONS = {
leads: '',
people: '',
check: '',
repeat: '',
shield: '',
calendar: '',
};
const stIcon = (n) => ``;
const pct = (a, b) => (b ? Math.round((a / b) * 100) : 0);
const isoDay = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
const shortDay = (s) => s.slice(8, 10) + '.' + s.slice(5, 7);
const Stats = {
preset: '30',
group: 'day',
data: null,
range() {
return { from: $('#st-from').value, to: $('#st-to').value };
},
setPreset(key) {
Stats.preset = key;
const p = ST_PRESETS.find((x) => x[0] === key);
if (p) {
const to = new Date(); const from = new Date(); from.setDate(from.getDate() - p[2]);
$('#st-from').value = isoDay(from); $('#st-to').value = isoDay(to);
}
for (const b of document.querySelectorAll('#st-presets button')) b.setAttribute('aria-pressed', String(b.dataset.key === key));
Stats.load();
},
async load() {
const { from, to } = Stats.range();
const br = $('#st-branch').value;
try {
const r = await api(`/admin/stats?from=${from}&to=${to}${br ? '&branch_id=' + br : ''}`);
Stats.data = r.data; Stats.meta = r.meta;
for (const k of ['by_day', 'by_form', 'by_source', 'by_section', 'by_branch', 'top_services', 'top_doctors']) Stats.data[k] ||= [];
Stats.render();
} catch (e) { toast(e.message, true); }
},
delta(cur, prev) {
if (!prev && !cur) return '';
if (!prev) return 'новое';
const d = Math.round(((cur - prev) / prev) * 100);
if (!d) return 'без изменений';
return `${d > 0 ? '↑' : '↓'} ${Math.abs(d)}%`;
},
render() {
const s = Stats.data, m = Stats.meta;
const period = m.days === 1 ? 'за день' : `за ${m.days} ${plural(m.days, 'день', 'дня', 'дней')}`;
const kpis = [
['leads', '#efeafb', '#7a5cc9', 'Всего заявок', s.total, Stats.delta(s.total, s.prev_total), period + ' · без спама'],
['people', '#e5f1fb', '#3a7bd5', 'Разные телефоны', s.unique_phones, Stats.delta(s.unique_phones, s.prev_unique), 'уникальные обращения'],
['check', '#e7f6ee', '#2f9a6a', 'Обработано', pct(s.done, s.total) + '%', '', `${s.done} из ${s.total} — статус «обработана»`],
['repeat', '#fdf1e3', '#d8822c', 'Повторные', s.repeat, '', 'номера с 2+ заявками'],
['shield', '#fdeaec', '#d9546d', 'Спам отсеян', s.spam, '', 'помечены как спам'],
];
const k = $('#st-kpis'); k.textContent = '';
for (const [icon, bg, ink, label, value, delta, hint] of kpis) {
const c = el('div', { className: 'card kpi' });
c.innerHTML = `${stIcon(icon)}
${label}${value}${delta}${hint}
`;
k.append(c);
}
Stats.chart();
Stats.donut('#st-sources', s.by_source.map((x) => [ST_SOURCES[x.key] ?? x.label, x.count]), s.total,
s.total && !s.with_utm ? 'Ни одна заявка за период не пришла с UTM-меткой или с другого сайта. Источник записывается только для заявок, оставленных после обновления сайта.' : '');
Stats.donut('#st-status', ['new', 'in_progress', 'done', 'spam'].map((key) => [LEAD_STATUS[key]?.[0] || key,
s[key === 'in_progress' ? 'in_progress' : key], ST_STATUS_COLORS[key]]), s.total + s.spam, '', 'заявок');
Stats.tops('#st-services', s.top_services, 'Услуга', 'Заявки с выбранной услугой или со страницы услуги.');
Stats.tops('#st-doctors', s.top_doctors, 'Врач', 'Заявки к врачу или со страницы врача.');
Stats.bars('#st-branches', s.by_branch.map((x) => [x.label ? shortAddress(x.label) : 'Филиал не выбран', x.count]), s.total);
Stats.bars('#st-forms', s.by_form.map((x) => [x.label, x.count]), s.total);
Stats.bars('#st-pages', s.by_section.map((x) => [ST_SECTIONS[x.key] || x.key, x.count]), s.total);
},
// Динамика: по дням или по неделям, с подсказкой при наведении
chart() {
const box = $('#st-chart'); box.textContent = '';
let pts = Stats.data.by_day.map((d) => ({ label: shortDay(d.date), full: ruDate(d.date), v: d.count }));
if (Stats.group === 'week' && pts.length > 13) {
const w = [];
for (let i = 0; i < pts.length; i += 7) {
const part = pts.slice(i, i + 7);
w.push({ label: part[0].label, full: `${part[0].full} — ${part[part.length - 1].full}`, v: part.reduce((a, b) => a + b.v, 0) });
}
pts = w;
}
const W = Math.max(box.clientWidth, 320), H = 230, L = 34, R = 10, T = 12, B = 26;
const max = Math.max(4, ...pts.map((p) => p.v));
const step = Math.ceil(max / 4);
const top = step * 4;
const x = (i) => L + (pts.length === 1 ? (W - L - R) / 2 : (i * (W - L - R)) / (pts.length - 1));
const y = (v) => T + (H - T - B) * (1 - v / top);
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)} ${y(p.v).toFixed(1)}`).join(' ');
const area = `${line} L${x(pts.length - 1).toFixed(1)} ${y(0)} L${x(0).toFixed(1)} ${y(0)} Z`;
let grid = '';
for (let g = 0; g <= 4; g++) {
const v = step * g;
grid += `${v}`;
}
const every = Math.ceil(pts.length / 10);
const labels = pts.map((p, i) => (i % every === 0 || i === pts.length - 1) ? `${p.label}` : '').join('');
const svg = ``;
box.innerHTML = svg + '';
const s = box.querySelector('svg'), tip = box.querySelector('.st-tip'), guide = s.querySelector('.st-guide'), dot = s.querySelector('.st-dot');
s.addEventListener('mousemove', (e) => {
const r = s.getBoundingClientRect();
const px = ((e.clientX - r.left) / r.width) * W;
let i = Math.round(((px - L) / (W - L - R)) * (pts.length - 1));
i = Math.max(0, Math.min(pts.length - 1, i));
const p = pts[i];
guide.setAttribute('x1', x(i)); guide.setAttribute('x2', x(i)); guide.hidden = false;
dot.setAttribute('cx', x(i)); dot.setAttribute('cy', y(p.v)); dot.hidden = false;
tip.hidden = false;
tip.innerHTML = `${p.v} ${plural(p.v, 'заявка', 'заявки', 'заявок')}${p.full}`;
tip.style.left = (x(i) / W) * r.width + 'px'; tip.style.top = (y(p.v) / H) * r.height + 'px';
});
s.addEventListener('mouseleave', () => { tip.hidden = true; guide.hidden = true; dot.hidden = true; });
},
donut(sel, items, total, note, unit = 'заявок') {
const box = $(sel); box.textContent = '';
items = items.filter((x) => x[1] > 0);
const sum = items.reduce((a, b) => a + b[1], 0);
if (!sum) { box.append(el('p', { className: 'st-empty', textContent: 'За этот период заявок нет.' })); return; }
const R = 60, C = 2 * Math.PI * R;
let off = 0, arcs = '';
items.forEach((it, i) => {
const len = (it[1] / sum) * C;
arcs += ``;
off += len;
});
const wrap = el('div', { className: 'st-donut' });
wrap.innerHTML = ``;
const legend = el('ul', { className: 'st-legend' });
items.forEach((it, i) => {
const li = el('li');
li.innerHTML = `${pct(it[1], sum)}%${it[1]}`;
li.querySelector('span').textContent = it[0];
legend.append(li);
});
wrap.append(legend); box.append(wrap);
if (note) box.append(el('p', { className: 'st-note', textContent: note }));
},
bars(sel, items, total) {
const box = $(sel); box.textContent = '';
if (!items.length) { box.append(el('p', { className: 'st-empty', textContent: 'За этот период заявок нет.' })); return; }
const max = Math.max(...items.map((x) => x[1]));
for (const [label, n] of items) {
const row = el('div', { className: 'st-bar' });
row.innerHTML = `
${pct(n, total)}%${n}`;
row.querySelector('span').textContent = label;
box.append(row);
}
},
tops(sel, list, what, note) {
const box = $(sel); box.textContent = '';
if (!list.length) { box.append(el('p', { className: 'st-empty', textContent: 'За этот период нет заявок, привязанных к ' + (what === 'Врач' ? 'врачу.' : 'услуге.') })); return; }
const t = el('table', { className: 'st-top' });
t.innerHTML = `| # | ${what} | Заявки | Обработано |
`;
const tb = el('tbody');
list.slice(0, 5).forEach((x, i) => {
const tr = el('tr');
tr.append(el('td', { className: 'muted', textContent: i + 1 }), el('td', { textContent: x.name }),
el('td', { textContent: x.count }), el('td', { className: 'muted', textContent: pct(x.done, x.count) + '%' }));
tb.append(tr);
});
t.append(tb); box.append(t);
box.append(el('p', { className: 'st-note', textContent: note }));
},
init() {
const pre = $('#st-presets');
for (const [key, label] of ST_PRESETS) {
const b = el('button', { type: 'button', textContent: label });
b.dataset.key = key;
b.onclick = () => Stats.setPreset(key);
pre.append(b);
}
$('#st-cal').innerHTML = stIcon('calendar');
for (const id of ['#st-from', '#st-to']) $(id).onchange = () => {
Stats.preset = '';
for (const b of document.querySelectorAll('#st-presets button')) b.setAttribute('aria-pressed', 'false');
if ($('#st-from').value && $('#st-to').value) Stats.load();
};
$('#st-branch').onchange = () => Stats.load();
$('#st-group').onchange = () => { Stats.group = $('#st-group').value; Stats.chart(); };
window.addEventListener('resize', debounce(() => { if (Stats.data && !$('#tab-stats').hidden) Stats.chart(); }, 150));
},
open() {
const sel = $('#st-branch');
if (sel.options.length <= 1) for (const b of branchList) sel.append(el('option', { value: b.id, textContent: shortAddress(b.address) }));
if (!Stats.data) Stats.setPreset(Stats.preset || '30'); else Stats.load();
},
};
Stats.init();