/* Раздел «Врачи»: таблица с сортировкой и фильтрами, быстрые действия и сводка справа. Карточка врача и «Услуги и филиалы» — прежние диалоги editDoctor / editDoctorLinks. */ const DR_ICONS = { building: '', pencil: '', dots: '', plus: '', download: '', list: '', people: '', info: '', close: '', sort: '', search: '', }; const drIcon = (n) => ``; let adminDoctors = []; let specialtiesCache = []; const TRANSLIT = { а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'kh', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'shch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' }; const toSlug = (s) => s.toLowerCase().split('').map((c) => TRANSLIT[c] ?? c).join('') .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100); const Doctors = { sort: { key: '', dir: 1 }, noteKey: 'dana-admin:dr-note-hidden', status(d) { if (!d.published) return ['скрыт', 'off']; if (d.absent) return ['не принимает', 'wait']; return ['на сайте', '']; }, filtered() { const q = $('#dr-q').value.trim().toLowerCase(); const f = $('#dr-filter').value; let list = adminDoctors.filter((d) => (!q || (fullName(d) + ' ' + d.position + ' ' + (d.specialties || '')).toLowerCase().includes(q)) && (f !== 'published' || d.published) && (f !== 'hidden' || !d.published) && (f !== 'absent' || d.absent) && (f !== 'no-services' || !d.service_ids.length) && (f !== 'no-branches' || !d.branch_ids.length) && (f !== 'no-photo' || !d.photo) && (f !== 'no-experience' || d.practice_start_year == null) && (f !== 'no-category' || !d.category) && (f !== 'no-bio' || !d.bio) && (f !== 'no-booking' || d.booking_provider !== 'native') && (f !== 'incomplete' || !d.photo || d.practice_start_year == null || (!d.bio && !d.bio_count)) && (!f.startsWith('sp:') || String(d.specialty_id) === f.slice(3))); const { key, dir } = Doctors.sort; if (key === 'name') list = list.slice().sort((a, b) => dir * fullName(a).localeCompare(fullName(b), 'ru')); if (key === 'services') list = list.slice().sort((a, b) => dir * (a.service_ids.length - b.service_ids.length)); return list; }, render() { const list = Doctors.filtered(); $('#dr-count').textContent = list.length + ' ' + plural(list.length, 'врач', 'врача', 'врачей'); const tb = $('#dr-body'); tb.textContent = ''; if (!list.length) { const tr = el('tr'); tr.append(el('td', { colSpan: 6, className: 'muted dr-empty', textContent: 'Никого не нашлось — измените поиск или фильтр.' })); tb.append(tr); } for (const d of list) tb.append(Doctors.row(d)); for (const th of document.querySelectorAll('#tab-doctors th[data-sort]')) { const on = th.dataset.sort === Doctors.sort.key; th.setAttribute('aria-sort', on ? (Doctors.sort.dir > 0 ? 'ascending' : 'descending') : 'none'); } Doctors.stats(); }, row(d) { const tr = el('tr'); const who = el('td'); const box = el('div', { className: 'dr-who' }); box.append(d.photo ? el('img', { className: 'dr-photo', src: d.photo, alt: '', loading: 'lazy' }) : el('span', { className: 'dr-photo dr-photo--empty', textContent: (d.last_name[0] || '') + (d.first_name[0] || '') })); const txt = el('div'); const name = el('button', { type: 'button', className: 'dr-name', textContent: fullName(d) }); name.onclick = () => editDoctor(d); txt.append(name, el('div', { className: 'dr-pos', textContent: d.position || 'должность не указана' })); box.append(txt); who.append(box); const spec = el('td'); spec.append(el('span', { className: 'chip', textContent: d.specialties || '—' })); const svc = el('td'); svc.append(d.service_ids.length ? el('span', { className: 'dr-num', textContent: d.service_ids.length }) : el('span', { className: 'pill warn-pill', textContent: 'нет' })); const br = el('td'); const branches = d.branch_ids.map((id) => branchList.find((b) => b.id === id)).filter(Boolean).map((b) => shortAddress(b.address)); if (branches.length) br.append(el('span', { className: 'dr-branches', textContent: branches.join('; ') })); else br.append(el('span', { className: 'pill warn-pill', textContent: 'не указан' })); const st = el('td'); const [label, cls] = Doctors.status(d); st.append(el('span', { className: 'pill dot ' + cls, textContent: label })); const act = el('td', { className: 'dr-actions' }); const links = el('button', { type: 'button', className: 'soft-btn' }); links.innerHTML = drIcon('building') + 'Услуги и филиалы'; links.onclick = () => editDoctorLinks(d); const edit = el('button', { type: 'button', className: 'icon-btn', title: 'Изменить карточку' }); edit.setAttribute('aria-label', 'Изменить: ' + fullName(d)); edit.innerHTML = drIcon('pencil'); edit.onclick = () => editDoctor(d); const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' }); more.setAttribute('aria-label', 'Ещё действия: ' + fullName(d)); more.setAttribute('aria-haspopup', 'menu'); more.innerHTML = drIcon('dots'); more.onclick = (e) => { e.stopPropagation(); Doctors.menu(d, more); }; act.append(links, edit, more); tr.append(who, spec, svc, br, st, act); return tr; }, // Меню «…»: открыть на сайте, скрыть/показать, «не принимает». menu(d, anchor) { Doctors.closeMenu(); const m = el('div', { className: 'menu', id: 'dr-menu', role: 'menu' }); const item = (text, fn, danger) => { const b = el('button', { type: 'button', role: 'menuitem', className: danger ? 'danger' : '', textContent: text }); b.onclick = () => { Doctors.closeMenu(); fn(); }; m.append(b); }; if (d.published && d.specialty_slug) { item('Открыть страницу на сайте', () => window.open(`/company/staff/${d.specialty_slug}/${d.slug}/`, '_blank', 'noopener')); } item(d.absent ? 'Снова принимает' : 'Временно не принимает', () => Doctors.patch(d, { absent: !d.absent }, d.absent ? 'Кнопка записи снова видна' : 'Кнопка записи скрыта')); item(d.published ? 'Скрыть с сайта' : 'Показать на сайте', () => Doctors.patch(d, { published: !d.published }, d.published ? 'Врач скрыт с сайта' : 'Врач на сайте'), d.published); document.body.append(m); const r = anchor.getBoundingClientRect(); m.style.top = (r.bottom + window.scrollY + 6) + 'px'; m.style.left = Math.max(12, r.right + window.scrollX - m.offsetWidth) + 'px'; m.querySelector('button').focus(); anchor.setAttribute('aria-expanded', 'true'); Doctors.menuAnchor = anchor; }, closeMenu() { $('#dr-menu')?.remove(); Doctors.menuAnchor?.setAttribute('aria-expanded', 'false'); Doctors.menuAnchor = null; }, async patch(d, change, message) { const body = { slug: d.slug, last_name: d.last_name, first_name: d.first_name, middle_name: d.middle_name, position: d.position, category: d.category, degree: d.degree, practice_start_year: d.practice_start_year, rating: d.rating, photo_id: d.photo_id, quote: d.quote, bio: d.bio, booking_provider: d.booking_provider, sort: d.sort, published: d.published, absent: d.absent, show_on_home: d.show_on_home, seo_title: d.seo_title, seo_description: d.seo_description, seo_h1: d.seo_h1, ...change, }; try { await api('/admin/doctors/' + d.id, { method: 'PUT', body: JSON.stringify(body) }); toast(message); await loadAdminDoctors(); } catch (e) { toast(e.message, true); } }, stats() { const all = adminDoctors; const n = (f) => all.filter(f).length; $('#dr-total').textContent = all.length; const cells = [ ['published', 'На сайте', n((d) => d.published), 'ok'], ['no-services', 'Нет услуг', n((d) => !d.service_ids.length), 'warn'], ['no-branches', 'Нет филиалов', n((d) => !d.branch_ids.length), 'rose'], ]; const box = $('#dr-stats'); box.textContent = ''; for (const [key, label, value, tone] of cells) { const b = el('button', { type: 'button', className: 'mini-stat ' + tone }); b.setAttribute('aria-pressed', String($('#dr-filter').value === key)); b.innerHTML = `${label}${value}`; b.onclick = () => Doctors.setFilter($('#dr-filter').value === key ? '' : key); box.append(b); } }, setFilter(value) { $('#dr-filter').value = value; Doctors.render(); }, fillFilter() { const sel = $('#dr-filter'); const keep = sel.value; sel.textContent = ''; const base = [['', 'Все врачи'], ['published', 'На сайте'], ['hidden', 'Скрытые'], ['absent', 'Не принимают'], ['no-services', 'Без услуг'], ['no-branches', 'Без филиала'], ['no-photo', 'Без фото'], ['incomplete', 'Неполные карточки'], ['no-experience', 'Без стажа'], ['no-category', 'Без категории'], ['no-bio', 'Без «О враче»'], ['no-booking', 'Без онлайн-записи 1С']]; for (const [v, t] of base) sel.append(el('option', { value: v, textContent: t })); const grp = el('optgroup', { label: 'Специальность' }); for (const s of specialtiesCache.filter((s) => s.doctors)) grp.append(el('option', { value: 'sp:' + s.id, textContent: `${s.name_plural} (${s.doctors})` })); sel.append(grp); sel.value = keep; if (sel.value !== keep) sel.value = ''; }, add() { if (!specialtiesCache.length) { toast('Сначала добавьте специальность', true); return; } openDialog('Новый врач', [ { key: 'last_name', label: 'Фамилия', value: '' }, { key: 'first_name', label: 'Имя', value: '' }, { key: 'middle_name', label: 'Отчество', value: '' }, { key: 'position', label: 'Должность, как на сайте (например, «Акушер-гинеколог, врач УЗИ»)', value: '' }, { key: 'specialty_id', label: 'Специальность — раздел в адресе страницы, потом не меняется', type: 'select', value: '', options: [{ value: '', label: '— выберите —' }].concat(specialtiesCache.map((s) => ({ value: s.id, label: s.name_plural }))) }, { key: 'slug', label: 'Адрес латиницей — заполнится сам из ФИО', value: '' }, ], async (data) => { if (!data.slug.trim()) data.slug = toSlug([data.last_name, data.first_name, data.middle_name].join(' ')); data.specialty_id = Number(data.specialty_id) || 0; const r = await api('/admin/doctors', { method: 'POST', body: JSON.stringify(data) }); toast('Врач добавлен скрытым — заполните карточку и включите «Показывать на сайте»'); await loadAdminDoctors(); const d = adminDoctors.find((x) => x.id === r.data.id); if (d) setTimeout(() => editDoctor(d), 50); }); // адрес подставляется из ФИО, пока его не правили руками const inputs = [...$('#dlg-body').querySelectorAll('input')]; const slug = inputs[inputs.length - 1]; let touched = false; slug.addEventListener('input', () => { touched = true; }); for (const i of inputs.slice(0, 3)) i.addEventListener('input', () => { if (!touched) slug.value = toSlug(inputs.slice(0, 3).map((x) => x.value).join(' ')); }); }, // Список врачей в CSV для Excel: открытые данные с сайта, без персональных данных пациентов. exportCSV() { const list = Doctors.filtered(); const rows = [['ФИО', 'Должность', 'Специальность', 'Услуг', 'Филиалы', 'Статус', 'Адрес на сайте']]; for (const d of list) { const branches = d.branch_ids.map((id) => branchList.find((b) => b.id === id)).filter(Boolean).map((b) => shortAddress(b.address)); rows.push([fullName(d), d.position, d.specialties, d.service_ids.length, branches.join('; '), Doctors.status(d)[0], d.specialty_slug ? `${location.origin}/company/staff/${d.specialty_slug}/${d.slug}/` : '']); } const esc = (v) => { const s = String(v ?? ''); return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const csv = '' + rows.map((r) => r.map(esc).join(';')).join('\r\n'); const a = el('a', { href: URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })), download: `vrachi-${new Date().toISOString().slice(0, 10)}.csv` }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); toast('Выгружено: ' + list.length + ' ' + plural(list.length, 'врач', 'врача', 'врачей')); }, }; async function loadSpecialties() { const r = await api('/admin/specialties'); specialtiesCache = r.data || []; Doctors.fillFilter(); } async function loadAdminDoctors() { const [r] = await Promise.all([api('/admin/doctors'), loadSpecialties()]); adminDoctors = r.data || []; Doctors.render(); } // старое имя: на него ссылаются подсказки колокольчика const renderDoctors = () => Doctors.render(); /* ---------- справочник специальностей ---------- */ const Specialties = { open() { const dlg = $('#sp-dlg'); Specialties.render(); dlg.showModal(); }, render() { const box = $('#sp-list'); box.textContent = ''; for (const s of specialtiesCache) { const row = el('div', { className: 'sp-row' }); const txt = el('div'); txt.append(el('b', { textContent: s.name_plural }), el('small', { textContent: `/company/staff/${s.slug}/ · ${s.doctors} ${plural(s.doctors, 'врач', 'врача', 'врачей')}` })); const edit = el('button', { type: 'button', className: 'icon-btn', title: 'Переименовать' }); edit.setAttribute('aria-label', 'Переименовать: ' + s.name_plural); edit.innerHTML = drIcon('pencil'); edit.onclick = () => Specialties.edit(s); const del = el('button', { type: 'button', className: 'btn danger', textContent: 'Удалить', disabled: s.doctors > 0, title: s.doctors ? 'Сначала перенесите врачей в другую специальность' : '' }); del.onclick = () => Specialties.remove(s); row.append(txt, edit, del); box.append(row); } }, form(s, onSave) { $('#sp-dlg').close(); const fields = [ { key: 'name_plural', label: 'Название раздела на сайте (например, «Кардиологи»)', value: s?.name_plural || '' }, { key: 'name', label: 'В единственном числе или так же (необязательно)', value: s?.name || '' }, { key: 'sort', label: 'Порядок в списке разделов (меньше — выше)', type: 'number', value: s?.sort ?? 500 }, ]; if (!s) fields.push({ key: 'slug', label: 'Адрес латиницей — заполнится сам, потом не меняется', value: '' }); openDialog(s ? 'Специальность: ' + s.name_plural : 'Новая специальность', fields, async (d) => { d.sort = Number(d.sort) || 500; if (!d.name.trim()) d.name = d.name_plural; if (!s && !d.slug.trim()) d.slug = toSlug(d.name_plural); await onSave(d); await loadSpecialties(); setTimeout(Specialties.open, 50); }); $('#dlg').addEventListener('close', function back() { $('#dlg').removeEventListener('close', back); if ($('#dlg').returnValue === 'cancel') setTimeout(Specialties.open, 50); }); }, add() { Specialties.form(null, async (d) => { await api('/admin/specialties', { method: 'POST', body: JSON.stringify(d) }); toast('Специальность добавлена'); }); }, edit(s) { Specialties.form(s, async (d) => { await api('/admin/specialties/' + s.id, { method: 'PUT', body: JSON.stringify(d) }); toast('Сохранено'); loadAdminDoctors(); }); }, async remove(s) { if (!confirm(`Удалить специальность «${s.name_plural}»?`)) return; try { await api('/admin/specialties/' + s.id, { method: 'DELETE' }); toast('Специальность удалена'); await loadSpecialties(); Specialties.render(); } catch (e) { toast(e.message, true); } }, }; /* ---------- обработчики ---------- */ $('#dr-q').addEventListener('input', debounce(() => Doctors.render(), 200)); $('#dr-filter').onchange = () => Doctors.render(); for (const th of document.querySelectorAll('#tab-doctors th[data-sort] button')) { th.insertAdjacentHTML('beforeend', drIcon('sort')); th.onclick = () => { const key = th.parentElement.dataset.sort; Doctors.sort = Doctors.sort.key === key ? { key, dir: -Doctors.sort.dir } : { key, dir: key === 'services' ? -1 : 1 }; Doctors.render(); }; } $('#dr-add').insertAdjacentHTML('afterbegin', drIcon('plus')); $('#dr-export').insertAdjacentHTML('afterbegin', drIcon('download')); $('#dr-spec').insertAdjacentHTML('afterbegin', drIcon('list')); $('#dr-total-icon').innerHTML = drIcon('people'); $('#dr-note-icon').innerHTML = drIcon('info'); $('#dr-note-close').innerHTML = drIcon('close'); $('#dr-search-icon').innerHTML = drIcon('search'); $('#dr-add').onclick = () => Doctors.add(); $('#dr-export').onclick = () => Doctors.exportCSV(); $('#dr-spec').onclick = () => Specialties.open(); $('#sp-add').onclick = () => Specialties.add(); $('#dr-note-close').onclick = () => { $('#dr-note').hidden = true; try { localStorage.setItem(Doctors.noteKey, '1'); } catch { /* приватный режим */ } }; try { if (localStorage.getItem(Doctors.noteKey)) $('#dr-note').hidden = true; } catch { /* приватный режим */ } document.addEventListener('click', (e) => { if (!e.target.closest('#dr-menu')) Doctors.closeMenu(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && $('#dr-menu')) { const a = Doctors.menuAnchor; Doctors.closeMenu(); a?.focus(); } }); window.addEventListener('hashchange', () => Doctors.closeMenu());