dana-medical-site/internal/api/adminui/doctors.js
2026-10-22 07:40:00 +00:00

367 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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