226 lines
14 KiB
JavaScript
226 lines
14 KiB
JavaScript
/* «Статистика»: только то, что сайт действительно сохраняет о заявках.
|
||
Звонков, посещений и выручки сайт не знает — таких карточек здесь нет. */
|
||
|
||
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: '<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"/>',
|
||
people: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3 19.5a6 6 0 0 1 12 0M16 5.5a3 3 0 0 1 0 6M18 19.5a5.5 5.5 0 0 0-2.5-4.6"/>',
|
||
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
|
||
repeat: '<path d="M4 12a8 8 0 0 1 13.7-5.6L20 8.7M20 4v4.7h-4.7M20 12a8 8 0 0 1-13.7 5.6L4 15.3M4 20v-4.7h4.7"/>',
|
||
shield: '<path d="M12 3.5 19 6v5.5c0 4.4-3 7.8-7 9-4-1.2-7-4.6-7-9V6z"/><path d="m9.5 12 2 2 3.5-4"/>',
|
||
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16"/>',
|
||
};
|
||
const stIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${ST_ICONS[n]}</svg>`;
|
||
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 '<span class="kpi__delta up">новое</span>';
|
||
const d = Math.round(((cur - prev) / prev) * 100);
|
||
if (!d) return '<span class="kpi__delta">без изменений</span>';
|
||
return `<span class="kpi__delta ${d > 0 ? 'up' : 'down'}">${d > 0 ? '↑' : '↓'} ${Math.abs(d)}%</span>`;
|
||
},
|
||
|
||
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 = `<span class="kpi__icon" style="background:${bg};color:${ink}">${stIcon(icon)}</span>
|
||
<div><span class="kpi__label">${label}</span><b class="kpi__value">${value}</b>${delta}<small>${hint}</small></div>`;
|
||
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 += `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}" class="st-grid"/><text x="${L - 8}" y="${y(v) + 4}" text-anchor="end" class="st-axis">${v}</text>`;
|
||
}
|
||
const every = Math.ceil(pts.length / 10);
|
||
const labels = pts.map((p, i) => (i % every === 0 || i === pts.length - 1) ? `<text x="${x(i)}" y="${H - 6}" text-anchor="middle" class="st-axis">${p.label}</text>` : '').join('');
|
||
const svg = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="Заявки ${Stats.group === 'week' ? 'по неделям' : 'по дням'}: всего ${Stats.data.total}">
|
||
<defs><linearGradient id="st-fill" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#4f8ff0" stop-opacity=".32"/><stop offset="1" stop-color="#4f8ff0" stop-opacity="0"/></linearGradient></defs>
|
||
${grid}<path d="${area}" fill="url(#st-fill)"/><path d="${line}" class="st-line"/>${labels}
|
||
<line class="st-guide" y1="${T}" y2="${y(0)}" hidden/><circle class="st-dot" r="5" hidden/></svg>`;
|
||
box.innerHTML = svg + '<div class="tooltip st-tip" hidden></div>';
|
||
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 = `<b>${p.v} ${plural(p.v, 'заявка', 'заявки', 'заявок')}</b>${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 += `<circle r="${R}" cx="80" cy="80" fill="none" stroke="${it[2] || ST_COLORS[i % ST_COLORS.length]}" stroke-width="26"
|
||
stroke-dasharray="${Math.max(len - 2, 0.5)} ${C}" stroke-dashoffset="${-off}" transform="rotate(-90 80 80)"/>`;
|
||
off += len;
|
||
});
|
||
const wrap = el('div', { className: 'st-donut' });
|
||
wrap.innerHTML = `<svg viewBox="0 0 160 160" role="img" aria-label="${items.map((x) => x[0] + ': ' + x[1]).join(', ')}">${arcs}
|
||
<text x="80" y="80" text-anchor="middle" class="st-donut__n">${sum}</text><text x="80" y="98" text-anchor="middle" class="st-donut__u">${unit}</text></svg>`;
|
||
const legend = el('ul', { className: 'st-legend' });
|
||
items.forEach((it, i) => {
|
||
const li = el('li');
|
||
li.innerHTML = `<i style="background:${it[2] || ST_COLORS[i % ST_COLORS.length]}"></i><span></span><em>${pct(it[1], sum)}%</em><b>${it[1]}</b>`;
|
||
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 = `<span></span><div class="st-bar__track"><i style="width:${Math.max(3, (n / max) * 100)}%"></i></div><em>${pct(n, total)}%</em><b>${n}</b>`;
|
||
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 = `<thead><tr><th>#</th><th>${what}</th><th>Заявки</th><th>Обработано</th></tr></thead>`;
|
||
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();
|