367 lines
20 KiB
JavaScript
367 lines
20 KiB
JavaScript
/* Раздел «Врачи»: таблица с сортировкой и фильтрами, быстрые действия и сводка справа.
|
||
Карточка врача и «Услуги и филиалы» — прежние диалоги editDoctor / editDoctorLinks. */
|
||
|
||
const DR_ICONS = {
|
||
building: '<path d="M4 20.5h16M6 20.5V5.5a1.5 1.5 0 0 1 1.5-1.5h9A1.5 1.5 0 0 1 18 5.5v15"/><path d="M9.5 8h1.5M13 8h1.5M9.5 11.5h1.5M13 11.5h1.5M10.5 20.5v-4h3v4"/>',
|
||
pencil: '<path d="M15.2 5.2l3.6 3.6M4.5 19.5l1-4.4L15.9 4.7a1.8 1.8 0 0 1 2.5 0l.9.9a1.8 1.8 0 0 1 0 2.5L8.9 18.5z"/>',
|
||
dots: '<circle cx="6" cy="12" r="1.3"/><circle cx="12" cy="12" r="1.3"/><circle cx="18" cy="12" r="1.3"/>',
|
||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||
download: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 19.5h14"/>',
|
||
list: '<path d="M8 4h8a2 2 0 0 1 2 2v14H6V6a2 2 0 0 1 2-2z"/><path d="M9.5 9h5M9.5 12.5h5M9.5 16h3"/>',
|
||
people: '<circle cx="12" cy="8.5" r="3.2"/><path d="M5.5 19.5a6.5 6.5 0 0 1 13 0"/><circle cx="5.6" cy="10" r="2.2"/><circle cx="18.4" cy="10" r="2.2"/>',
|
||
info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
|
||
close: '<path d="M6 6l12 12M18 6 6 18"/>',
|
||
sort: '<path d="M8 9.5 12 5.5l4 4M8 14.5l4 4 4-4"/>',
|
||
search: '<path d="m20 20-4.3-4.3"/><circle cx="11" cy="11" r="6.3"/>',
|
||
};
|
||
const drIcon = (n) => `<svg viewBox="0 0 24 24" aria-hidden="true">${DR_ICONS[n]}</svg>`;
|
||
|
||
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') + '<span>Услуги и филиалы</span>';
|
||
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 = `<span><i></i>${label}</span><b>${value}</b>`;
|
||
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());
|