454 lines
27 KiB
JavaScript
454 lines
27 KiB
JavaScript
/* «Отчёты» → «Звонки АТС»: подключение Виртуальной АТС МегаФон, загрузка звонков,
|
||
отчёт по звонкам и привязка номеров клиники к каналам. Ключ 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: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/>',
|
||
phoneIn: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/><path d="M20.5 3.5 15 9M15 5v4h4"/>',
|
||
phoneOut: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/><path d="M15 9l5.5-5.5M16.5 3.5h4v4"/>',
|
||
phoneMiss: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/><path d="M15 3.5l5 5M20 3.5l-5 5"/>',
|
||
people: '<circle cx="9.5" cy="8.5" r="3.2"/><path d="M3.5 19.5a6 6 0 0 1 12 0"/><path d="M16 5.6a3 3 0 0 1 0 5.8M18 14.2a5.6 5.6 0 0 1 2.5 5.3"/>',
|
||
callback: '<path d="M4.5 12a7.5 7.5 0 1 0 2.3-5.4"/><path d="M4.5 4.5v4h4"/><path d="M12 8v4.3l2.8 1.7"/>',
|
||
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16"/>',
|
||
copy: '<rect x="8.5" y="8.5" width="11" height="11" rx="2.2"/><path d="M15.5 8.5V6a1.5 1.5 0 0 0-1.5-1.5H6A1.5 1.5 0 0 0 4.5 6v8A1.5 1.5 0 0 0 6 15.5h2.5"/>',
|
||
info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
|
||
left: '<path d="M14.5 6 8.5 12l6 6"/>',
|
||
right: '<path d="M9.5 6l6 6-6 6"/>',
|
||
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"/>',
|
||
up: '<path d="M12 19V5M6.5 10.5 12 5l5.5 5.5"/>',
|
||
down: '<path d="M12 5v14M6.5 13.5 12 19l5.5-5.5"/>',
|
||
};
|
||
const pxIcon = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${PX_ICONS[name]}</svg>`;
|
||
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 = '<button type="button" class="px-date" data-end="from"></button><span class="px-dash">—</span>' +
|
||
'<button type="button" class="px-date" data-end="to"></button><div class="dp" hidden role="dialog" aria-label="Выбор периода"></div>';
|
||
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') + `<span>${pxRu(input.value)}</span>`;
|
||
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 = `<div class="dp__head"><button type="button" class="dp__nav" data-go="-1" aria-label="Предыдущий месяц">${pxIcon('left')}</button>` +
|
||
`<b>${PX_MONTHS[m]} ${y}</b><button type="button" class="dp__nav" data-go="1" aria-label="Следующий месяц">${pxIcon('right')}</button></div><div class="dp__grid">`;
|
||
['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'].forEach((w, i) => { html += `<span class="dp__wd${i > 4 ? ' dp__we' : ''}">${w}</span>`; });
|
||
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 += `<button type="button" class="${cls.join(' ')}" data-d="${iso}" aria-label="${d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' })}"` +
|
||
`${iso === a || iso === b ? ' aria-pressed="true"' : ''}>${d.getDate()}</button>`;
|
||
}
|
||
html += '</div><div class="dp__foot"><button type="button" class="dp__link" data-act="clear">Очистить</button>' +
|
||
'<button type="button" class="dp__today-btn" data-act="today">Сегодня</button></div>';
|
||
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 `<svg class="px-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">` +
|
||
`<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".28"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs>` +
|
||
`<path d="${line} L ${w} ${h} L 0 ${h} Z" fill="url(#${id})"/><path d="${line}" fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" vector-effect="non-scaling-stroke"/></svg>`;
|
||
}
|
||
|
||
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 = `<span class="px-trend${good === null ? '' : good ? ' good' : ' bad'}" title="${cmp}">` +
|
||
`${v ? pxIcon(v > 0 ? 'up' : 'down') : ''}${v > 0 ? '+' : v < 0 ? '−' : ''}${Math.abs(v)}${points ? ' п.п.' : '%'}</span>`;
|
||
}
|
||
}
|
||
c.innerHTML = `<div class="px-kpi__top"><span class="px-kpi__icon">${pxIcon(icon)}</span><div class="px-kpi__main">` +
|
||
`<div class="px-kpi__row"><b class="px-kpi__value"></b>${trend}</div><span class="px-kpi__label"></span></div></div>` +
|
||
`<small class="px-kpi__hint"></small>${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); }
|
||
};
|