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

454 lines
27 KiB
JavaScript
Raw 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.

/* «Отчёты» → «Звонки АТС»: подключение Виртуальной АТС МегаФон, загрузка звонков,
отчёт по звонкам и привязка номеров клиники к каналам. Ключ 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); }
};