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