/* История: показатели одной обработанной книги, без суммирования разных выгрузок. */ const RH_ICONS = { search: '', calendar: '', channels: '', wallet: '', receipt: '', }; const rhIcon = (name) => ``; 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 = `${rhIcon(icon)}
`; 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 = '
'; 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');