279 lines
16 KiB
JavaScript
279 lines
16 KiB
JavaScript
/* «Услуги и цены» → «Услуги»: направления раскрываются, внутри — услуги направления.
|
||
Поиск и фильтры работают в браузере: услуг меньше сотни. Форма — прежняя 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);
|