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

226 lines
14 KiB
JavaScript
Raw Permalink 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 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();