/* Оболочка админки: Sidebar (органическая лента навигации), Topbar (поиск, календарь, уведомления, профиль) и маршрутизация разделов через #адрес. */ const ICONS = { home: '', leads: '', doctors: '', catalog: '', content: '', reviews: '', settings: '', stats: '', reports: '', logout: '', search: '', calendar: '', bell: '', }; const svg = (name) => ``; // Разделы ленты. Подразделы — прежние вкладки: их разметка и код не менялись. const NAV = [ { key: 'home', label: 'Главная', icon: 'home', tabs: [['home', 'Главная']] }, { key: 'leads', label: 'Заявки', icon: 'leads', tabs: [['leads', 'Заявки']] }, { key: 'doctors', label: 'Врачи', icon: 'doctors', tabs: [['doctors-dash', 'Дашборд'], ['doctors', 'Список врачей'], ['onec', 'Запись через 1С']] }, { key: 'catalog', label: 'Услуги и цены', icon: 'catalog', tabs: [['services', 'Услуги'], ['prices', 'Прайс'], ['checkups', 'Чек-апы'], ['promos', 'Акции']] }, { key: 'content', label: 'Контент сайта', icon: 'content', tabs: [['posts', 'Новости и статьи'], ['banners', 'Слайды на главной'], ['pages', 'Страницы'], ['faq', 'Вопросы'], ['documents', 'Документы'], ['vacancies', 'Вакансии'], ['partners', 'Партнёры']] }, { key: 'reviews', label: 'Отзывы', icon: 'reviews', tabs: [['reviews', 'Отзывы']] }, { key: 'stats', label: 'Статистика', icon: 'stats', tabs: [['stats', 'Статистика']] }, { key: 'reports', label: 'Отчёты', icon: 'reports', tabs: [['reports', 'Отчёты']] }, { key: 'settings', label: 'Настройки', icon: 'settings', tabs: [['settings', 'Реквизиты и филиалы'], ['redirects', 'Редиректы']] }, ]; const TAB_GROUP = Object.fromEntries(NAV.flatMap((g) => g.tabs.map(([t]) => [t, g]))); // Подзаголовок под названием раздела const TAB_SUB = { doctors: 'Управление врачами, их услугами и филиалами', leads: 'Заявки с форм сайта: запись, обратный звонок, отклик на вакансию', }; const TAB_TITLE = Object.fromEntries(NAV.flatMap((g) => g.tabs.map(([t, title]) => [t, g.tabs.length > 1 ? g.label : title]))); /* ---------- Sidebar ---------- */ const Sidebar = { render() { const nav = $('#side-nav'); nav.textContent = ''; for (const g of NAV) { const b = el('button', { type: 'button', className: 'side__btn' }); b.dataset.group = g.key; b.setAttribute('aria-label', g.label); b.innerHTML = svg(g.icon) + ``; if (g.key === 'reviews') b.append($('#rv-badge')); if (g.key === 'leads') b.append(el('span', { id: 'ld-badge', className: 'side__badge', hidden: true })); b.onclick = () => go(g.tabs[0][0]); nav.append(b); } $('#logout').setAttribute('aria-label', 'Выйти'); $('#logout').innerHTML = svg('logout') + ''; Sidebar.shape(); window.addEventListener('resize', debounce(Sidebar.shape, 100)); }, // Контур ленты: три «капсулы» (логотип, меню, низ), соединённые плавными перетяжками. shape() { const side = $('.side'); const h = side.clientHeight, w = side.clientWidth; if (!h || getComputedStyle($('.side__shape')).display === 'none') return; const logo = $('.side__logo'), nav = $('#side-nav'), foot = $('.side__foot'); const gap = (a, b) => ({ a, b, m: (a + b) / 2 }); const n1 = gap(logo.offsetTop + logo.offsetHeight + 14, nav.offsetTop - 14); const n2 = gap(nav.offsetTop + nav.offsetHeight + 14, foot.offsetTop + 46); const neck = 17; // насколько сужается лента в перетяжке const r = w / 2, k = 0.55; const down = (x0, x1, n) => `L ${x0} ${n.a} C ${x0} ${n.a + (n.m - n.a) * k}, ${x1} ${n.m - (n.m - n.a) * k}, ${x1} ${n.m} ` + `C ${x1} ${n.m + (n.b - n.m) * k}, ${x0} ${n.b - (n.b - n.m) * k}, ${x0} ${n.b} `; const up = (x0, x1, n) => `L ${x0} ${n.b} C ${x0} ${n.b - (n.b - n.m) * k}, ${x1} ${n.m + (n.b - n.m) * k}, ${x1} ${n.m} ` + `C ${x1} ${n.m - (n.m - n.a) * k}, ${x0} ${n.a + (n.m - n.a) * k}, ${x0} ${n.a} `; const d = `M ${r} 0 A ${r} ${r} 0 0 0 0 ${r} ` + down(0, neck, n1) + down(0, neck, n2) + `L 0 ${h - r} A ${r} ${r} 0 0 0 ${w} ${h - r} ` + up(w, w - neck, n2) + up(w, w - neck, n1) + `L ${w} ${r} A ${r} ${r} 0 0 0 ${r} 0 Z`; $('.side__shape').setAttribute('viewBox', `0 0 ${w} ${h}`); $('.side__shape path').setAttribute('d', d); }, highlight(tab) { const g = TAB_GROUP[tab]; document.querySelectorAll('.side__btn[data-group]').forEach((b) => { if (b.dataset.group === g.key) b.setAttribute('aria-current', 'page'); else b.removeAttribute('aria-current'); }); }, }; // Шапки с фото у разделов с вкладками: кнопка «Добавить» зависит от вкладки и нажимает // скрытую кнопку самой вкладки. «Услуги и цены» — catalogHero в services.js. const GROUP_HERO = { posts: ['Добавить публикацию', '#po-add'], banners: ['Добавить слайд', '#bn-add'], faq: ['Добавить вопрос', '#fq-add'], documents: ['Добавить документ', '#dc-add'], vacancies: ['Добавить вакансию', '#va-add'], partners: ['Добавить партнёра', '#pt-add'], reviews: ['Добавить отзыв', '#rv-add'], }; function groupHero(tab) { const h = document.querySelector('[data-hero-for]:not([hidden])'); const add = h?.querySelector('[data-hero-add]'); if (!add) return; const c = GROUP_HERO[tab]; add.hidden = !c; if (!c) return; add.innerHTML = svIcon(BN_ICONS.plus) + c[0]; add.onclick = () => $(c[1]).click(); } /* ---------- маршруты ---------- */ function go(tab) { if (location.hash.slice(1) !== tab) location.hash = tab; else route(); } function route() { let tab = location.hash.slice(1) || 'home'; if (!TAB_GROUP[tab]) tab = 'home'; document.body.classList.toggle('leads-view', tab === 'leads'); document.body.classList.toggle('reports-history-view', tab === 'reports' && $('#tab-reports').classList.contains('rp-history-view')); const g = TAB_GROUP[tab]; document.querySelectorAll('main > section').forEach((s) => { s.hidden = s.id !== 'tab-' + tab; }); Sidebar.highlight(tab); const sub = $('#subtabs'); sub.textContent = ''; if (g.tabs.length > 1) { for (const [t, title] of g.tabs) { const b = el('button', { type: 'button', textContent: title }); if (t === tab) b.setAttribute('aria-current', 'page'); b.onclick = () => go(t); sub.append(b); } } $('#page-title').textContent = tab === 'home' ? '' : TAB_TITLE[tab]; $('#page-title').hidden = tab === 'home'; $('#page-sub').textContent = TAB_SUB[tab] || ''; $('#page-sub').hidden = !TAB_SUB[tab]; // у раздела со своей шапкой (data-hero) общий заголовок не нужен if ($('#tab-' + tab)?.hasAttribute('data-hero')) { $('#page-title').hidden = true; $('#page-sub').hidden = true; } // подразделы — внутри шапки раздела, если в ней есть место для них // общие шапки групп разделов (data-hero-for="services prices …") document.querySelectorAll('[data-hero-for]').forEach((h) => { h.hidden = !h.dataset.heroFor.split(' ').includes(tab); }); if (typeof catalogHero === 'function') catalogHero(tab); groupHero(tab); const slot = document.querySelector('[data-hero-for]:not([hidden]) [data-subtabs-slot]') || $('#tab-' + tab)?.querySelector('[data-subtabs-slot]'); if (!Sidebar.subtabsHome) Sidebar.subtabsHome = sub.previousElementSibling; if (slot) slot.append(sub); else if (sub.parentElement !== Sidebar.subtabsHome.parentElement) Sidebar.subtabsHome.after(sub); document.title = (tab === 'home' ? 'Панель клиники' : TAB_TITLE[tab]) + ' — DANA'; if (tab === 'home') Dashboard.load(); if (tab === 'leads') Leads.load(); if (tab === 'stats') Stats.open(); if (tab === 'reports') Reports.open(); if (tab === 'doctors-dash') DoctorDash.open(); if (tab === 'onec') Onec.open(); window.scrollTo({ top: 0 }); } window.addEventListener('hashchange', route); /* ---------- Topbar ---------- */ const Topbar = { searchIndex: null, init(user) { const name = user.name || 'Администратор'; $('#who-name').textContent = name; $('#who-mail').textContent = user.email; $('#who-avatar').textContent = (name.match(/\S+/g) || ['A']).slice(0, 2).map((w) => w[0]).join('').toUpperCase(); $('#search-btn').innerHTML = '' + svg('search') + ''; $('#cal-btn').insertAdjacentHTML('afterbegin', svg('calendar')); // сегодняшняя дата рядом с календарём, как в шапке референса const now = new Date(); $('#cal-date').innerHTML = `${new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }).format(now).replace(' г.', '')}` + `${new Intl.DateTimeFormat('ru-RU', { weekday: 'long' }).format(now).replace(/^./, (c) => c.toUpperCase())}`; $('#bell-btn').insertAdjacentHTML('afterbegin', svg('bell')); const input = $('#search-q'); input.addEventListener('input', debounce(() => Topbar.search(input.value), 150)); input.addEventListener('focus', () => { if (input.value.trim()) Topbar.search(input.value); }); input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { input.value = ''; Topbar.closeAll(); } if (e.key === 'ArrowDown') { $('#search-results button')?.focus(); e.preventDefault(); } }); $('#search-btn').onclick = () => { input.focus(); Topbar.search(input.value); }; $('#cal-btn').onclick = (e) => { e.stopPropagation(); Topbar.toggle('#cal-pop'); }; $('#bell-btn').onclick = (e) => { e.stopPropagation(); Topbar.toggle('#bell-pop'); }; document.addEventListener('click', (e) => { if (!e.target.closest('.search, .popover, .round')) Topbar.closeAll(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') Topbar.closeAll(); }); }, closeAll() { $('#search-results').hidden = true; ['#cal-pop', '#bell-pop'].forEach((id) => { $(id).hidden = true; }); ['#cal-btn', '#bell-btn'].forEach((id) => $(id).setAttribute('aria-expanded', 'false')); }, toggle(id) { const open = $(id).hidden; Topbar.closeAll(); $(id).hidden = !open; $(id === '#cal-pop' ? '#cal-btn' : '#bell-btn').setAttribute('aria-expanded', String(open)); }, // Поиск по уже загруженным спискам админки: врачи, услуги, чек-апы, публикации, страницы. async buildIndex() { const [news, articles, pages] = await Promise.all(['/admin/news', '/admin/articles', '/admin/pages'] .map((p) => api(p).then((r) => r.data || []).catch(() => []))); const idx = []; for (const d of adminDoctors) idx.push({ text: fullName(d) + ' ' + (d.position || ''), title: fullName(d), kind: 'Врач', open: () => { go('doctors'); editDoctor(d); } }); for (const s of servicesCache) idx.push({ text: s.name, title: s.name, kind: s.parent_id ? 'Услуга' : 'Направление', open: () => { go('services'); editService(s); } }); for (const c of checkupsCache) idx.push({ text: c.name, title: c.name, kind: 'Чек-ап', open: () => { go('checkups'); editCheckup(c); } }); for (const [list, kind, key] of [[news, 'Новость', 'news'], [articles, 'Статья', 'articles']]) { for (const p of list) idx.push({ text: p.title, title: p.title, kind, open: () => { go('posts'); $('#po-kind').value = key; loadPosts().then(() => editPost(p)); } }); } for (const p of pages) idx.push({ text: p.title, title: p.title, kind: 'Страница', open: () => { go('pages'); editPage(p); } }); Topbar.searchIndex = idx; }, async search(raw) { const q = raw.trim().toLowerCase(); const box = $('#search-results'); if (!q) { box.hidden = true; return; } if (!Topbar.searchIndex) await Topbar.buildIndex(); const hits = Topbar.searchIndex.filter((x) => x.text.toLowerCase().includes(q)).slice(0, 12); box.textContent = ''; if (!hits.length) box.append(el('div', { className: 'search__empty', textContent: 'Ничего не нашлось. Ищем по врачам, услугам, чек-апам, публикациям и страницам.' })); for (const h of hits) { const b = el('button', { type: 'button' }); b.append(el('span', { textContent: h.title }), el('small', { textContent: h.kind })); b.onclick = () => { Topbar.closeAll(); $('#search-q').value = ''; h.open(); }; b.onkeydown = (e) => { if (e.key === 'ArrowDown') { b.nextElementSibling?.focus(); e.preventDefault(); } if (e.key === 'ArrowUp') { (b.previousElementSibling || $('#search-q')).focus(); e.preventDefault(); } }; box.append(b); } box.hidden = false; }, // Календарь: реальные сроки акций и слайдов на главной на ближайшие 60 дней. calendar(items) { const box = $('#cal-list'); box.textContent = ''; if (!items.length) { box.append(el('div', { className: 'popover__empty', textContent: 'В ближайшие 60 дней акций и слайдов со сроками нет.' })); return; } const fmt = new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short' }); for (const it of items) { const b = el('button', { type: 'button', className: 'popover__item' }); const kind = it.kind === 'promo' ? 'Акция' : 'Слайд'; b.append(el('b', { textContent: fmt.format(new Date(it.at)).replace('.', '') })); const t = el('span', { textContent: it.title }); t.append(el('small', { textContent: `${kind} · ${it.event === 'ends' ? 'последний день показа' : 'начало показа'}` })); b.append(t); b.onclick = () => { Topbar.closeAll(); go(it.kind === 'promo' ? 'promos' : 'banners'); }; box.append(b); } }, // Уведомления: незакрытые дела по данным базы, без выдуманных событий. todo(items) { const TEXT = { new_leads: ['новых заявок', 'Ответьте пациентам', 'leads'], pending_reviews: ['отзывов ждут проверки', 'Опубликуйте или отклоните', 'reviews'], doctors_without_services: ['врачей без услуг', 'Отметьте, какие услуги ведут', 'doctors'], doctors_without_branches: ['врачей без филиала', 'Укажите, где принимают', 'doctors'], doctors_without_photo: ['врачей без фото', 'Загрузите фото', 'doctors'], patients_page_hidden: ['страница «Пациенту» скрыта', 'Проверьте телефон в тексте и включите', 'pages'], }; const box = $('#bell-list'); box.textContent = ''; $('#bell-dot').hidden = !items.length; if (!items.length) { box.append(el('div', { className: 'popover__empty', textContent: 'Всё заполнено — дел нет.' })); return; } for (const it of items) { const [label, hint, tab] = TEXT[it.key] || [it.key, '', 'home']; const b = el('button', { type: 'button', className: 'popover__item' }); b.append(el('b', { textContent: it.key === 'patients_page_hidden' ? '!' : it.count })); const t = el('span', { textContent: label }); t.append(el('small', { textContent: hint })); b.append(t); b.onclick = () => { Topbar.closeAll(); go(tab); if (tab === 'doctors') Doctors.setFilter({ doctors_without_branches: 'no-branches', doctors_without_services: 'no-services', doctors_without_photo: 'no-photo' }[it.key] || ''); }; box.append(b); } }, }; /* ---------- анимации: переключатель внизу ленты ---------- */ function initMotionSwitch() { const sw = $('#motion'); let off = false; try { off = localStorage.getItem('dana-admin-motion') === 'off'; } catch { /* приватный режим */ } const apply = () => { document.documentElement.dataset.motion = off ? 'off' : 'on'; sw.setAttribute('aria-checked', String(!off)); sw.querySelector('.tip').textContent = off ? 'Анимации выключены' : 'Анимации включены'; }; sw.onclick = () => { off = !off; try { localStorage.setItem('dana-admin-motion', off ? 'off' : 'on'); } catch { /* без сохранения */ } apply(); }; apply(); } /* ---------- запуск ---------- */ const baseShowApp = showApp; showApp = function (user) { baseShowApp(user); Sidebar.render(); Topbar.init(user); initMotionSwitch(); route(); // на главной сводку загрузит сам экран; на остальных страницах — только для шапки if (location.hash && location.hash !== '#home') Dashboard.summary().catch(() => {}); }; boot();