516 lines
30 KiB
JavaScript
516 lines
30 KiB
JavaScript
/* «Отчёты»: загрузка книги Excel, ход расчёта на сервере, история и шаблоны.
|
||
Проценты и время этапов приходят с сервера — это настоящий расчёт, а не анимация. */
|
||
|
||
const RP_KINDS = {
|
||
phone_match: { title: 'Отчёт по каналам', file: true,
|
||
text: 'Книга «отчёт по каналам»: выгрузка 1С и листы каналов с номерами (все номера, сайт, инстаграм, яндекс, 2гис…). Лиды, пациенты, конверсия, услуги, выручка и средний чек по каждому каналу — как на листе «ОТЧЁТ».' },
|
||
};
|
||
const RP_STAGES = [
|
||
['Загрузка файла', 'Файл принят сервером'],
|
||
['Проверка данных', 'Читаем листы книги и находим выгрузку 1С'],
|
||
['Обработка данных', 'Сопоставляем номера 1С с каналами'],
|
||
['Формирование отчёта', 'Собираем таблицу для Excel'],
|
||
['Завершение', 'Сохраняем результат'],
|
||
];
|
||
const RP_STATUS = { running: ['В обработке', 'sky'], done: ['Готов', ''], failed: ['Ошибка', 'off'], cancelled: ['Отменён', 'grey'] };
|
||
const RP_ICONS = {
|
||
doc: '<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.5v5h5M12.2 11.5v6M9.2 14.5h6"/>',
|
||
file: '<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.5v5h5"/>',
|
||
refresh: '<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"/>',
|
||
play: '<path d="M8 5.5v13l10-6.5z"/>',
|
||
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
|
||
info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
|
||
x: '<path d="M6 6l12 12M18 6 6 18"/>',
|
||
down: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14"/>',
|
||
dots: '<circle cx="6" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="18" cy="12" r="1.3"/>',
|
||
check: '<path d="m6.5 12.5 3.5 3.5 7.5-8"/>',
|
||
};
|
||
const rpIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${RP_ICONS[n]}</svg>`;
|
||
const rpSize = (b) => (b >= 1 << 20 ? (b / (1 << 20)).toFixed(1).replace('.', ',') + ' МБ' : Math.max(1, Math.round(b / 1024)) + ' КБ');
|
||
const rpDur = (ms) => (ms < 1000 ? '0,' + String(Math.max(1, Math.round(ms / 10))).padStart(2, '0') + ' с'
|
||
: ms < 60000 ? (ms / 1000).toFixed(1).replace('.', ',') + ' с' : new Date(ms).toISOString().slice(11, 19));
|
||
const rpClock = (ms) => new Date(Math.max(0, ms)).toISOString().slice(11, 19);
|
||
|
||
const Reports = {
|
||
file: null,
|
||
filePeriod: null,
|
||
periodLoading: false,
|
||
periodRequest: 0,
|
||
autoOpenReport: null,
|
||
current: null, // задание, которое показывает правая карточка
|
||
list: [],
|
||
timer: null,
|
||
poll: null,
|
||
|
||
view(name) {
|
||
// Temporarily hidden tabs stay unreachable through secondary UI actions.
|
||
const tab = [...document.querySelectorAll('#rp-tabs button')].find((b) => b.dataset.view === name);
|
||
if (!tab || tab.hidden) name = 'new';
|
||
$('#tab-reports').classList.toggle('rp-history-view', name === 'history');
|
||
document.body.classList.toggle('reports-history-view', name === 'history' && location.hash === '#reports');
|
||
const heads = { new: 'Отчёты', history: 'История отчётов', pbx: 'Звонки АТС', weekly: WeeklyDashboard.mode === 'months' ? 'Дашборд по месяцам' : 'Дашборд по неделям' };
|
||
$('#tab-reports .rp-head h1').textContent = heads[name];
|
||
for (const b of document.querySelectorAll('#rp-tabs button')) b.setAttribute('aria-selected', String(b.dataset.view === name && (!b.dataset.mode || b.dataset.mode === WeeklyDashboard.mode)));
|
||
for (const v of document.querySelectorAll('[data-rp-view]')) v.hidden = v.dataset.rpView !== name;
|
||
if (name === 'history') ReportsHistory.render();
|
||
if (name === 'pbx') Pbx.open();
|
||
if (name === 'weekly') WeeklyFullscreen.open();
|
||
},
|
||
|
||
async open() {
|
||
await Reports.load();
|
||
const running = Reports.list.find((r) => r.status === 'running');
|
||
if (running) Reports.watch(running.id);
|
||
else if (!Reports.current) Reports.renderProgress(null);
|
||
},
|
||
|
||
async load() {
|
||
try {
|
||
const r = await api(ReportClinic.q('/admin/reports'));
|
||
Reports.list = r.data || [];
|
||
Reports.loadError = '';
|
||
} catch (e) { Reports.loadError = e.message; toast(e.message, true); }
|
||
Reports.renderTable('#rp-recent', Reports.list.slice(0, 5));
|
||
if (!$('[data-rp-view="history"]').hidden) ReportsHistory.render();
|
||
},
|
||
|
||
/* ---------- файл ---------- */
|
||
setFile(f) {
|
||
if (!f) return;
|
||
const ext = (f.name.split('.').pop() || '').toLowerCase();
|
||
if (!['xlsx', 'csv'].includes(ext)) { toast('Нужен файл .xlsx или .csv', true); return; }
|
||
if (f.size > 10 * 1024 * 1024) { toast('Файл больше 10 МБ', true); return; }
|
||
Reports.file = f;
|
||
Reports.renderDrop();
|
||
Reports.detectFilePeriod(f);
|
||
},
|
||
async detectFilePeriod(file) {
|
||
const request = ++Reports.periodRequest;
|
||
Reports.filePeriod = null;
|
||
Reports.periodLoading = true;
|
||
const hint = $('#rp-file-period');
|
||
hint.hidden = false;
|
||
hint.dataset.state = 'loading';
|
||
hint.textContent = 'Читаем период из строки 2 файла 1С…';
|
||
Reports.syncStart();
|
||
try {
|
||
const data = new FormData(); data.append('file', file);
|
||
const response = await fetch('/api/v1/admin/reports/period', {method: 'POST', body: data, credentials: 'same-origin'});
|
||
const result = await response.json();
|
||
if (!response.ok) throw new Error(result.error?.message || 'Не удалось прочитать период');
|
||
if (request !== Reports.periodRequest) return;
|
||
Reports.filePeriod = result.data;
|
||
hint.dataset.state = 'ready';
|
||
hint.textContent = `${result.data.mode === 'months' ? 'Месячный' : 'Недельный'} отчёт · ${ReportClinic.title} · ${result.data.label}`;
|
||
Reports.renderPartners();
|
||
} catch (error) {
|
||
if (request !== Reports.periodRequest) return;
|
||
hint.dataset.state = 'warning';
|
||
hint.textContent = `${error.message}. В строке 2 файла должна быть запись «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ».`;
|
||
} finally {
|
||
if (request === Reports.periodRequest) {
|
||
Reports.periodLoading = false;
|
||
Reports.syncStart();
|
||
}
|
||
}
|
||
},
|
||
openDashboard(period) {
|
||
WeeklyDashboard.mode = period.mode;
|
||
$('#wk-filter-from').value = ''; $('#wk-filter-to').value = '';
|
||
Reports.view('weekly');
|
||
},
|
||
renderDrop() {
|
||
const f = Reports.file, box = $('#rp-drop');
|
||
box.classList.toggle('has-file', !!f);
|
||
if (f) {
|
||
box.innerHTML = `<span class="rp-drop__icon">${rpIcon('doc')}</span><b>Файл загружен</b>
|
||
<span class="rp-drop__name"><span></span><i class="rp-ok">${rpIcon('check')}</i></span>
|
||
<small>${rpSize(f.size)} · ${f.name.split('.').pop().toUpperCase()}</small>`;
|
||
box.querySelector('.rp-drop__name span').textContent = f.name;
|
||
} else {
|
||
box.innerHTML = `<span class="rp-drop__icon">${rpIcon('doc')}</span><b>Перетащите книгу Excel сюда</b>
|
||
<small>или нажмите, чтобы выбрать файл · .xlsx или .csv до 10 МБ</small>`;
|
||
}
|
||
$('#rp-replace').hidden = !f;
|
||
Reports.syncStart();
|
||
},
|
||
// Необязательные файлы кабинетов: сервер сам их скачать не может (кабинеты отвечают 403 с VPS).
|
||
sber: null,
|
||
prodoc: [],
|
||
setSber(f) {
|
||
if (!f) return;
|
||
if (!/\.xls$/i.test(f.name)) { toast('СберЗдоровье: нужен файл .xls из кабинета', true); return; }
|
||
Reports.sber = f;
|
||
Reports.renderPartners();
|
||
},
|
||
addProdoc(list) {
|
||
for (const f of list) {
|
||
if (!/\.xlsx$/i.test(f.name)) { toast('ПроДокторов: нужен файл .xlsx из кабинета', true); continue; }
|
||
if (Reports.prodoc.some((x) => x.name === f.name && x.size === f.size)) continue;
|
||
if (Reports.prodoc.length >= 6) { toast('ПроДокторов: не больше 6 файлов (на месяц — онлайн-запись и телефонная запись)', true); break; }
|
||
Reports.prodoc.push(f);
|
||
}
|
||
Reports.renderPartners();
|
||
},
|
||
renderPartners() {
|
||
const draw = (box, files, remove) => {
|
||
box.replaceChildren(...files.map((f, i) => {
|
||
const row = el('div', { className: 'rp-partner__file' });
|
||
row.append(el('span', { textContent: `${f.name} · ${rpSize(f.size)}` }));
|
||
const x = el('button', { type: 'button', title: 'Убрать файл', textContent: '×' });
|
||
x.setAttribute('aria-label', 'Убрать файл ' + f.name);
|
||
x.onclick = () => remove(i);
|
||
row.append(x);
|
||
return row;
|
||
}));
|
||
};
|
||
draw($('#rp-sber-files'), Reports.sber ? [Reports.sber] : [], () => { Reports.sber = null; Reports.renderPartners(); });
|
||
draw($('#rp-prodoc-files'), Reports.prodoc, (i) => { Reports.prodoc.splice(i, 1); Reports.renderPartners(); });
|
||
$('#rp-sber-add').textContent = Reports.sber ? 'Заменить файл' : 'Добавить файл';
|
||
// какие месяцы нужны ПроДокторову для найденного в файле 1С периода
|
||
const need = $('#rp-prodoc-need'), p = Reports.filePeriod;
|
||
const months = [];
|
||
if (p && p.from && p.to) {
|
||
const names = ['январь', 'февраль', 'март', 'апрель', 'май', 'июнь', 'июль', 'август', 'сентябрь', 'октябрь', 'ноябрь', 'декабрь'];
|
||
const [fy, fm] = p.from.split('-').map(Number), [ty, tm] = p.to.split('-').map(Number);
|
||
for (let y = fy, m = fm; y < ty || (y === ty && m <= tm); m++) {
|
||
if (m > 12) { m = 1; y++; }
|
||
months.push(`${names[m - 1]} ${y}`);
|
||
if (months.length > 13) break;
|
||
}
|
||
}
|
||
need.textContent = months.length ? 'Для этого периода нужны файлы за: ' + months.join(', ') : '';
|
||
},
|
||
syncStart() {
|
||
const running = Reports.current && Reports.current.status === 'running';
|
||
// АТС и Битрикс24 берут даты из файла, поэтому без найденного периода запуск недоступен.
|
||
$('#rp-start').disabled = !Reports.file || running || Reports.periodLoading || !Reports.filePeriod;
|
||
},
|
||
|
||
/* ---------- запуск и ход ---------- */
|
||
async start(kind = 'phone_match') {
|
||
const fd = new FormData();
|
||
fd.append('kind', kind);
|
||
fd.append('clinic', ReportClinic.key);
|
||
if (RP_KINDS[kind].file) {
|
||
if (!Reports.file) { toast('Сначала выберите файл', true); return; }
|
||
fd.append('file', Reports.file);
|
||
// Всегда весь отчёт: номера АТС МегаФон и Битрикс24 за период из файла.
|
||
fd.append('source', 'all');
|
||
fd.append('bitrix', '1');
|
||
if (Reports.sber) fd.append('sber_file', Reports.sber);
|
||
for (const f of Reports.prodoc) fd.append('prodoctorov_file', f);
|
||
}
|
||
$('#rp-start').disabled = true;
|
||
try {
|
||
const res = await fetch('/api/v1/admin/reports', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||
const j = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(j.error?.message || 'Не удалось запустить отчёт');
|
||
Reports.view('new');
|
||
Reports.autoOpenReport = j.data.id;
|
||
Reports.watch(j.data.id);
|
||
Reports.load();
|
||
} catch (e) { toast(e.message, true); Reports.syncStart(); }
|
||
},
|
||
|
||
// Ссылка «только просмотр» на дашборд выбранной клиники — для маркетолога без входа в админку.
|
||
async shareDialog() {
|
||
const clinic = ReportClinic.title;
|
||
const dlg = el('dialog', { className: 'rp-share' });
|
||
const close = () => { dlg.close(); dlg.remove(); };
|
||
const head = el('h2', { textContent: `Ссылка на дашборд «${clinic}»` });
|
||
const note = el('p', { className: 'muted', textContent: 'По ссылке открывается только дашборд по неделям и месяцам этой клиники, без входа и без права что-либо менять. Данных пациентов там нет. Кто получил ссылку — тот и видит, поэтому отправляйте её только маркетологу.' });
|
||
const state = el('p', { className: 'rp-share__state', textContent: 'Проверяем…' });
|
||
const out = el('div', { className: 'rp-share__out', hidden: true });
|
||
const urlBox = el('input', { type: 'text', readOnly: true, className: 'rp-share__url', ariaLabel: 'Ссылка для маркетолога' });
|
||
const copy = el('button', { type: 'button', className: 'px-btn', textContent: 'Скопировать' });
|
||
copy.onclick = async () => { try { await navigator.clipboard.writeText(urlBox.value); toast('Ссылка скопирована'); } catch { urlBox.select(); } };
|
||
out.append(urlBox, copy, el('p', { className: 'muted', textContent: 'Ссылка показывается один раз — сохраните её сейчас. Потом можно только выпустить новую.' }));
|
||
const issue = el('button', { type: 'button', className: 'px-btn' });
|
||
const revoke = el('button', { type: 'button', className: 'bn-edit', textContent: 'Отключить ссылку', hidden: true });
|
||
const done = el('button', { type: 'button', className: 'bn-edit', textContent: 'Закрыть' });
|
||
done.onclick = close;
|
||
const actions = el('div', { className: 'rp-share__actions' });
|
||
actions.append(issue, revoke, done);
|
||
dlg.append(head, note, state, out, actions);
|
||
dlg.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
|
||
document.body.append(dlg);
|
||
dlg.showModal();
|
||
const df = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
const show = (info) => {
|
||
state.textContent = info.exists
|
||
? `Ссылка выпущена ${df.format(new Date(info.created_at))}` + (info.last_used_at ? `, последний раз открывали ${df.format(new Date(info.last_used_at))}` : ', ещё не открывали')
|
||
: 'Ссылки пока нет.';
|
||
issue.textContent = info.exists ? 'Выпустить новую (старая перестанет работать)' : 'Создать ссылку';
|
||
revoke.hidden = !info.exists;
|
||
};
|
||
try { show((await api(ReportClinic.q('/admin/share-link'))).data); } catch (e) { state.textContent = e.message; }
|
||
issue.onclick = async () => {
|
||
issue.disabled = true;
|
||
try {
|
||
const r = await api(ReportClinic.q('/admin/share-link'), { method: 'POST' });
|
||
urlBox.value = location.origin + r.data.path;
|
||
out.hidden = false;
|
||
show((await api(ReportClinic.q('/admin/share-link'))).data);
|
||
urlBox.select();
|
||
} catch (e) { toast(e.message, true); } finally { issue.disabled = false; }
|
||
};
|
||
revoke.onclick = async () => {
|
||
if (!confirm('Отключить ссылку? Маркетолог больше не сможет открыть дашборд, пока вы не выпустите новую.')) return;
|
||
try {
|
||
await api(ReportClinic.q('/admin/share-link'), { method: 'DELETE' });
|
||
out.hidden = true; urlBox.value = '';
|
||
show({ exists: false });
|
||
toast('Ссылка отключена');
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
},
|
||
|
||
// Другая клиника: свои отчёты, дашборд и звонки. Выбранные файлы сбрасываются, чтобы книга 1С
|
||
// одной клиники не ушла в отчёт другой.
|
||
onClinicChange() {
|
||
clearTimeout(Reports.poll);
|
||
Reports.current = null;
|
||
Reports.autoOpenReport = null;
|
||
Reports.periodRequest++;
|
||
Reports.periodLoading = false;
|
||
Reports.file = null; Reports.filePeriod = null; Reports.sber = null; Reports.prodoc = [];
|
||
$('#rp-file-period').hidden = true;
|
||
Reports.renderDrop();
|
||
Reports.renderPartners();
|
||
Reports.renderProgress(null);
|
||
Reports.open();
|
||
if (!$('[data-rp-view="weekly"]').hidden) WeeklyDashboard.load();
|
||
if (!$('[data-rp-view="pbx"]').hidden) Pbx.open();
|
||
},
|
||
|
||
watch(id) {
|
||
clearTimeout(Reports.poll);
|
||
const tick = async () => {
|
||
try {
|
||
const r = await api('/admin/reports/' + id);
|
||
Reports.current = r.data;
|
||
Reports.renderProgress(r.data);
|
||
if (r.data.status === 'running') { Reports.poll = setTimeout(tick, 400); return; }
|
||
Reports.load();
|
||
if (Reports.autoOpenReport === r.data.id) {
|
||
Reports.autoOpenReport = null;
|
||
const dashboard = r.data.summary?.dashboard;
|
||
if (r.data.status === 'done' && dashboard?.id) Reports.openDashboard(dashboard);
|
||
}
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
tick();
|
||
},
|
||
|
||
async cancel(id) {
|
||
try {
|
||
await api('/admin/reports/' + id + '/cancel', { method: 'POST' });
|
||
toast('Отчёт отменён');
|
||
} catch (e) { toast(e.message, true); }
|
||
if (Reports.current?.id === id) Reports.watch(id);
|
||
Reports.load();
|
||
},
|
||
|
||
renderProgress(r) {
|
||
clearInterval(Reports.timer);
|
||
const card = $('#rp-progress');
|
||
const st = r ? r.status : 'idle';
|
||
card.dataset.state = st;
|
||
const pct = r ? (st === 'done' ? 100 : r.progress) : 0;
|
||
const titles = { idle: 'Отчёт ещё не запущен', running: 'Выполняется отчёт', done: 'Отчёт готов', failed: 'Отчёт не сформирован', cancelled: 'Отчёт отменён' };
|
||
const subs = {
|
||
idle: 'Загрузите книгу слева и нажмите «Запустить отчёт».',
|
||
running: 'Идёт обработка данных на сервере — процент показывает, сколько строк уже обработано.',
|
||
done: r ? `${RP_KINDS[r.kind]?.title || r.title}${r.source_name ? ' · ' + r.source_name : ''}` : '',
|
||
failed: r?.error || 'Расчёт остановился с ошибкой.',
|
||
cancelled: 'Расчёт остановлен, результат не сохранён.',
|
||
};
|
||
$('#rp-title').textContent = titles[st];
|
||
$('#rp-sub').textContent = subs[st];
|
||
// кольцо
|
||
const C = 2 * Math.PI * 70;
|
||
$('#rp-ring-bar').setAttribute('stroke-dasharray', `${(pct / 100) * C} ${C}`);
|
||
$('#rp-ring-bar').style.opacity = pct ? 1 : 0; // скруглённый кончик полосы виден и при 0%
|
||
$('#rp-pct').textContent = pct + '%';
|
||
$('#rp-pct-label').textContent = st === 'done' ? 'Готово' : st === 'running' ? 'Выполнено' : st === 'idle' ? 'Ожидание' : 'Остановлено';
|
||
// время
|
||
const clock = $('#rp-clock');
|
||
clock.hidden = !r;
|
||
if (r) {
|
||
const t0 = new Date(r.created_at).getTime();
|
||
const upd = () => {
|
||
const end = r.finished_at ? new Date(r.finished_at).getTime() : Date.now();
|
||
clock.querySelector('span').textContent = st === 'running' ? 'Прошло ' + rpClock(end - t0)
|
||
: 'Заняло ' + (end - t0 < 60000 ? rpDur(end - t0) : rpClock(end - t0));
|
||
};
|
||
upd();
|
||
if (st === 'running') Reports.timer = setInterval(upd, 1000);
|
||
}
|
||
// этапы
|
||
const times = r ? (Array.isArray(r.stages) ? r.stages : []) : [];
|
||
const ol = $('#rp-stages'); ol.textContent = '';
|
||
RP_STAGES.forEach(([name, hint], i) => {
|
||
let s = 'wait';
|
||
if (r) {
|
||
if (i < times.length || st === 'done') s = 'done';
|
||
else if (i === r.stage && st === 'running') s = 'now';
|
||
else if (i === r.stage && st !== 'running') s = 'stop';
|
||
}
|
||
const li = el('li', { className: 'rp-stage ' + s });
|
||
li.innerHTML = `<span class="rp-stage__mark">${s === 'done' ? rpIcon('check') : ''}</span>
|
||
<div><b>${name}</b><small>${s === 'now' ? hint + '…' : s === 'wait' ? 'Ожидание…' : s === 'stop' ? (st === 'failed' ? 'Ошибка на этом этапе' : 'Остановлено') : ''}</small></div>
|
||
<time>${s === 'done' && times[i] != null ? rpDur(times[i]) : ''}</time>`;
|
||
ol.append(li);
|
||
});
|
||
// итоги и действия
|
||
const out = $('#rp-result'); out.textContent = '';
|
||
out.hidden = st !== 'done' && st !== 'running';
|
||
if (st === 'running') {
|
||
const b = el('button', { type: 'button', className: 'rp-btn-ghost' });
|
||
b.innerHTML = rpIcon('x') + '<span>Отменить</span>';
|
||
b.onclick = () => Reports.cancel(r.id);
|
||
out.append(b);
|
||
}
|
||
if (st === 'done') {
|
||
const a = el('a', { className: 'rp-download', href: `/api/v1/admin/reports/${r.id}/download` });
|
||
a.innerHTML = rpIcon('down') + `<span>Скачать отчёт · CSV для Excel · ${rpSize(r.result_size)}</span>`;
|
||
out.append(a);
|
||
const s = r.summary || {};
|
||
if (s.dashboard?.id) {
|
||
const open = el('button', {type: 'button', className: 'rp-download'});
|
||
open.textContent = `Открыть ${s.dashboard.mode === 'months' ? 'месячный' : 'недельный'} отчёт · ${s.dashboard.label}`;
|
||
open.onclick = () => Reports.openDashboard(s.dashboard);
|
||
out.append(open);
|
||
} else if (s.dashboard?.warning) {
|
||
out.append(el('p', {className: 'rp-control bad', textContent: s.dashboard.warning}));
|
||
}
|
||
if (r.kind === 'phone_match' && s.channels) {
|
||
const rub = (v) => Math.round(v).toLocaleString('ru-RU') + ' ₽';
|
||
const c = s.control || {};
|
||
const diff = c.has_total ? Math.round((c.revenue - c.revenue_total) * 100) / 100 : null;
|
||
out.append(el('p', { className: 'rp-control ' + (diff === 0 ? 'ok' : diff === null ? '' : 'bad'),
|
||
textContent: diff === null ? 'Строки «Итого» в выгрузке 1С нет — сверить выручку не с чем.'
|
||
: diff === 0 ? `Контроль базы: выручка по строкам 1С совпала с «Итого» — ${rub(c.revenue)}.`
|
||
: `Контроль базы: расхождение с «Итого» 1С — ${rub(diff)}. Проверьте выгрузку.` }));
|
||
const t = el('table', { className: 'rp-sum' });
|
||
t.innerHTML = '<thead><tr><th>Канал</th><th>Лиды</th><th>Пациентов</th><th>Конверсия</th><th>Выручка</th><th>Средний чек</th></tr></thead>';
|
||
const tb = el('tbody');
|
||
const pctf = (v) => v.toFixed(1).replace('.', ',') + '%';
|
||
for (const x of s.channels.concat([s.internet])) {
|
||
const tr = el('tr', { className: x === s.internet ? 'rp-sum__total' : '' });
|
||
tr.append(el('td', { textContent: x.name }), el('td', { textContent: x.leads }), el('td', { textContent: x.patients }),
|
||
el('td', { textContent: pctf(x.conv) }), el('td', { textContent: rub(x.revenue) }), el('td', { textContent: rub(x.avg_check) }));
|
||
tb.append(tr);
|
||
}
|
||
t.append(tb); out.append(t);
|
||
if (s.bitrix) out.append(el('p', { className: 'rp-control', textContent:
|
||
`Номера из Битрикс24: записей ${s.bitrix.records}, с номером ${s.bitrix.phones}` +
|
||
(s.bitrix.skipped ? `, не из DANA пропущено ${s.bitrix.skipped}.` : '.') }));
|
||
if (s.prodoctorov) out.append(el('p', { className: 'rp-control', textContent:
|
||
`ПроДокторов: записей (онлайн и по телефону) по дате создания ${s.prodoctorov.records}, уникальных номеров до исключения заглушек ${s.prodoctorov.phones}.` }));
|
||
if (s.sberhealth) out.append(el('p', { className: 'rp-control', textContent:
|
||
`СберЗдоровье: записей по дате создания ${s.sberhealth.records}, уникальных номеров до исключения заглушек ${s.sberhealth.phones}; строк выгрузки проверено ${s.sberhealth.export_records}.` }));
|
||
}
|
||
}
|
||
Reports.syncStart();
|
||
},
|
||
|
||
/* ---------- таблицы ---------- */
|
||
renderTable(sel, list) {
|
||
const tb = $(sel); tb.textContent = '';
|
||
if (!list.length) {
|
||
const tr = el('tr'); tr.append(el('td', { colSpan: 5, className: 'muted dr-empty', textContent: 'Отчётов пока нет — запустите первый.' })); tb.append(tr);
|
||
return;
|
||
}
|
||
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 list) {
|
||
const tr = el('tr');
|
||
const name = el('td');
|
||
const nb = el('button', { type: 'button', className: 'dr-name', textContent: RP_KINDS[r.kind]?.title || r.title });
|
||
nb.onclick = () => { Reports.view('new'); Reports.watch(r.id); window.scrollTo({ top: 0, behavior: 'smooth' }); };
|
||
name.append(nb);
|
||
const file = el('td', { className: 'rp-file' });
|
||
file.innerHTML = rpIcon('file');
|
||
file.append(r.source_name || r.result_name || '—');
|
||
const [label, cls] = RP_STATUS[r.status] || [r.status, 'grey'];
|
||
const st = el('td'); st.append(el('span', { className: 'pill dot ' + cls, textContent: label }));
|
||
if (r.status === 'failed' && r.error) st.title = r.error;
|
||
const act = el('td', { className: 'dr-actions' });
|
||
if (r.status === 'running') {
|
||
const b = el('button', { type: 'button', className: 'bn-edit' });
|
||
b.innerHTML = rpIcon('x') + '<span>Отменить</span>';
|
||
b.onclick = () => Reports.cancel(r.id);
|
||
act.append(b);
|
||
} else if (r.status === 'done') {
|
||
const a = el('a', { className: 'bn-edit', href: `/api/v1/admin/reports/${r.id}/download` });
|
||
a.innerHTML = rpIcon('down') + '<span>Скачать</span>';
|
||
act.append(a);
|
||
}
|
||
const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' });
|
||
more.setAttribute('aria-label', 'Ещё действия с отчётом');
|
||
more.setAttribute('aria-haspopup', 'menu');
|
||
more.innerHTML = rpIcon('dots');
|
||
more.onclick = (e) => { e.stopPropagation(); Reports.menu(r, more); };
|
||
act.append(more);
|
||
tr.append(name, file, el('td', { className: 'muted', textContent: df.format(new Date(r.created_at)).replace(',', '') }), st, act);
|
||
tb.append(tr);
|
||
}
|
||
},
|
||
|
||
menu(r, anchor) {
|
||
Doctors.closeMenu();
|
||
const m = el('div', { className: 'menu', id: 'dr-menu', role: 'menu' });
|
||
const item = (text, fn, danger) => {
|
||
const x = el('button', { type: 'button', role: 'menuitem', className: danger ? 'danger' : '', textContent: text });
|
||
x.onclick = () => { Doctors.closeMenu(); fn(); };
|
||
m.append(x);
|
||
};
|
||
item('Показать ход и итоги', () => { Reports.view('new'); Reports.watch(r.id); window.scrollTo({ top: 0, behavior: 'smooth' }); });
|
||
if (r.status !== 'running') item('Удалить отчёт', () => Reports.remove(r), true);
|
||
document.body.append(m);
|
||
const b = anchor.getBoundingClientRect();
|
||
m.style.top = (b.bottom + window.scrollY + 6) + 'px';
|
||
m.style.left = Math.max(12, b.right + window.scrollX - m.offsetWidth) + 'px';
|
||
m.querySelector('button').focus();
|
||
anchor.setAttribute('aria-expanded', 'true');
|
||
Doctors.menuAnchor = anchor;
|
||
},
|
||
|
||
async remove(r) {
|
||
if (!confirm('Удалить отчёт «' + (RP_KINDS[r.kind]?.title || r.title) + '»? Файл результата тоже удалится.')) return;
|
||
try {
|
||
await api('/admin/reports/' + r.id, { method: 'DELETE' });
|
||
toast('Отчёт удалён');
|
||
if (Reports.current?.id === r.id) { Reports.current = null; Reports.renderProgress(null); }
|
||
Reports.load();
|
||
} catch (e) { toast(e.message, true); }
|
||
},
|
||
|
||
};
|
||
|
||
/* ---------- обработчики ---------- */
|
||
$('#rp-start').insertAdjacentHTML('afterbegin', rpIcon('play'));
|
||
$('#rp-replace').insertAdjacentHTML('afterbegin', rpIcon('refresh'));
|
||
$('#rp-clock').insertAdjacentHTML('afterbegin', rpIcon('clock'));
|
||
$('#rp-start').onclick = () => Reports.start('phone_match');
|
||
$('#rp-sber-add').onclick = () => $('#rp-sber-input').click();
|
||
$('#rp-sber-input').onchange = () => { Reports.setSber($('#rp-sber-input').files[0]); $('#rp-sber-input').value = ''; };
|
||
$('#rp-prodoc-add').onclick = () => $('#rp-prodoc-input').click();
|
||
$('#rp-prodoc-input').onchange = () => { Reports.addProdoc([...$('#rp-prodoc-input').files]); $('#rp-prodoc-input').value = ''; };
|
||
Reports.renderPartners();
|
||
$('#rp-replace').onclick = () => $('#rp-input').click();
|
||
$('#rp-input').onchange = () => { Reports.setFile($('#rp-input').files[0]); $('#rp-input').value = ''; };
|
||
{
|
||
const drop = $('#rp-drop');
|
||
drop.onclick = () => $('#rp-input').click();
|
||
drop.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#rp-input').click(); } };
|
||
drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); });
|
||
drop.addEventListener('dragleave', () => drop.classList.remove('over'));
|
||
drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); Reports.setFile(e.dataTransfer.files[0]); });
|
||
}
|
||
for (const b of document.querySelectorAll('#rp-tabs button')) b.onclick = () => b.dataset.mode
|
||
? Reports.openDashboard({mode: b.dataset.mode}) : Reports.view(b.dataset.view);
|
||
$('#rp-all').onclick = () => Reports.view('history');
|
||
Reports.renderDrop();
|