/* «Отчёты»: загрузка книги 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: '',
file: '',
refresh: '',
play: '',
clock: '',
info: '',
x: '',
down: '',
dots: '',
check: '',
};
const rpIcon = (n) => ``;
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 = `${rpIcon('doc')}Файл загружен
${rpIcon('check')}
${rpSize(f.size)} · ${f.name.split('.').pop().toUpperCase()}`;
box.querySelector('.rp-drop__name span').textContent = f.name;
} else {
box.innerHTML = `${rpIcon('doc')}Перетащите книгу Excel сюда
или нажмите, чтобы выбрать файл · .xlsx или .csv до 10 МБ`;
}
$('#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 = `${s === 'done' ? rpIcon('check') : ''}
${name}${s === 'now' ? hint + '…' : s === 'wait' ? 'Ожидание…' : s === 'stop' ? (st === 'failed' ? 'Ошибка на этом этапе' : 'Остановлено') : ''}
`;
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') + 'Отменить';
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') + `Скачать отчёт · CSV для Excel · ${rpSize(r.result_size)}`;
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 = '| Канал | Лиды | Пациентов | Конверсия | Выручка | Средний чек |
';
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') + 'Отменить';
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') + 'Скачать';
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();