/* Оболочка админки: 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) + `${g.label}`;
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();