/* «Отчёты» → «Звонки АТС»: подключение Виртуальной АТС МегаФон, загрузка звонков, отчёт по звонкам и привязка номеров клиники к каналам. Ключ API — только на сервере (.env). */ const pxISO = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; const pxDate = (iso) => { const [y, m, d] = iso.split('-').map(Number); return new Date(y, m - 1, d); }; const pxRu = (iso) => (iso ? iso.split('-').reverse().join('.') : 'дд.мм.гггг'); const pxDur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)} ч ${pad2(Math.round((s % 3600) / 60))} мин` : s >= 60 ? `${Math.round(s / 60)} мин` : `${s} с`); const pxDT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); const pxNum = (n) => Number(n).toLocaleString('ru-RU'); const PX_ICONS = { phone: '', phoneIn: '', phoneOut: '', phoneMiss: '', people: '', callback: '', calendar: '', copy: '', info: '', left: '', right: '', dots: '', up: '', down: '', }; const pxIcon = (name) => ``; document.querySelectorAll('[data-px-icon]').forEach((e) => { e.innerHTML = pxIcon(e.dataset.pxIcon); }); /* ---------- выбор периода: два поля и календарь с выделением диапазона ---------- */ const PX_MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; class PxRange { constructor(box) { this.box = box; this.from = $('#' + box.dataset.from); this.to = $('#' + box.dataset.to); box.innerHTML = '—' + ''; this.pop = box.querySelector('.dp'); this.active = 'from'; box.querySelectorAll('.px-date').forEach((b) => { b.onclick = () => this.toggle(b.dataset.end); }); document.addEventListener('click', (e) => { if (!this.pop.hidden && !box.contains(e.target)) this.close(); }); box.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !this.pop.hidden) { this.close(); box.querySelector('.px-date').focus(); } }); } // значения полей → подписи кнопок sync() { for (const [b, input] of [[this.box.querySelector('[data-end=from]'), this.from], [this.box.querySelector('[data-end=to]'), this.to]]) { b.innerHTML = pxIcon('calendar') + `${pxRu(input.value)}`; b.classList.toggle('empty', !input.value); b.classList.toggle('active', !this.pop.hidden && b.dataset.end === this.active); b.setAttribute('aria-expanded', String(!this.pop.hidden && b.dataset.end === this.active)); b.setAttribute('aria-label', (b.dataset.end === 'from' ? 'Период с ' : 'Период по ') + (input.value ? pxRu(input.value) : 'не выбрано')); } } // Левая и правая кнопки явно выбирают соответствующую границу периода. // Повторный клик по полю не закрывает календарь: он закрывается выбором конца, // кликом снаружи или Escape. Так правое поле нельзя случайно принять за начало. toggle(end) { if (this.pop.hidden) this.open(end); else { this.active = end; const value = end === 'to' ? (this.to.value || this.from.value) : this.from.value; if (value) { const d = pxDate(value); this.month = new Date(d.getFullYear(), d.getMonth(), 1); } this.sync(); this.render(); } } open(end = 'from') { this.active = end; const value = end === 'to' ? (this.to.value || this.from.value) : this.from.value; const base = value ? pxDate(value) : new Date(); this.month = new Date(base.getFullYear(), base.getMonth(), 1); this.pop.hidden = false; this.box.classList.add('open'); this.sync(); this.render(); // на телефоне календарь может открыться ниже края экрана requestAnimationFrame(() => this.pop.scrollIntoView({ block: 'nearest', behavior: 'smooth' })); } close() { this.pop.hidden = true; this.box.classList.remove('open'); this.sync(); } pick(iso) { if (this.active === 'to' && this.from.value) { if (iso < this.from.value) { // Если конец оказался раньше начала, сохраняем обе выбранные даты, // просто располагая их в хронологическом порядке. this.to.value = this.from.value; this.from.value = iso; } else { this.to.value = iso; } this.active = 'from'; this.sync(); this.render(); setTimeout(() => this.close(), 180); } else { this.from.value = iso; if (this.to.value && this.to.value < iso) this.to.value = ''; this.active = 'to'; this.sync(); this.render(); } } render() { const y = this.month.getFullYear(), m = this.month.getMonth(); const first = new Date(y, m, 1), shift = (first.getDay() + 6) % 7; const start = new Date(y, m, 1 - shift), today = pxISO(new Date()); const a = this.from.value, b = this.to.value || this.from.value; let html = `
` + `${PX_MONTHS[m]} ${y}
`; ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].forEach((w, i) => { html += `${w}`; }); for (let i = 0; i < 42; i++) { const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i); if (i === 35 && d.getMonth() !== m) break; // шестая неделя не нужна const iso = pxISO(d), cls = ['dp__day']; if (d.getMonth() !== m) cls.push('dp__out'); if (i % 7 > 4) cls.push('dp__we'); if (a && iso >= a && iso <= b) cls.push('dp__in'); if (iso === a) cls.push('dp__start'); if (iso === b && this.to.value) cls.push('dp__end'); if (iso === today) cls.push('dp__today'); html += ``; } html += '
' + '
'; this.pop.innerHTML = html; this.pop.querySelectorAll('[data-go]').forEach((btn) => { btn.onclick = () => { this.month = new Date(y, m + Number(btn.dataset.go), 1); this.render(); }; }); this.pop.querySelectorAll('[data-d]').forEach((btn) => { btn.onclick = () => this.pick(btn.dataset.d); }); this.pop.querySelector('[data-act=clear]').onclick = () => { this.from.value = ''; this.to.value = ''; this.active = 'from'; this.sync(); this.render(); }; this.pop.querySelector('[data-act=today]').onclick = () => { this.from.value = this.to.value = today; this.active = 'from'; this.month = new Date(); this.month.setDate(1); this.sync(); this.render(); setTimeout(() => this.close(), 180); }; } } /* ---------- мини-график в карточке ---------- */ function pxSpark(values, color) { const w = 220, h = 44, pad = 3; if (!values || values.length < 2) return ''; const max = Math.max(...values), min = Math.min(...values); const span = max - min || 1; const pts = values.map((v, i) => [(i / (values.length - 1)) * w, h - pad - ((v - min) / span) * (h - pad * 2)]); const line = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' '); const id = 'pxg' + Math.random().toString(36).slice(2, 8); return ``; } const Pbx = { status: null, channels: [], report: null, lostFilter: '', // период по умолчанию — текущая неделя, как в ВАТС defaults() { const now = new Date(), mon = new Date(now); mon.setDate(now.getDate() - ((now.getDay() + 6) % 7)); for (const [a, b] of [['#px-from', '#px-to'], ['#px-sync-from', '#px-sync-to'], ['#rp-pbx-from', '#rp-pbx-to']]) { if (!$(a).value) $(a).value = pxISO(mon); if (!$(b).value) $(b).value = pxISO(now); } Pbx.ranges.forEach((r) => r.sync()); }, async open() { Pbx.defaults(); await Pbx.loadStatus(); await Promise.all([Pbx.loadReport(), Pbx.loadLines()]); }, async loadStatus() { try { const r = await api(ReportClinic.q('/admin/pbx/status')); Pbx.status = r.data; } catch (e) { toast(e.message, true); return; } const { configured, domain, status: s } = Pbx.status; $('#px-conn').classList.toggle('off', !configured); $('#px-conn-text').textContent = configured ? `Подключена: ${domain}` : 'Не подключена — ключ API ещё не задан на сервере'; $('#px-copy').hidden = !configured; const parts = []; parts.push(s.calls ? `В базе ${pxNum(s.calls)} звонков, с ${ruDate(localDate(s.oldest))} по ${ruDate(localDate(s.newest))}.` : 'Звонков в базе пока нет.'); if (s.last_sync_at) parts.push(`Последняя загрузка: ${pxDT.format(new Date(s.last_sync_at))}${s.last_error ? ' — ошибка: ' + s.last_error : ', звонков ' + s.last_calls}.`); if (s.lines_unknown) parts.push(`Линий без канала: ${s.lines_unknown} — их звонки в отчёты не входят (канал задаётся внизу страницы).`); $('#px-state').textContent = parts.join(' '); $('#px-state').classList.toggle('px-bad', !!s.last_error); $('#px-setup').hidden = configured; // строки .env своей клиники: у DANA MEGAFON_API_*, у «Очевидно» OCHEVIDNO_MEGAFON_API_* const [urlVar, keyVar] = (Pbx.status.env || 'MEGAFON_API_URL и MEGAFON_API_KEY').split(' и '); $('#px-setup-env').textContent = `${urlVar}=${ReportClinic.key === 'ochevidno' ? 'vats363024' : 'vats970345'}.megapbx.ru\n${keyVar}=ваш ключ`; $('#px-check').disabled = !configured; $('#px-sync').disabled = !configured; $('#rp-pbx-opt').classList.toggle('px-off', !configured && !s.calls); }, async check() { const b = $('#px-check'); b.disabled = true; try { const r = await api(ReportClinic.q('/admin/pbx/check'), { method: 'POST' }); toast(`Ключ принят: сотрудников в АТС — ${r.data.users}`); } catch (e) { toast(e.message, true); } b.disabled = false; }, async sync() { if (!$('#px-sync-from').value || !$('#px-sync-to').value) { toast('Выберите период загрузки', true); return; } const b = $('#px-sync'); b.disabled = true; const old = b.textContent; b.textContent = 'Загружаем…'; try { const r = await api(ReportClinic.q('/admin/pbx/sync'), { method: 'POST', body: JSON.stringify({ from: $('#px-sync-from').value, to: $('#px-sync-to').value }) }); toast(`Загружено звонков: ${r.data.calls}`); } catch (e) { toast(e.message, true); } b.textContent = old; b.disabled = false; await Pbx.loadStatus(); await Promise.all([Pbx.loadReport(), Pbx.loadLines()]); }, // Период для сравнения. Месяц (с 1-го числа) — с прошлым месяцем: целый — с целым, начало // месяца — с теми же числами. Остальное (неделя, несколько дней) — с теми же днями недели // на целое число недель раньше. Если период включает сегодня — прошлый берётся до того же часа. // missing: звонков прошлого периода в базе нет целиком — сравнивать не с чем. previousRange(from, to) { const oldest = Pbx.status?.status?.oldest; if (!from || !to) return null; const now = new Date(), today = pxISO(now); if (from > today) return null; const f = pxDate(from), t = pxDate(to); // будущие дни звонков ещё не имеют: сравниваем то, что уже прошло, — по сегодня const cut = to > today ? pxDate(today) : t; const lastDay = (y, m) => new Date(y, m + 1, 0).getDate(); let pf = null, pt = null, kind = 'week'; if (f.getDate() === 1) { const months = (t.getFullYear() - f.getFullYear()) * 12 + t.getMonth() - f.getMonth() + 1; const full = t.getDate() === lastDay(t.getFullYear(), t.getMonth()); if (full || months === 1) { kind = 'month'; pf = new Date(f.getFullYear(), f.getMonth() - months, 1); if (full && cut === t) pt = new Date(pf.getFullYear(), pf.getMonth() + months, 0); else { // начало месяца (или месяц, который ещё идёт) — те же числа прошлого месяца const m = pf.getMonth() + (cut.getMonth() - f.getMonth()) + (cut.getFullYear() - f.getFullYear()) * 12; pt = new Date(pf.getFullYear(), m, Math.min(cut.getDate(), lastDay(pf.getFullYear(), m))); } } } if (!pf) { const shift = Math.ceil((Math.round((t - f) / 864e5) + 1) / 7) * 7; pf = new Date(f); pf.setDate(pf.getDate() - shift); pt = new Date(cut); pt.setDate(pt.getDate() - shift); } const mins = now.getHours() * 60 + now.getMinutes(); const until = pxISO(cut) === today && mins > 0 ? `${pxISO(pt)}T${pad2(now.getHours())}:${pad2(now.getMinutes())}` : ''; return { from: pxISO(pf), to: pxISO(pt), until, kind, missing: !oldest || pxISO(pf) < localDate(oldest) }; }, async loadReport() { const from = $('#px-from').value, to = $('#px-to').value; if (from && !to) { toast('Выберите конец периода', true); return; } const q = from ? `?from=${from}&to=${to}` : ''; let r, prev = null; const pr = Pbx.previousRange(from, to); try { [r, prev] = await Promise.all([api(ReportClinic.q('/admin/pbx/report' + q)), pr && !pr.missing ? api(ReportClinic.q(`/admin/pbx/report?from=${pr.from}&to=${pr.to}${pr.until ? '&until=' + pr.until : ''}`)).catch(() => null) : null]); } catch (e) { toast(e.message, true); return; } const d = r.data, t = d.total, p = prev?.data; Pbx.report = d; const pt = p && (p.total.incoming + p.outgoing) > 0 ? p.total : null; // с чем сравниваются проценты в карточках let cmp = ''; if (pr) { const span = `${pxRu(pr.from)}–${pxRu(pr.to)}${pr.until ? ', до ' + pr.until.slice(11) : ''}`; const what = pr.kind === 'month' ? 'прошлый месяц' : 'те же дни недели раньше'; cmp = pr.missing ? `Сравнения нет: звонки за ${span} не загружены.` : pt ? `Проценты — к периоду ${span} (${what}).` : `Сравнения нет: за ${span} звонков нет.`; } $('#px-cmp').textContent = cmp; $('#px-cmp').hidden = !cmp; // [подпись, значение, подсказка, иконка, цвет, ряд, сравнение: [сейчас, раньше, рост — хорошо?, в п.п.?]] const kpis = [ ['Все звонки', pxNum(t.incoming + d.outgoing), 'входящие и исходящие', 'phone', '#3f86f2', d.series.all, pt && [t.incoming + d.outgoing, pt.incoming + p.outgoing, true]], ['Входящих звонков', pxNum(t.incoming), `разных клиентов: ${pxNum(t.clients)}`, 'phoneIn', '#22b07d', d.series.incoming, pt && [t.incoming, pt.incoming, true]], ['Принято', `${Math.round(t.answered_rate)}%`, `${pxNum(t.answered)} звонков, ожидание ~${Math.round(t.avg_wait)} с`, 'people', '#8b6cf0', d.series.answered_rate, pt && [t.answered_rate, pt.answered_rate, true, true]], ['Пропущено', pxNum(t.missed), 'входящих без ответа', 'phoneMiss', '#ec5a72', d.series.missed, pt && [t.missed, pt.missed, false]], ['Не перезвонили', pxNum(t.missed_lost), 'клиентов после пропущенного', 'callback', '#f29a3c', d.series.lost, pt && [t.missed_lost, pt.missed_lost, false]], ['Исходящих', pxNum(d.outgoing), `разговоры: ${pxDur(t.out_talk)}`, 'phoneOut', '#1fa6c4', d.series.outgoing, pt && [d.outgoing, p.outgoing, true]], ]; const k = $('#px-kpis'); k.textContent = ''; for (const [label, value, hint, icon, color, series, cmp] of kpis) { const c = el('div', { className: 'px-kpi' }); c.style.setProperty('--c', color); let trend = ''; if (cmp) { const [now, before, upGood, points] = cmp; const diff = points ? now - before : before ? ((now - before) / before) * 100 : null; if (diff !== null && Number.isFinite(diff)) { const v = Math.round(diff); const good = v === 0 ? null : (v > 0) === upGood; trend = `` + `${v ? pxIcon(v > 0 ? 'up' : 'down') : ''}${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(v)}${points ? ' п.п.' : '%'}`; } } c.innerHTML = `
${pxIcon(icon)}
` + `
${trend}
` + `${pxSpark(series, color)}`; c.querySelector('.px-kpi__value').textContent = value; c.querySelector('.px-kpi__label').textContent = label; c.querySelector('.px-kpi__hint').textContent = hint; k.append(c); } const tb = $('#px-channels'); tb.textContent = ''; if (!d.channels.length) tb.append(Pbx.empty(9, 'За период нет звонков на линии с каналом. Загрузите звонки из АТС, выберите другие даты или задайте каналы линиям внизу страницы.')); // строка «Итого» — сверка с фильтром по тем же номерам в «Истории» ВАТС const rows = d.channels.length > 1 ? [...d.channels, { ...t, outgoing: d.outgoing, channel: 'Итого', total: true }] : d.channels; for (const c of rows) { const tr = el('tr', { className: c.total ? 'px-total' : '' }); const name = el('td'); name.append(c.total ? el('b', { textContent: c.channel }) : Pbx.chip(c.channel)); const menu = el('td', { className: 'px-menu-cell' }); if (!c.total) { const btn = el('button', { type: 'button', className: 'px-more' }); btn.innerHTML = pxIcon('dots'); btn.setAttribute('aria-label', 'Действия: ' + c.channel); btn.onclick = (e) => { e.stopPropagation(); Pbx.menu(btn, c); }; menu.append(btn); } tr.append(name, el('td', { textContent: pxNum(c.incoming) }), el('td', { textContent: pxNum(c.clients) }), el('td', { textContent: c.incoming ? `${pxNum(c.answered)} · ${Math.round(c.answered_rate)}%` : '—' }), el('td', { className: c.total && c.missed ? 'px-lost' : '', textContent: pxNum(c.missed) }), el('td', { textContent: pxNum(c.outgoing) }), el('td', { textContent: pxDur(c.talk_seconds + c.out_talk) }), el('td', { textContent: c.incoming ? `${Math.round(c.avg_wait)} с` : '—' }), menu); tb.append(tr); } Pbx.renderLost(); const ub = $('#px-users'); ub.textContent = ''; if (!d.users.length) ub.append(Pbx.empty(4, 'Нет звонков за период.')); for (const u of d.users) { const tr = el('tr'); tr.append(el('td', { textContent: u.name }), el('td', { textContent: u.answered }), el('td', { textContent: u.outgoing }), el('td', { textContent: pxDur(u.talk_seconds) })); ub.append(tr); } }, renderLost() { const d = Pbx.report; if (!d) return; const list = Pbx.lostFilter ? d.lost.filter((l) => l.channel === Pbx.lostFilter) : d.lost; $('#px-lost-title').textContent = Pbx.lostFilter ? `Кому перезвонить · ${Pbx.lostFilter}` : 'Кому перезвонить'; $('#px-lost-all').hidden = !Pbx.lostFilter; const lb = $('#px-lost'); lb.textContent = ''; if (!list.length) lb.append(Pbx.empty(4, 'Всем, кого пропустили, перезвонили или они дозвонились сами.')); for (const l of list.slice(0, 100)) { const tr = el('tr'); const ph = el('td'); ph.append(el('a', { href: 'tel:+' + String(l.client).replace(/\D/g, ''), textContent: l.client })); const ch = el('td'); ch.append(Pbx.chip(l.channel)); tr.append(ph, ch, el('td', { textContent: pxDT.format(new Date(l.last_call)) }), el('td', { textContent: l.attempts })); lb.append(tr); } }, // меню строки канала: кому перезвонить по каналу, номера и каналы menu(btn, c) { document.querySelector('.px-pop')?.remove(); const pop = el('div', { className: 'px-pop' }); pop.setAttribute('role', 'menu'); const item = (text, fn) => { const b = el('button', { type: 'button', textContent: text }); b.setAttribute('role', 'menuitem'); b.onclick = () => { pop.remove(); fn(); }; pop.append(b); }; item(`Кому перезвонить${c.missed_lost ? ` (${c.missed_lost})` : ''}`, () => { Pbx.lostFilter = c.channel; Pbx.renderLost(); $('#px-lost-card').scrollIntoView({ behavior: 'smooth', block: 'start' }); }); item('Номера клиники и каналы', () => $('#px-lines-card').scrollIntoView({ behavior: 'smooth', block: 'start' })); btn.parentElement.append(pop); pop.querySelector('button').focus(); const close = (e) => { if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('click', close); } }; setTimeout(() => document.addEventListener('click', close)); pop.addEventListener('keydown', (e) => { if (e.key === 'Escape') { pop.remove(); btn.focus(); } }); }, async loadLines() { let r; try { r = await api(ReportClinic.q('/admin/pbx/lines')); } catch (e) { toast(e.message, true); return; } Pbx.channels = r.meta?.channels || []; const tb = $('#px-lines'); tb.textContent = ''; if (!r.data.length) tb.append(Pbx.empty(4, 'Номера появятся после первой загрузки звонков.')); for (const l of r.data) { const tr = el('tr'); const sel = el('select'); sel.setAttribute('aria-label', 'Канал номера ' + (l.via || l.via_key)); sel.append(el('option', { value: '', textContent: '— не учитывать —' })); for (const c of Pbx.channels) sel.append(el('option', { value: c.key, textContent: c.title })); if (l.channel && !Pbx.channels.some((c) => c.key === l.channel)) sel.append(el('option', { value: l.channel, textContent: l.channel })); sel.value = l.channel; sel.onchange = async () => { try { await api(ReportClinic.q('/admin/pbx/lines'), { method: 'PUT', body: JSON.stringify({ via_key: l.via_key, channel: sel.value }) }); toast(sel.value ? 'Канал номера сохранён' : 'Номер больше не учитывается'); Pbx.loadReport(); Pbx.loadStatus(); } catch (e) { toast(e.message, true); } }; const ch = el('td'); ch.append(sel); tr.append(el('td', { textContent: l.via || l.via_key }), el('td', { className: 'muted', textContent: (l.user_names || []).join(', ') || '—' }), el('td', { textContent: l.calls }), ch); tb.append(tr); } }, // канал цветом — те же цвета, что в «Истории отчётов»; основной номер — как колл-центр chip(name) { const [color, , soft] = name === 'Основной номер' ? RH_CHANNEL_COLORS['колл-центр'] : rhChannelColor(name); const s = el('span', { className: 'px-ch', textContent: name }); s.style.setProperty('--ch', color); s.style.setProperty('--ch-soft', soft); return s; }, empty(cols, text) { const tr = el('tr'); tr.append(el('td', { colSpan: cols, className: 'muted dr-empty', textContent: text })); return tr; }, }; Pbx.ranges = [new PxRange($('#px-range')), new PxRange($('#px-sync-range'))]; $('#px-check').onclick = () => Pbx.check(); $('#px-sync').onclick = () => Pbx.sync(); $('#px-show').onclick = () => Pbx.loadReport(); $('#px-lost-all').onclick = () => { Pbx.lostFilter = ''; Pbx.renderLost(); }; $('#px-copy').onclick = async () => { try { await navigator.clipboard.writeText(Pbx.status.domain); toast('Адрес АТС скопирован'); } catch { toast('Не удалось скопировать', true); } };