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

159 lines
12 KiB
JavaScript
Raw 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 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');