237 lines
15 KiB
JavaScript
237 lines
15 KiB
JavaScript
/* «Дашборд врачей»: полнота карточек, онлайн-запись, отзывы, заявки.
|
||
Всё считается по текущим данным базы при каждом открытии. */
|
||
|
||
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);
|
||
}
|
||
}
|