';
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); }
};