/* «Дашборд врачей»: полнота карточек, онлайн-запись, отзывы, заявки.
Всё считается по текущим данным базы при каждом открытии. */
const DD_ICONS = {
id: '',
photo: '',
clock: '',
award: '',
alert: '',
star: '',
link: '',
users: '',
check: '',
chat: '',
calendar: '',
download: '',
};
const ddIcon = (n) => ``;
const DoctorDash = {
q: null,
async open() {
try {
const [r] = await Promise.all([api('/admin/doctors-quality'), adminDoctors.length ? null : loadAdminDoctors()]);
DoctorDash.q = r.data;
DoctorDash.fillFilter();
DoctorDash.render();
} catch (e) { toast(e.message, true); }
},
fillFilter() {
const sel = $('#dd-spec'); const keep = sel.value;
sel.textContent = '';
sel.append(el('option', { value: '', textContent: 'Все специальности' }));
for (const s of specialtiesCache.filter((x) => x.doctors)) sel.append(el('option', { value: s.id, textContent: `${s.name_plural} (${s.doctors})` }));
sel.value = keep; if (sel.value !== keep) sel.value = '';
},
// врачи с данными из обоих источников
rows() {
const byId = new Map(DoctorDash.q.doctors.map((x) => [x.id, x]));
const spec = $('#dd-spec').value;
return adminDoctors.filter((d) => !spec || String(d.specialty_id) === spec)
.map((d) => ({ d, q: byId.get(d.id) || {} }));
},
render() {
const rows = DoctorDash.rows(), n = rows.length, Q = DoctorDash.q;
const cnt = (f) => rows.filter(f).length;
const basic = cnt(DoctorDash.metrics[0][1]);
$('#dd-count').textContent = `${n} ${plural(n, 'врач', 'врача', 'врачей')}`;
// четыре круглых показателя
const stats = [
['id', '#e3eefb', '#4a7fc4', basic, 'ФИО, должность и специальность', ''],
['photo', '#fbe7ec', '#c7627a', cnt(({ d }) => !!d.photo), 'с фото', 'no-photo'],
['clock', '#fdeedd', '#c9803f', cnt(({ d }) => d.practice_start_year != null), 'указан стаж', 'no-experience'],
['award', '#e2f4ec', '#3c9670', cnt(({ d }) => !!d.category), 'с категорией', 'no-category'],
];
const box = $('#dd-stats'); box.textContent = '';
for (const [icon, bg, ink, v, label, filter] of stats) {
const b = el('button', { type: 'button', className: 'dd-stat' });
b.innerHTML = `${ddIcon(icon)}
${v}из ${n}${label}`;
b.onclick = () => DoctorDash.toList(filter);
box.append(b);
}
DoctorDash.chart(rows);
// требуют внимания
const noPhoto = n - stats[1][3];
const withServices = cnt(({ d }) => d.service_ids.length > 0);
const att = [
['photo', noPhoto, plural(noPhoto, 'карточка без фото', 'карточки без фото', 'карточек без фото'), () => DoctorDash.toList('no-photo')],
['chat', Q.reviews_pending_total, plural(Q.reviews_pending_total, 'отзыв ждёт публикации', 'отзыва ждут публикации', 'отзывов ждут публикации'), () => go('reviews')],
['link', withServices, `${plural(withServices, 'врач', 'врача', 'врачей')} с указанными услугами`, () => DoctorDash.toList('no-services')],
];
const ab = $('#dd-attention'); ab.textContent = '';
for (const [icon, v, label, fn] of att) {
const b = el('button', { type: 'button', className: 'dd-att' });
b.innerHTML = `${ddIcon(icon)}${v}${label}`;
b.onclick = fn;
ab.append(b);
}
DoctorDash.details(rows);
},
// Инфографика: по каждому разделу — мягкий «холм», высота = сколько врачей его заполнили.
// Холмы соседних разделов перекрываются полупрозрачными слоями; шкала — от 0 до числа врачей.
metrics: [
['Основные сведения', ({ d }) => !!(d.last_name && d.first_name && d.position && d.specialties)],
['Фото', ({ d }) => !!d.photo],
['Стаж', ({ d }) => d.practice_start_year != null],
['Онлайн-запись', ({ d }) => d.booking_provider === 'medflex'],
['Категория', ({ d }) => !!d.category],
['«О враче»', ({ d }) => !!d.bio],
],
// холодный голубой, персиковый, пудровый, светло-лавандовый
hills: ['#9cc0ec', '#f4c09a', '#eeb0bd', '#c3b8ee'],
chart(rows) {
const box = $('#dd-chart'); box.textContent = '';
const n = rows.length;
$('#dd-total').textContent = `${n} ${plural(n, 'врач', 'врача', 'врачей')}`;
const data = DoctorDash.metrics.map(([name, f]) => ({ name, v: rows.filter(f).length }));
const W = Math.max(box.clientWidth, 340), H = 290, L = 8, R = 40, T = 18, B = 46;
const max = Math.max(n, 1);
// поля по краям, чтобы крайние холмы плавно уходили в ноль, а не обрывались
const step = (W - L - R) / (data.length + 1.9);
const cx = (i) => L + step * (i + 1.45);
const y = (v) => T + (H - T - B) * (1 - v / max);
const sigma = step * 0.55;
const hill = (i, v) => {
const pts = [];
for (let k = -2.6; k <= 2.601; k += 0.1) {
const xx = cx(i) + k * sigma;
if (xx < L || xx > W - R) continue;
pts.push(`${xx.toFixed(1)} ${y(v * Math.exp(-(k * k) / 2)).toFixed(1)}`);
}
const x0 = Math.max(L, cx(i) - 2.6 * sigma), x1 = Math.min(W - R, cx(i) + 2.6 * sigma);
return { area: `M${x0.toFixed(1)} ${y(0)} L${pts.join(' L')} L${x1.toFixed(1)} ${y(0)} Z`, line: `M${pts.join(' L')}` };
};
const ticks = [0, 0.25, 0.5, 0.75, 1].map((t) => Math.round(max * t));
let grid = '';
for (const t of ticks) grid += `${t}`;
// высокие холмы — дальше, низкие — ближе, чтобы было видно все
const order = data.map((d, i) => i).sort((a, b) => data[b].v - data[a].v);
const defs = DoctorDash.hills.map((c, i) => `
`).join('');
const hills = order.map((i) => {
const h = hill(i, data[i].v), c = i % DoctorDash.hills.length;
return ``;
}).join('');
const labels = data.map((d, i) => `${d.name}
${d.v}`).join('');
box.innerHTML = `
`;
const svg = box.querySelector('svg'), tip = box.querySelector('.dd-tip'), guide = svg.querySelector('.dd-guide');
const show = (i) => {
svg.querySelectorAll('.dd-hill').forEach((g) => g.classList.toggle('on', Number(g.dataset.i) === i));
guide.setAttribute('x1', cx(i)); guide.setAttribute('x2', cx(i)); guide.hidden = false;
const r = svg.getBoundingClientRect();
tip.hidden = false;
tip.innerHTML = `${data[i].v} из ${n}${data[i].name} · ${n ? Math.round((data[i].v / n) * 100) : 0}%`;
tip.style.left = (cx(i) / W) * r.width + 'px';
tip.style.top = (y(data[i].v) / H) * r.height + 'px';
};
svg.addEventListener('mousemove', (e) => {
const r = svg.getBoundingClientRect();
const px = ((e.clientX - r.left) / r.width) * W;
show(Math.max(0, Math.min(data.length - 1, Math.round((px - L) / step - 1.45))));
});
svg.addEventListener('mouseleave', () => {
tip.hidden = true; guide.hidden = true;
svg.querySelectorAll('.dd-hill').forEach((g) => g.classList.remove('on'));
});
},
details(rows) {
const n = rows.length, cnt = (f) => rows.filter(f).length;
const sum = (k) => rows.reduce((a, r) => a + (r.q[k] || 0), 0);
const pending = sum('reviews_pending'), live = sum('reviews_published');
const withReviews = cnt(({ q }) => q.reviews_pending + q.reviews_published > 0);
const rs = sum('rating_sum'), rc = sum('rating_count');
const leads = sum('leads'), Q = DoctorDash.q;
const extra = cnt(({ d, q }) => d.quote || q.kids_from || q.languages || d.show_on_home || d.absent);
const list = [
['«О враче»', cnt(({ d }) => !!d.bio), (v) => `заполнено у ${v}`, 'no-bio'],
['Образование', cnt(({ q }) => q.education > 0), (v) => `заполнено у ${v}`],
['Награды', cnt(({ q }) => q.awards > 0), (v) => `заполнено у ${v}`],
['Конференции', cnt(({ q }) => q.conferences > 0), (v) => `заполнено у ${v}`],
['Онлайн-запись Medflex', cnt(({ d }) => d.booking_provider === 'medflex'), (v) => `подключена у ${v}`, 'no-medflex'],
['Услуги врача', cnt(({ d }) => d.service_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не указаны', 'no-services'],
['Филиалы приёма', cnt(({ d }) => d.branch_ids.length > 0), (v) => v ? `указаны у ${v}` : 'не заполнены', 'no-branches'],
['Отзывы', withReviews, () => (pending + live
? `${pending} на проверке у ${cnt(({ q }) => q.reviews_pending > 0)} ${plural(cnt(({ q }) => q.reviews_pending > 0), 'врача', 'врачей', 'врачей')}` +
`, ${live ? 'опубликовано ' + live : 'опубликованных нет'}` + (rc ? `, средняя оценка ${(rs / rc).toFixed(2).replace('.', ',')}` : '')
: 'отзывов пока нет'), 'reviews'],
['Заявки к врачу', cnt(({ q }) => q.leads > 0), () => `пока ${leads} из ${Q.leads_total} ${plural(Q.leads_total, 'заявки', 'заявок', 'заявок')}`],
['Дополнительно: цитата, возраст детей, языки, «на главной», «не принимает»', extra, (v) => v ? `заполнено у ${v}` : 'пока не заполнены'],
];
const box = $('#dd-details'); box.textContent = '';
for (const [label, v, text, target] of list) {
const p = n ? Math.round((v / n) * 100) : 0;
const [st, cls] = !v ? ['пусто', 'empty'] : p >= 90 ? ['заполнено', 'ok'] : ['требует внимания', 'warn'];
const row = el(target ? 'button' : 'div', { className: 'dd-row ' + cls });
if (target) { row.type = 'button'; row.onclick = () => (target === 'reviews' ? go('reviews') : DoctorDash.toList(target)); }
row.innerHTML = `
${p}%${st}`;
row.querySelector('.dd-row__label').textContent = label;
row.querySelector('.dd-row__text').textContent = text(v);
box.append(row);
}
},
toList(filter) {
go('doctors');
Doctors.setFilter(filter || '');
},
// отчёт по каждому врачу: что заполнено, что нет
exportCSV() {
const yes = (b) => (b ? 'да' : 'нет');
const rows = [['ФИО', 'Специальность', 'Фото', 'Стаж', 'Категория', '«О враче»', 'Образование', 'Награды', 'Конференции',
'Онлайн-запись', 'Услуг', 'Филиалов', 'Отзывов на проверке', 'Отзывов опубликовано', 'Заявок']];
for (const { d, q } of DoctorDash.rows()) {
rows.push([fullName(d), d.specialties, yes(d.photo), d.practice_start_year ? new Date().getFullYear() - d.practice_start_year + ' лет' : 'нет',
d.category || 'нет', yes(d.bio), q.education || 0, q.awards || 0, q.conferences || 0, d.booking_provider === 'medflex' ? 'Medflex' : 'заявка',
d.service_ids.length, d.branch_ids.length, q.reviews_pending || 0, q.reviews_published || 0, q.leads || 0]);
}
const esc = (v) => { const x = String(v ?? ''); return /[";\n]/.test(x) ? '"' + x.replace(/"/g, '""') + '"' : x; };
const a = el('a', { href: URL.createObjectURL(new Blob(['' + rows.map((r) => r.map(esc).join(';')).join('\r\n')], { type: 'text/csv;charset=utf-8' })),
download: `kartochki-vrachey-${new Date().toISOString().slice(0, 10)}.csv` });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
toast('Отчёт выгружен: ' + (rows.length - 1) + ' ' + plural(rows.length - 1, 'врач', 'врача', 'врачей'));
},
};
$('#dd-spec').onchange = () => DoctorDash.render();
window.addEventListener('resize', debounce(() => { if (DoctorDash.q && !$('#tab-doctors-dash').hidden) DoctorDash.chart(DoctorDash.rows()); }, 150));
{
const acts = [['users', 'Все врачи', () => DoctorDash.toList('')], ['check', 'Проверить карточки', () => DoctorDash.toList('incomplete')],
['chat', 'Отзывы', () => go('reviews')], ['calendar', 'Онлайн-запись', () => DoctorDash.toList('no-medflex')],
['download', 'Экспорт отчёта', () => DoctorDash.exportCSV()]];
const bar = $('#dd-actions');
for (const [icon, label, fn] of acts) {
const b = el('button', { type: 'button', className: 'dd-act' });
b.innerHTML = ddIcon(icon) + `${label}`;
b.onclick = fn;
bar.append(b);
}
}