/* «Услуги и цены» → «Услуги»: направления раскрываются, внутри — услуги направления.
Поиск и фильтры работают в браузере: услуг меньше сотни. Форма — прежняя editService. */
const SV_ICONS = {
'detskaya-klinika': '',
ginekologiya: '',
oko: '',
estetic: '',
endokrinologiya: '',
urologiya: '',
khirurgiya: '',
mammologiya: '',
osteo: '',
proktologiya: '',
onkologiya: '',
'meditsinskiy-turizm': '',
_default: '',
};
const SV_TINTS = ['#fdeaec', '#e7f6ee', '#e5f1fb', '#fdf1e3', '#efeafb', '#e8f5f4'];
const SV_INK = ['#d9546d', '#2f9a6a', '#3a7bd5', '#d8822c', '#7a5cc9', '#2c8f88'];
const svIcon = (d) => ``;
const Services = {
open: new Set(),
openKey: 'dana-admin:sv-open',
dirs() { return servicesCache.filter((s) => !s.parent_id); },
children(id) { return servicesCache.filter((s) => s.parent_id === id); },
match(s) {
const q = $('#s-q').value.trim().toLowerCase();
const st = $('#s-status').value;
return (!q || (s.name + ' /' + s.path).toLowerCase().includes(q)) &&
(st !== 'published' || s.published) && (st !== 'hidden' || !s.published) &&
(st !== 'no-price' || !s.price_count);
},
render() {
const dirFilter = $('#s-dir').value;
const box = $('#s-groups'); box.textContent = '';
const filtering = $('#s-q').value.trim() || $('#s-status').value;
let found = 0;
Services.dirs().forEach((dir, i) => {
if (dirFilter && String(dir.id) !== dirFilter) return;
const kids = Services.children(dir.id).sort((a, b) => a.sort - b.sort || a.id - b.id);
const shown = kids.filter(Services.match);
if (filtering && !shown.length) return;
found += shown.length;
box.append(Services.group(dir, kids, shown, i, filtering || dirFilter));
});
$('#s-count').innerHTML = `Найдено ${found} ${plural(found, 'услуга', 'услуги', 'услуг')}`;
if (!box.children.length) box.append(el('p', { className: 'muted dr-empty', textContent: 'Ничего не нашлось — измените поиск или фильтры.' }));
},
group(dir, kids, shown, i, forceOpen) {
const open = forceOpen || Services.open.has(dir.id);
const g = el('section', { className: 'sv-group' + (open ? ' open' : '') });
const head = el('button', { type: 'button', className: 'sv-head' });
head.setAttribute('aria-expanded', String(!!open));
const ic = el('span', { className: 'sv-icon' });
ic.style.background = SV_TINTS[i % SV_TINTS.length];
ic.style.color = SV_INK[i % SV_INK.length];
ic.innerHTML = svIcon(SV_ICONS[dir.slug] || SV_ICONS._default);
const txt = el('span', { className: 'sv-head__text' });
txt.append(el('b', { textContent: dir.name }),
el('small', { textContent: `${kids.length} ${plural(kids.length, 'услуга', 'услуги', 'услуг')}` +
(dir.published ? '' : ' · направление скрыто') }));
const chev = el('span', { className: 'sv-chev' });
chev.innerHTML = svIcon('');
head.append(ic, txt, chev);
head.onclick = () => {
if (Services.open.has(dir.id)) Services.open.delete(dir.id); else Services.open.add(dir.id);
try { localStorage.setItem(Services.openKey, JSON.stringify([...Services.open])); } catch { /* приватный режим */ }
Services.render();
};
const editDir = el('button', { type: 'button', className: 'icon-btn sv-dir-edit', title: 'Изменить направление' });
editDir.setAttribute('aria-label', 'Изменить направление «' + dir.name + '»');
editDir.innerHTML = svIcon(BN_ICONS.pencil);
editDir.onclick = () => editService(dir);
const top = el('div', { className: 'sv-top' });
top.append(head, editDir);
g.append(top);
if (open) {
const wrap = el('div', { className: 'dr-scroll' });
const t = el('table', { className: 'sv-table' });
t.innerHTML = ' | Название | Адрес | Цена от | Позиции | Порядок | Статус | Действия |
';
const tb = el('tbody');
if (!shown.length) {
const tr = el('tr'); tr.append(el('td', { colSpan: 8, className: 'muted dr-empty', textContent: 'В направлении пока нет услуг.' })); tb.append(tr);
}
const draggable = !$('#s-q').value.trim() && !$('#s-status').value;
for (const s of shown) tb.append(Services.row(s, dir, draggable));
t.append(tb); wrap.append(t); g.append(wrap);
}
return g;
},
row(s, dir, draggable) {
const tr = el('tr');
const grip = el('td');
if (draggable) {
const h = el('button', { type: 'button', className: 'grip', title: 'Перетащите, чтобы поменять порядок' });
h.setAttribute('aria-label', `Переместить «${s.name}». Стрелки вверх и вниз меняют порядок`);
h.innerHTML = svIcon(BN_ICONS.grip);
h.onkeydown = (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
Services.move(dir.id, s.id, e.key === 'ArrowUp' ? -1 : 1);
};
grip.append(h);
tr.draggable = true;
tr.addEventListener('dragstart', (e) => { Services.drag = { dir: dir.id, id: s.id }; tr.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; });
tr.addEventListener('dragend', () => { tr.classList.remove('dragging'); Services.drag = null; });
tr.addEventListener('dragover', (e) => { if (Services.drag?.dir === dir.id && Services.drag.id !== s.id) { e.preventDefault(); tr.classList.add('drop'); } });
tr.addEventListener('dragleave', () => tr.classList.remove('drop'));
tr.addEventListener('drop', (e) => {
e.preventDefault(); tr.classList.remove('drop');
const ids = Services.children(dir.id).sort((a, b) => a.sort - b.sort || a.id - b.id).map((x) => x.id);
const from = ids.indexOf(Services.drag.id), to = ids.indexOf(s.id);
ids.splice(to, 0, ids.splice(from, 1)[0]);
Services.reorder(ids);
});
}
const st = el('td');
st.append(el('span', { className: 'pill dot ' + (s.published ? '' : 'off'), textContent: s.published ? 'Опубликовано' : 'Скрыто' }));
const act = el('td', { className: 'dr-actions' });
const edit = el('button', { type: 'button', className: 'bn-edit' });
edit.innerHTML = svIcon(BN_ICONS.pencil) + 'Изменить';
edit.onclick = () => editService(s);
const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' });
more.setAttribute('aria-label', 'Ещё действия: ' + s.name);
more.setAttribute('aria-haspopup', 'menu');
more.innerHTML = svIcon(BN_ICONS.dots);
more.onclick = (e) => { e.stopPropagation(); Services.menu(s, more); };
act.append(edit, more);
tr.append(grip,
el('td', { className: 'sv-name', textContent: s.name }),
el('td', { className: 'muted sv-path', textContent: '/' + s.path, title: '/services/' + s.path + '/' }),
el('td', { textContent: s.price_from ? 'от ' + Number(s.price_from).toLocaleString('ru-RU') + ' ₽' : '—' }),
el('td', { textContent: s.price_count || '—' }),
el('td', { className: 'muted', textContent: s.sort }),
st, act);
return tr;
},
menu(s, anchor) {
Doctors.closeMenu();
const m = el('div', { className: 'menu', id: 'dr-menu', role: 'menu' });
const item = (text, fn) => {
const x = el('button', { type: 'button', role: 'menuitem', textContent: text });
x.onclick = () => { Doctors.closeMenu(); fn(); };
m.append(x);
};
if (s.published) item('Открыть страницу на сайте', () => window.open(`/services/${s.path}/`, '_blank', 'noopener'));
item(s.published ? 'Скрыть с сайта' : 'Показать на сайте', () => Services.save(s, { published: !s.published },
s.published ? 'Услуга скрыта' : 'Услуга на сайте'));
item('Цены этой услуги', () => { go('prices'); $('#p-q').value = s.name; $('#p-q').dispatchEvent(new Event('input')); });
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;
},
body(s, change = {}) {
return { name: s.name, short_name: s.short_name, lead: s.lead, sort: s.sort, published: s.published,
seo_title: s.seo_title, seo_description: s.seo_description, seo_h1: s.seo_h1, ...change };
},
async save(s, change, message) {
try {
await api('/admin/services/' + s.id, { method: 'PUT', body: JSON.stringify(Services.body(s, change)) });
toast(message);
await loadServices();
} catch (e) { toast(e.message, true); }
},
async reorder(ids) {
const byId = new Map(servicesCache.map((s) => [s.id, s]));
const changed = ids.map((id, i) => [byId.get(id), (i + 1) * 10]).filter(([s, sort]) => s.sort !== sort);
try {
for (const [s, sort] of changed) await api('/admin/services/' + s.id, { method: 'PUT', body: JSON.stringify(Services.body(s, { sort })) });
if (changed.length) toast('Порядок услуг сохранён');
} catch (e) { toast(e.message, true); }
await loadServices();
},
async move(dirId, id, dir) {
const ids = Services.children(dirId).sort((a, b) => a.sort - b.sort || a.id - b.id).map((x) => x.id);
const i = ids.indexOf(id), j = i + dir;
if (j < 0 || j >= ids.length) return;
[ids[i], ids[j]] = [ids[j], ids[i]];
await Services.reorder(ids);
[...document.querySelectorAll('#s-groups .grip')].find((g) => g.getAttribute('aria-label').includes(servicesCache.find((s) => s.id === id).name))?.focus();
},
fillDirs() {
const sel = $('#s-dir'); const keep = sel.value;
sel.textContent = '';
sel.append(el('option', { value: '', textContent: 'Все направления' }));
for (const d of Services.dirs()) sel.append(el('option', { value: d.id, textContent: d.name }));
sel.value = keep; if (sel.value !== keep) sel.value = '';
},
add() {
const dirs = Services.dirs();
const pre = $('#s-dir').value;
openDialog('Новая услуга', [
{ key: 'parent_id', label: 'Направление — раздел в адресе страницы', type: 'select', value: pre,
options: [{ value: '', label: '— выберите —' }].concat(dirs.map((d) => ({ value: d.id, label: d.name }))) },
{ key: 'name', label: 'Название (например, «Приём кардиолога»)', value: '' },
{ key: 'lead', label: 'Краткое описание — под заголовком на странице услуги', value: '', type: 'textarea' },
{ key: 'slug', label: 'Адрес латиницей — заполнится сам из названия', value: '' },
], async (d) => {
if (!d.slug.trim()) d.slug = toSlug(d.name);
d.parent_id = Number(d.parent_id) || 0;
const r = await api('/admin/services', { method: 'POST', body: JSON.stringify(d) });
toast('Услуга добавлена скрытой — проверьте её и включите «Опубликована»');
Services.open.add(d.parent_id);
await loadServices();
const s = servicesCache.find((x) => x.id === r.data.id);
if (s) setTimeout(() => editService(s), 50);
});
const inputs = [...$('#dlg-body').querySelectorAll('input')];
const [name, slug] = [inputs[0], inputs[inputs.length - 1]];
let touched = false;
slug.addEventListener('input', () => { touched = true; });
name.addEventListener('input', () => { if (!touched) slug.value = toSlug(name.value); });
},
exportCSV() {
const rows = [['Направление', 'Услуга', 'Адрес на сайте', 'Цена от, ₽', 'Позиций в прайсе', 'Статус']];
for (const dir of Services.dirs()) {
for (const s of Services.children(dir.id).filter(Services.match)) {
rows.push([dir.name, s.name, `${location.origin}/services/${s.path}/`, s.price_from ?? '', s.price_count, s.published ? 'опубликована' : 'скрыта']);
}
}
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: `uslugi-${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, 'услуга', 'услуги', 'услуг'));
},
};
async function loadServices() {
const r = await api('/admin/services');
servicesCache = r.data || [];
if (!Services.initDone) {
Services.initDone = true;
try { for (const id of JSON.parse(localStorage.getItem(Services.openKey) || '[]')) Services.open.add(id); } catch { /* приватный режим */ }
if (!Services.open.size && Services.dirs()[0]) Services.open.add(Services.dirs()[0].id);
}
Services.fillDirs();
Services.render();
}
$('#s-q').addEventListener('input', debounce(() => Services.render(), 200));
$('#s-dir').onchange = () => Services.render();
$('#s-status').onchange = () => Services.render();
$('#s-export').insertAdjacentHTML('afterbegin', svIcon(DR_ICONS.download));
$('#s-search-icon').innerHTML = svIcon(DR_ICONS.search);
$('#s-export').onclick = () => Services.exportCSV();
// общая шапка «Услуги и цены»: кнопка «Добавить» зависит от вкладки
const CATALOG_HERO = {
services: ['Добавить услугу', () => Services.add()],
prices: ['Добавить позицию', () => $('#p-add').click()],
checkups: ['Добавить чек-ап', () => $('#ck-add').click()],
promos: ['Добавить акцию', () => $('#pr-add').click()],
};
function catalogHero(tab) {
const c = CATALOG_HERO[tab];
if (!c) return;
$('#cat-add').innerHTML = svIcon(BN_ICONS.plus) + c[0];
$('#cat-add').onclick = c[1];
}
$('#ck-search-icon').innerHTML = svIcon(DR_ICONS.search);