dana-medical-site/internal/api/adminui/services.js
2026-10-14 13:33:13 +00:00

279 lines
16 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.

/* «Услуги и цены» → «Услуги»: направления раскрываются, внутри — услуги направления.
Поиск и фильтры работают в браузере: услуг меньше сотни. Форма — прежняя editService. */
const SV_ICONS = {
'detskaya-klinika': '<circle cx="12" cy="12.5" r="7.5"/><path d="M9.3 11.3h.01M14.7 11.3h.01M9.5 15a3.4 3.4 0 0 0 5 0M10.5 5.3c0-1.3 1-2 2-1.6"/>',
ginekologiya: '<path d="M12 21v-6.5M12 14.5c-3.5 0-5-2.6-5-5.6V6.5M12 14.5c3.5 0 5-2.6 5-5.6V6.5"/><path d="M7 6.5C7 4.5 5.4 3.5 4 4.5M17 6.5c0-2 1.6-3 3-2M12 14.5V9"/>',
oko: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3.2"/>',
estetic: '<path d="M12 3.5l1.8 4.7 4.7 1.8-4.7 1.8L12 16.5l-1.8-4.7L5.5 10l4.7-1.8zM18.5 15.5l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z"/>',
endokrinologiya: '<path d="M12 3.5s6 6.4 6 10.5a6 6 0 0 1-12 0c0-4.1 6-10.5 6-10.5z"/><path d="M9.5 14.5a2.6 2.6 0 0 0 2.5 2.5"/>',
urologiya: '<path d="M8.5 4.5c-3 0-4.5 3-4.5 6.5s1.8 7.5 5 7.5c2.2 0 2.4-2.4 1.6-4.5-.6-1.6.4-3 1.9-3M15.5 4.5c3 0 4.5 3 4.5 6.5s-1.8 7.5-5 7.5c-2.2 0-2.4-2.4-1.6-4.5"/>',
khirurgiya: '<path d="M4 20l9.5-9.5M13.5 10.5l5.2-5.2a1.8 1.8 0 0 1 2.5 2.5l-5.2 5.2-2.5-2.5zM4 20l2.5-.7L4.7 17.5z"/>',
mammologiya: '<path d="M12 21c-3-4.4-6-7.4-6-11a6 6 0 0 1 12 0c0 3.6-3 6.6-6 11z"/><path d="M9 3.8 15 16M15 3.8 9 16"/>',
osteo: '<path d="M12 3v18M9 5.5h6M8.5 9.5h7M9 13.5h6M9.5 17.5h5"/>',
proktologiya: '<path d="M6 4.5v5.2a4 4 0 0 0 8 0V4.5"/><path d="M10 13.7v1.1a4.8 4.8 0 0 0 9.6 0v-2"/><circle cx="19.6" cy="10.8" r="1.9"/>',
onkologiya: '<path d="M12 12c-2.3-2.9-3.5-5.2-3.5-6.8a3.5 3.5 0 0 1 7 0c0 1.6-1.2 3.9-3.5 6.8zM12 12l-4.5 8.5M12 12l4.5 8.5"/>',
'meditsinskiy-turizm': '<path d="M3 13.5 21 7l-2.5 5.5L8 15l-2.3 3.8H4l1-4.3zM10 9.2 7 5.5h2.5l5 2.3"/>',
_default: '<rect x="4" y="4" width="16" height="16" rx="4"/><path d="M12 8v8M8 12h8"/>',
};
const SV_TINTS = ['#fdeaec', '#e7f6ee', '#e5f1fb', '#fdf1e3', '#efeafb', '#e8f5f4'];
const SV_INK = ['#d9546d', '#2f9a6a', '#3a7bd5', '#d8822c', '#7a5cc9', '#2c8f88'];
const svIcon = (d) => `<svg viewBox="0 0 24 24" aria-hidden="true">${d}</svg>`;
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 = `Найдено <u>${found}</u> ${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('<path d="m6 9 6 6 6-6"/>');
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 = '<thead><tr><th style="width:34px"></th><th>Название</th><th>Адрес</th><th>Цена от</th><th>Позиции</th><th>Порядок</th><th>Статус</th><th>Действия</th></tr></thead>';
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) + '<span>Изменить</span>';
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);