/* «Дашборд врачей»: полнота карточек, онлайн-запись, отзывы, заявки. Всё считается по текущим данным базы при каждом открытии. */ 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 = ` ${defs}${grid}${hills}${labels}`; 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); } }