159 lines
12 KiB
JavaScript
159 lines
12 KiB
JavaScript
/* История: показатели одной обработанной книги, без суммирования разных выгрузок. */
|
||
const RH_ICONS = {
|
||
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 4.5 4.5"/>',
|
||
calendar: '<rect x="4" y="5" width="16" height="16" rx="3"/><path d="M8 3v4m8-4v4M4 11h16m-12 4h2m4 0h2m-8 3h2"/>',
|
||
channels: '<rect x="8" y="3" width="8" height="5" rx="1.5"/><path d="M12 8v5M5 16v-3h14v3"/><rect x="2" y="16" width="6" height="5" rx="1.5"/><rect x="16" y="16" width="6" height="5" rx="1.5"/>',
|
||
wallet: '<path d="M19 7V4H6a3 3 0 0 0 0 6h14v11H6a3 3 0 0 1-3-3V7"/><path d="M20 13h-5v5h5m-3-2.5h.01"/>',
|
||
receipt: '<path d="M6 3h12v18l-3-2-3 2-3-2-3 2zM9 7h6M9 11h6m-6 4h3"/>',
|
||
};
|
||
const rhIcon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${RH_ICONS[name] || RP_ICONS[name] || ''}</svg>`;
|
||
const rhNumber = (value, digits = 0) => typeof value === 'number' && Number.isFinite(value)
|
||
? value.toLocaleString('ru-RU', { maximumFractionDigits: digits }) : '—';
|
||
const rhMoney = (value) => rhNumber(value) + (typeof value === 'number' && Number.isFinite(value) ? ' ₽' : '');
|
||
const rhPercent = (value) => rhNumber(value, 1) + (typeof value === 'number' && Number.isFinite(value) ? '%' : '');
|
||
|
||
// Цвет закреплён за каналом, а не за местом в рейтинге: в обоих списках канал одного цвета.
|
||
const RH_CHANNEL_COLORS = {
|
||
'колл-центр': ['#198fff', '#3cd7e7', '#e1f3ff'],
|
||
'инстаграм': ['#ff8a1f', '#ffc36b', '#fff0dc'],
|
||
'яндекс': ['#ee2d2d', '#ff8a80', '#ffe4e2'],
|
||
'2гис': ['#1fae4b', '#7bdc8f', '#e2f7e7'],
|
||
'сайт': ['#7261ff', '#c892ff', '#ede9ff'],
|
||
'продокторов': ['#04b9a8', '#60e5d7', '#dcfbf4'],
|
||
'zoon': ['#f838ad', '#ffa0d9', '#ffe5f4'],
|
||
'напоправку': ['#3f51d8', '#8a9bff', '#e6e9ff'],
|
||
'сберздоровье': ['#0aa6d6', '#6fd8f5', '#dff5fc'],
|
||
'баннер': ['#d4a300', '#f5d75e', '#fdf6d6'],
|
||
'докту': ['#5b6b82', '#a3b1c4', '#eef1f5'],
|
||
'тв': ['#b0359f', '#e58ad6', '#fbe6f7'],
|
||
'маршрутки': ['#8d6e63', '#c7a89c', '#f3ebe8'],
|
||
};
|
||
const RH_EXTRA_COLORS = [['#6d4cff', '#b3a2ff', '#eeeaff'], ['#00897b', '#6cc9bd', '#e0f4f1'], ['#c2185b', '#f07aa6', '#fce4ec']];
|
||
function rhChannelColor(name) {
|
||
const n = String(name || '').toLowerCase();
|
||
for (const [key, color] of Object.entries(RH_CHANNEL_COLORS)) if (n.startsWith(key)) return color;
|
||
let h = 0;
|
||
for (const ch of n) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
|
||
return RH_EXTRA_COLORS[h % RH_EXTRA_COLORS.length];
|
||
}
|
||
|
||
const ReportsHistory = {
|
||
selected: null,
|
||
|
||
render() {
|
||
const query = $('#rh-search').value.trim().toLocaleLowerCase('ru-RU');
|
||
const status = $('#rh-status').value;
|
||
const from = $('#rh-from').value, to = $('#rh-to').value;
|
||
const invalidRange = from && to && from > to;
|
||
const message = invalidRange ? 'Дата начала должна быть не позже даты окончания.'
|
||
: Reports.loadError ? 'Не удалось обновить историю. ' + Reports.loadError : '';
|
||
$('#rh-feedback').textContent = message;
|
||
$('#rh-feedback').hidden = !message;
|
||
const list = Reports.list.filter((r) => {
|
||
const date = new Date(r.created_at);
|
||
const day = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||
const text = `${RP_KINDS[r.kind]?.title || r.title} ${r.source_name || ''} ${r.result_name || ''}`.toLocaleLowerCase('ru-RU');
|
||
return !invalidRange && (!query || text.includes(query)) && (!status || r.status === status)
|
||
&& (!from || day >= from) && (!to || day <= to);
|
||
});
|
||
Reports.renderTable('#rp-history', list);
|
||
$('#rh-count').textContent = `Показано ${list.length} из ${Reports.list.length}`;
|
||
if (!list.length) $('#rp-history td').textContent = Reports.loadError ? 'История недоступна. Попробуйте обновить страницу.'
|
||
: Reports.list.length ? 'По выбранным условиям отчёты не найдены.' : 'Отчётов пока нет. Загрузите книгу, чтобы сформировать первый.';
|
||
|
||
const ready = list.filter((r) => r.kind === 'phone_match' && r.status === 'done' && Array.isArray(r.summary?.channels) && r.summary?.internet);
|
||
if (!ready.some((r) => String(r.id) === String(ReportsHistory.selected))) ReportsHistory.selected = ready[0]?.id ?? null;
|
||
const select = $('#rh-report'); select.textContent = '';
|
||
const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
for (const r of ready) select.append(el('option', { value: String(r.id), textContent: `${r.source_name || r.title} · ${df.format(new Date(r.created_at))} · №${r.id}` }));
|
||
select.disabled = ready.length === 0;
|
||
if (!ready.length) select.append(el('option', { textContent: 'Нет готовых отчётов по выбранным условиям' }));
|
||
else select.value = String(ReportsHistory.selected);
|
||
const report = ready.find((r) => String(r.id) === String(ReportsHistory.selected));
|
||
$('#rh-dashboard').hidden = !report;
|
||
$('#rh-empty').hidden = !!report;
|
||
$('#rh-download').hidden = !report;
|
||
if (!report) {
|
||
$('#rh-download').removeAttribute('href');
|
||
$('#rh-empty h2').textContent = Reports.loadError ? 'Не удалось загрузить показатели'
|
||
: query || status || from || to ? 'Нет готового отчёта по выбранным условиям' : 'Пока нет готового отчёта по каналам';
|
||
$('#rh-empty p').textContent = Reports.loadError ? 'Нажмите «Обновить историю», чтобы повторить загрузку.'
|
||
: query || status || from || to ? 'Измените фильтры или создайте новый отчёт по каналам.' : 'После обработки книги здесь появятся показатели и сравнение каналов.';
|
||
return;
|
||
}
|
||
$('#rh-download').href = `/api/v1/admin/reports/${report.id}/download`;
|
||
ReportsHistory.dashboard(report.summary);
|
||
},
|
||
|
||
dashboard(s) {
|
||
const internet = s.internet, totals = s.totals || {}, control = s.control || {};
|
||
const cards = [
|
||
['channels', 'Лиды из интернета', rhNumber(internet.leads), 'Без повторов между интернет-каналами', 'blue'],
|
||
['calendar', 'Услуги по базе 1С', rhNumber(totals.services, 1), 'Из выбранной выгрузки', 'violet'],
|
||
['wallet', 'Выручка по базе 1С', rhMoney(totals.revenue), 'Из выбранной выгрузки', 'mint'],
|
||
['receipt', 'Средний чек', rhMoney(totals.avg_check), 'По базе 1С', 'peach'],
|
||
];
|
||
const kpis = $('#rh-kpis'); kpis.textContent = '';
|
||
for (const [icon, label, value, note, color] of cards) {
|
||
const card = el('article', { className: `rh-kpi rh-kpi--${color}` });
|
||
card.innerHTML = `<span class="rh-kpi-icon">${rhIcon(icon)}</span><div><strong></strong><span></span><small></small></div>`;
|
||
card.querySelector('strong').textContent = value;
|
||
card.querySelector('div > span').textContent = label;
|
||
card.querySelector('small').textContent = note;
|
||
kpis.append(card);
|
||
}
|
||
const donut = $('#rh-donut'); donut.textContent = '';
|
||
const hasConversion = Number.isFinite(internet.conv);
|
||
donut.style.setProperty('--rh-progress', hasConversion ? Math.max(0, Math.min(100, internet.conv)) + '%' : '0%');
|
||
donut.append(el('strong', { textContent: rhPercent(internet.conv) }), el('span', { textContent: 'Конверсия' }));
|
||
donut.setAttribute('aria-label', `Конверсия интернет-каналов: ${rhPercent(internet.conv)}`);
|
||
const facts = $('#rh-internet'); facts.textContent = '';
|
||
facts.append(el('h3', { textContent: 'Интернет-каналы' }));
|
||
for (const [label, value] of [['Лиды', rhNumber(internet.leads)], ['Услуги', rhNumber(internet.services, 1)], ['Выручка', rhMoney(internet.revenue)]]) {
|
||
const row = el('div'); row.append(el('span', { textContent: label }), el('strong', { textContent: value })); facts.append(row);
|
||
}
|
||
const check = $('#rh-control'); check.textContent = '';
|
||
const hasTotal = control.has_total && Number.isFinite(control.revenue) && Number.isFinite(control.revenue_total);
|
||
const diff = hasTotal ? Math.round((control.revenue - control.revenue_total) * 100) / 100 : null;
|
||
for (const [label, value] of [['По строкам 1С', rhMoney(control.revenue)], ['Строка «Итого»', hasTotal ? rhMoney(control.revenue_total) : 'Нет в файле'], ['Расхождение', diff === null ? '—' : rhNumber(diff, 2) + ' ₽']]) {
|
||
const row = el('div', { className: 'rh-check-row' }); row.append(el('span', { textContent: label }), el('strong', { textContent: value })); check.append(row);
|
||
}
|
||
const state = diff === null ? 'unknown' : diff === 0 ? 'ok' : 'warning';
|
||
const note = el('p', { className: 'rh-check-note rh-check-note--' + state });
|
||
note.innerHTML = rhIcon(state === 'ok' ? 'check' : 'info');
|
||
note.append(el('span', { textContent: diff === null ? 'В книге нет строки «Итого» для сверки.' : diff === 0 ? 'Выручка совпадает с итогом 1С' : 'Есть расхождение — проверьте выгрузку 1С' }));
|
||
check.append(note);
|
||
ReportsHistory.ranking('#rh-leads', s.channels, 'leads');
|
||
ReportsHistory.ranking('#rh-revenue', s.channels, 'revenue');
|
||
},
|
||
|
||
ranking(selector, channels, metric) {
|
||
const box = $(selector); box.textContent = '';
|
||
const sorted = channels.filter((c) => Number.isFinite(c[metric])).slice().sort((a, b) => b[metric] - a[metric]);
|
||
const max = Math.max(0, ...sorted.map((c) => c[metric]));
|
||
if (!sorted.length) { box.append(el('li', { className: 'rh-ranking-empty', textContent: 'В этом отчёте нет данных по каналам.' })); return; }
|
||
sorted.forEach((c, index) => {
|
||
const row = el('li', { className: 'rh-rank' });
|
||
const [start, end, soft] = rhChannelColor(c.name);
|
||
row.style.setProperty('--rank-start', start);
|
||
row.style.setProperty('--rank-end', end);
|
||
row.style.setProperty('--rank-soft', soft);
|
||
row.innerHTML = '<span class="rh-rank-number"></span><div class="rh-rank-data"><div class="rh-rank-title"><span></span><strong></strong></div><div class="rh-track"><i></i></div><small></small></div>';
|
||
row.querySelector('.rh-rank-number').textContent = index + 1;
|
||
row.querySelector('.rh-rank-title span').textContent = c.name;
|
||
row.querySelector('strong').textContent = metric === 'revenue' ? rhMoney(c.revenue) : rhNumber(c.leads);
|
||
row.querySelector('i').style.width = (max > 0 ? Math.max(0, c[metric]) / max * 100 : 0) + '%';
|
||
row.querySelector('small').textContent = metric === 'revenue' ? 'Средний чек: ' + rhMoney(c.avg_check) : 'Конверсия: ' + rhPercent(c.conv);
|
||
box.append(row);
|
||
});
|
||
},
|
||
};
|
||
|
||
for (const icon of document.querySelectorAll('[data-rh-icon]')) icon.innerHTML = rhIcon(icon.dataset.rhIcon);
|
||
$('#rh-search').addEventListener('input', debounce(() => ReportsHistory.render(), 200));
|
||
for (const id of ['rh-status', 'rh-from', 'rh-to']) $('#' + id).onchange = () => ReportsHistory.render();
|
||
$('#rh-report').onchange = () => { ReportsHistory.selected = $('#rh-report').value; ReportsHistory.render(); };
|
||
$('#rh-reset').onclick = () => { for (const id of ['rh-search', 'rh-status', 'rh-from', 'rh-to']) $('#' + id).value = ''; ReportsHistory.render(); };
|
||
$('#rh-refresh').onclick = async () => { $('#rh-refresh').disabled = true; try { await Reports.load(); } finally { $('#rh-refresh').disabled = false; } };
|
||
$('#rh-new').onclick = () => Reports.view('new');
|