dana-medical-site/internal/api/adminui/doctors-dash.js
2026-10-22 07:40:00 +00:00

237 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

/* «Дашборд врачей»: полнота карточек, онлайн-запись, отзывы, заявки.
Всё считается по текущим данным базы при каждом открытии. */
const DD_ICONS = {
id: '<rect x="3.5" y="5" width="17" height="14" rx="3"/><circle cx="9" cy="11" r="2.2"/><path d="M5.8 16.2a3.5 3.5 0 0 1 6.4 0M14 10h4M14 13.5h3"/>',
photo: '<rect x="3.5" y="6" width="17" height="13" rx="3"/><circle cx="12" cy="12.5" r="3.3"/><path d="M8.5 6l1.4-2h4.2l1.4 2"/>',
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
award: '<circle cx="12" cy="9" r="5"/><path d="m9 13.3-1.5 7 4.5-2.3 4.5 2.3-1.5-7"/>',
alert: '<path d="M12 4.5 21 19.5H3z"/><path d="M12 10v4M12 16.8h.01"/>',
star: '<path d="m12 4 2.4 5 5.4.6-4 3.7 1.1 5.4L12 16l-4.9 2.7 1.1-5.4-4-3.7 5.4-.6z"/>',
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
users: '<circle cx="9" cy="8.5" r="3.2"/><path d="M3 19.5a6 6 0 0 1 12 0M16 5.5a3 3 0 0 1 0 6M18 19.5a5.5 5.5 0 0 0-2.5-4.6"/>',
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/><circle cx="12" cy="12" r="9"/>',
chat: '<path d="M20.5 11.6a8 8 0 0 1-11.7 7.1L4 19.8l1.1-4.4a8 8 0 1 1 15.4-3.8z"/>',
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16"/>',
download: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14"/>',
};
const ddIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${DD_ICONS[n]}</svg>`;
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 = `<span class="dd-stat__icon" style="background:${bg};color:${ink}">${ddIcon(icon)}</span>
<b>${v}</b><span class="dd-stat__of">из ${n}</span><span class="dd-stat__label">${label}</span>`;
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 = `<span class="dd-att__icon">${ddIcon(icon)}</span><b>${v}</b><span>${label}</span>`;
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 === 'native'],
['Категория', ({ 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 += `<line x1="${L}" x2="${W - R + 4}" y1="${y(t)}" y2="${y(t)}" class="dd-grid"/><text x="${W - R + 10}" y="${y(t) + 4}" class="dd-axis">${t}</text>`;
// высокие холмы — дальше, низкие — ближе, чтобы было видно все
const order = data.map((d, i) => i).sort((a, b) => data[b].v - data[a].v);
const defs = DoctorDash.hills.map((c, i) => `<linearGradient id="dd-h${i}" x1="0" x2="0" y1="0" y2="1">
<stop offset="0" stop-color="${c}" stop-opacity=".62"/><stop offset="1" stop-color="${c}" stop-opacity=".1"/></linearGradient>`).join('');
const hills = order.map((i) => {
const h = hill(i, data[i].v), c = i % DoctorDash.hills.length;
return `<g class="dd-hill" data-i="${i}"><path d="${h.area}" fill="url(#dd-h${c})"/><path d="${h.line}" fill="none" stroke="${DoctorDash.hills[c]}" stroke-opacity=".75" stroke-width="1.2"/></g>`;
}).join('');
const labels = data.map((d, i) => `<text x="${cx(i)}" y="${H - 24}" text-anchor="middle" class="dd-cat">${d.name}</text>
<text x="${cx(i)}" y="${H - 8}" text-anchor="middle" class="dd-val">${d.v}</text>`).join('');
box.innerHTML = `<svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img"
aria-label="Сколько врачей заполнили разделы карточки из ${n}: ${data.map((d) => d.name + ' ' + d.v).join(', ')}">
<defs>${defs}</defs>${grid}${hills}${labels}<line class="dd-guide" y1="${T}" y2="${y(0)}" hidden/></svg><div class="tooltip dd-tip" hidden></div>`;
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 = `<b>${data[i].v} из ${n}</b>${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}`],
['Онлайн-запись 1С', cnt(({ d }) => d.booking_provider === 'native'), (v) => `подключена у ${v}`, 'no-booking'],
['Услуги врача', 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 = `<span class="dd-row__label"></span><span class="dd-row__bar"><i style="width:${Math.max(v ? 3 : 0, p)}%"></i></span>
<span class="dd-row__text"></span><span class="dd-row__pct">${p}%</span><span class="dd-row__st">${st}</span>`;
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 === 'native' ? '1С' : 'заявка',
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-booking')],
['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) + `<span>${label}</span>`;
b.onclick = fn;
bar.append(b);
}
}