/* «Статистика»: только то, что сайт действительно сохраняет о заявках. Звонков, посещений и выручки сайт не знает — таких карточек здесь нет. */ 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 = ` ${grid}${labels} `; 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 = `${arcs} ${sum}${unit}`; 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();