/* Раздел «Врачи»: таблица с сортировкой и фильтрами, быстрые действия и сводка справа.
Карточка врача и «Услуги и филиалы» — прежние диалоги 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-medflex' || d.booking_provider !== 'medflex') &&
(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, medflex_user: d.medflex_user,
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-medflex', 'Без онлайн-записи']];
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());