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

516 lines
30 KiB
JavaScript
Raw Permalink 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.

/* «Отчёты»: загрузка книги 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();