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

317 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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.

/* Оболочка админки: Sidebar (органическая лента навигации), Topbar (поиск, календарь,
уведомления, профиль) и маршрутизация разделов через #адрес. */
const ICONS = {
home: '<path d="M3.5 10.5 12 3.8l8.5 6.7V19a1.5 1.5 0 0 1-1.5 1.5h-4.2v-5.8H9.2v5.8H5A1.5 1.5 0 0 1 3.5 19z"/>',
leads: '<path d="M5.2 3.8h3.3l1.7 4.3-2.1 1.3a10.5 10.5 0 0 0 5.3 5.3l1.3-2.1 4.3 1.7v3.3a1.9 1.9 0 0 1-2 1.9A15.8 15.8 0 0 1 3.3 5.8a1.9 1.9 0 0 1 1.9-2z"/>',
doctors: '<path d="M6 3.5v5.2a4 4 0 0 0 8 0V3.5"/><path d="M10 12.7v2.1a4.8 4.8 0 0 0 9.6 0v-2"/><circle cx="19.6" cy="10.8" r="1.9"/>',
catalog: '<path d="M20.4 5.9a4.9 4.9 0 0 0-6.9 0L12 7.4l-1.5-1.5a4.9 4.9 0 1 0-6.9 6.9l8.4 8.4 8.4-8.4a4.9 4.9 0 0 0 0-6.9z"/><path d="M6.5 12.2h3l1.4-2.2 2.2 4.3 1.4-2.1h3"/>',
content: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5M9.5 13h6M9.5 16.5h6"/>',
reviews: '<path d="M20.5 11.6a8 8 0 0 1-11.7 7.1L4 19.8l1.1-4.4a8 8 0 1 1 15.4-3.8z"/><path d="m12 8.4 1 2 2.2.3-1.6 1.6.4 2.2-2-1-2 1 .4-2.2-1.6-1.6 2.2-.3z"/>',
settings: '<circle cx="12" cy="12" r="3.1"/><path d="M19.2 14.7a1.6 1.6 0 0 0 .3 1.8l.1.1a1.9 1.9 0 1 1-2.7 2.7l-.1-.1a1.6 1.6 0 0 0-1.8-.3 1.6 1.6 0 0 0-1 1.5v.2a1.9 1.9 0 1 1-3.9 0v-.1a1.6 1.6 0 0 0-1-1.5 1.6 1.6 0 0 0-1.8.3l-.1.1a1.9 1.9 0 1 1-2.7-2.7l.1-.1a1.6 1.6 0 0 0 .3-1.8 1.6 1.6 0 0 0-1.5-1H3.3a1.9 1.9 0 1 1 0-3.9h.1a1.6 1.6 0 0 0 1.5-1 1.6 1.6 0 0 0-.3-1.8l-.1-.1a1.9 1.9 0 1 1 2.7-2.7l.1.1a1.6 1.6 0 0 0 1.8.3h.1a1.6 1.6 0 0 0 1-1.5v-.2a1.9 1.9 0 1 1 3.9 0v.1a1.6 1.6 0 0 0 1 1.5 1.6 1.6 0 0 0 1.8-.3l.1-.1a1.9 1.9 0 1 1 2.7 2.7l-.1.1a1.6 1.6 0 0 0-.3 1.8v.1a1.6 1.6 0 0 0 1.5 1h.2a1.9 1.9 0 1 1 0 3.9h-.1a1.6 1.6 0 0 0-1.5 1z"/>',
stats: '<path d="M4 20.5h16"/><rect x="5.5" y="12" width="3.2" height="6.5" rx="1"/><rect x="10.4" y="7" width="3.2" height="11.5" rx="1"/><rect x="15.3" y="3.5" width="3.2" height="15" rx="1"/>',
reports: '<path d="M7 3.5h7l5 5v12H7a1.5 1.5 0 0 1-1.5-1.5V5A1.5 1.5 0 0 1 7 3.5z"/><path d="M14 3.5v5h5M9 17v-3M12 17v-5M15 17v-2"/>',
logout: '<path d="M14.5 4.2A8.3 8.3 0 1 0 20 12"/><path d="M12 12h9M18 9l3 3-3 3"/>',
search: '<circle cx="11" cy="11" r="6.3"/><path d="m20 20-4.3-4.3"/>',
calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M8 3.5v4M16 3.5v4M4 10h16M9.5 14.5h5"/>',
bell: '<path d="M6.3 16.6V11a5.7 5.7 0 0 1 11.4 0v5.6l1.6 1.9H4.7z"/><path d="M10 20.5a2.2 2.2 0 0 0 4 0"/>',
};
const svg = (name) => `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`;
// Разделы ленты. Подразделы — прежние вкладки: их разметка и код не менялись.
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) + `<span class="tip" aria-hidden="true">${g.label}</span>`;
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') + '<span class="tip" aria-hidden="true">Выйти</span>';
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 = '<span>' + svg('search') + '</span>';
$('#cal-btn').insertAdjacentHTML('afterbegin', svg('calendar'));
// сегодняшняя дата рядом с календарём, как в шапке референса
const now = new Date();
$('#cal-date').innerHTML = `<b>${new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }).format(now).replace(' г.', '')}</b>` +
`<small>${new Intl.DateTimeFormat('ru-RU', { weekday: 'long' }).format(now).replace(/^./, (c) => c.toUpperCase())}</small>`;
$('#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();