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