2427 lines
136 KiB
HTML
2427 lines
136 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>DANA — админка</title>
|
||
<link rel="stylesheet" href="admin.css?v=20261014-share">
|
||
<link rel="stylesheet" href="weekly-experiment.css?v=20261005-corners2">
|
||
<link rel="stylesheet" href="leads.css">
|
||
<link rel="stylesheet" href="onec.css">
|
||
<link rel="stylesheet" href="reports-history.css">
|
||
<link rel="icon" href="/favicon.ico">
|
||
</head>
|
||
<body>
|
||
|
||
<div id="login" hidden>
|
||
<div class="side__logo" style="align-items:flex-start;font-size:22px"><b>DANA</b><span>admin</span></div>
|
||
<h2>Вход в админку</h2>
|
||
<label><span>E-mail</span><input id="l-email" type="email" autocomplete="username"></label>
|
||
<label><span>Пароль</span><input id="l-pass" type="password" autocomplete="current-password"></label>
|
||
<button class="btn" style="width:100%" id="l-btn">Войти</button>
|
||
<div class="err" id="l-err"></div>
|
||
</div>
|
||
|
||
<div id="app" hidden>
|
||
<aside class="side" aria-label="Разделы админки">
|
||
<svg class="side__shape" aria-hidden="true" preserveAspectRatio="none"><path fill="#fff"/></svg>
|
||
<a class="side__logo" href="#home" aria-label="DANA — главная админки"><b>DANA</b><span>admin</span></a>
|
||
<nav class="side__nav" id="side-nav"></nav>
|
||
<span id="rv-badge" class="side__badge" hidden></span>
|
||
<div class="side__foot">
|
||
<button class="side__btn" id="logout" type="button"></button>
|
||
<button class="switch" id="motion" type="button" role="switch" aria-checked="true" aria-label="Анимации интерфейса"><span class="tip" aria-hidden="true">Анимации</span></button>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="shell">
|
||
<header class="top">
|
||
<div class="search" role="search">
|
||
<div class="search__shape">
|
||
<input id="search-q" type="search" placeholder="Поиск: врач, услуга, чек-ап, статья…" aria-label="Поиск по админке" autocomplete="off">
|
||
<svg class="search__neck" viewBox="0 0 22 42" aria-hidden="true"><path d="M0 0 C 9 0, 11 9, 22 9 L 22 33 C 11 33, 9 42, 0 42 Z" fill="#fff"/></svg>
|
||
<button class="search__btn" id="search-btn" type="button" aria-label="Искать"></button>
|
||
</div>
|
||
<div class="search__results" id="search-results" hidden></div>
|
||
</div>
|
||
<div class="top__spacer"></div>
|
||
<div style="position:relative">
|
||
<button class="round round--date" id="cal-btn" type="button" aria-label="Сроки акций и слайдов" aria-expanded="false" aria-controls="cal-pop"><span class="cal-date" id="cal-date"></span></button>
|
||
<div class="popover" id="cal-pop" hidden><h4>Ближайшие сроки</h4><div id="cal-list"></div></div>
|
||
</div>
|
||
<div style="position:relative">
|
||
<button class="round" id="bell-btn" type="button" aria-label="Что нужно сделать" aria-expanded="false" aria-controls="bell-pop"><span class="round__dot" id="bell-dot" hidden></span></button>
|
||
<div class="popover" id="bell-pop" hidden><h4>Что нужно сделать</h4><div id="bell-list"></div></div>
|
||
</div>
|
||
<div class="who">
|
||
<span class="who__avatar" id="who-avatar" aria-hidden="true"></span>
|
||
<div>
|
||
<div class="who__name" id="who-name"></div>
|
||
<div class="who__meta"><span id="who-mail"></span><span>Махачкала</span></div>
|
||
</div>
|
||
</div>
|
||
<span id="who" hidden></span>
|
||
</header>
|
||
<nav class="subtabs" id="subtabs" aria-label="Подразделы"></nav>
|
||
<h1 class="page-title" id="page-title" hidden></h1>
|
||
<p class="page-sub" id="page-sub" hidden></p>
|
||
|
||
<main>
|
||
<!-- ГЛАВНАЯ -->
|
||
<section id="tab-home" aria-label="Панель клиники"></section>
|
||
|
||
<!-- ЗАЯВКИ -->
|
||
<section id="tab-leads" hidden>
|
||
<div class="ld-heading">
|
||
<h1>Заявки</h1>
|
||
<p>Заявки с форм сайта: запись на приём, обратный звонок, заказ услуги, отклик на вакансию.</p>
|
||
</div>
|
||
<div class="ld-summary" aria-label="Сводка заявок">
|
||
<div class="ld-stat"><span class="ld-stat__icon" aria-hidden="true"><svg viewBox="0 0 32 32"><rect x="7" y="3.5" width="18" height="25" rx="2.5"/><path d="M11 9h10M11 14h10M11 19h7"/></svg></span><span class="ld-stat__copy">Новые заявки<strong id="ld-new">0</strong></span><span class="ld-stat__badge ld-stat__badge--blue" id="ld-new-badge">0</span></div>
|
||
<div class="ld-stat"><span class="ld-stat__icon" aria-hidden="true"><svg viewBox="0 0 32 32"><circle cx="16" cy="16" r="12"/><path d="M16 8v8l5 3"/></svg></span><span class="ld-stat__copy">В работе<strong id="ld-progress">0</strong></span><span class="ld-stat__badge ld-stat__badge--orange" id="ld-progress-badge">0</span></div>
|
||
<div class="ld-stat"><span class="ld-stat__icon" aria-hidden="true"><svg viewBox="0 0 32 32"><rect x="5" y="7" width="22" height="21" rx="2.5"/><path d="M10 4v6M22 4v6M5 14h22"/></svg></span><span class="ld-stat__copy">Сегодня<strong id="ld-today">0</strong></span><span class="ld-stat__badge ld-stat__badge--blue" id="ld-today-badge">0</span></div>
|
||
</div>
|
||
<div class="ld-panel">
|
||
<div class="ld-toolbar">
|
||
<select id="ld-status" aria-label="Статус заявок">
|
||
<option value="new">Новые</option>
|
||
<option value="in_progress">В работе</option>
|
||
<option value="done">Обработаны</option>
|
||
<option value="spam">Спам</option>
|
||
<option value="">Все</option>
|
||
</select>
|
||
<label class="ld-search"><span aria-hidden="true">⌕</span><input type="search" id="ld-q" placeholder="Поиск по имени, телефону или сообщению…" aria-label="Поиск заявок"></label>
|
||
<div class="ld-date"><label><span>С</span><input id="ld-from" type="date" aria-label="Дата с"></label><label><span>По</span><input id="ld-to" type="date" aria-label="Дата по"></label></div>
|
||
<button class="ld-export" id="ld-export" type="button" title="Экспортировать показанные заявки в CSV"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3"/></svg><span>Экспорт страницы</span></button>
|
||
<button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки">↻</button>
|
||
</div>
|
||
<div class="ld-table-wrap"><table class="ld-table">
|
||
<thead><tr><th>Дата</th><th>Имя</th><th>Телефон</th><th>Форма</th><th>Сообщение</th><th>Статус</th><th><span class="visually-hidden">Действие</span></th></tr></thead>
|
||
<tbody id="ld-body"></tbody>
|
||
</table></div>
|
||
<div class="ld-pager">
|
||
<span id="ld-count">Показано 0 заявок</span>
|
||
<div class="ld-pager__actions">
|
||
<button class="btn ghost" id="ld-prev">← Назад</button>
|
||
<span id="ld-page"></span>
|
||
<button class="btn ghost" id="ld-next">Вперёд →</button>
|
||
</div>
|
||
</div>
|
||
<p class="ld-privacy">Заявки содержат персональные данные. Не пересылайте и не выгружайте их без необходимости.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- УСЛУГИ -->
|
||
<!-- общая шапка раздела «Услуги и цены» -->
|
||
<div class="bn-hero bn-hero--services has-photo" id="catalog-hero" data-hero-for="services prices checkups promos" hidden>
|
||
<div class="bn-hero__text">
|
||
<h1>Услуги и цены</h1>
|
||
<div class="hero-subtabs" data-subtabs-slot></div>
|
||
</div>
|
||
<button type="button" class="bn-add" id="cat-add"></button>
|
||
</div>
|
||
|
||
<section id="tab-services" hidden data-hero>
|
||
<div class="card bn-card sv-card">
|
||
<div class="sv-toolbar">
|
||
<label class="dr-search"><span id="s-search-icon"></span>
|
||
<input type="search" id="s-q" placeholder="Поиск по названию или адресу…" aria-label="Поиск услуги"></label>
|
||
<select id="s-dir" aria-label="Направление"></select>
|
||
<select id="s-status" aria-label="Статус услуг">
|
||
<option value="">Все статусы</option>
|
||
<option value="published">Опубликованные</option>
|
||
<option value="hidden">Скрытые</option>
|
||
<option value="no-price">Без цен в прайсе</option>
|
||
</select>
|
||
<div class="spacer"></div>
|
||
<span class="sv-count" id="s-count"></span>
|
||
<button type="button" class="bn-edit" id="s-export">Экспорт</button>
|
||
</div>
|
||
<div id="s-groups"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ВРАЧИ -->
|
||
<!-- ДАШБОРД ВРАЧЕЙ -->
|
||
<section id="tab-doctors-dash" hidden data-hero>
|
||
<div class="dd-top">
|
||
<h1 class="dd-title">Дашборд врачей</h1>
|
||
<div data-subtabs-slot></div>
|
||
</div>
|
||
<div class="dd-board">
|
||
<div class="dd-intro">
|
||
<h2>Данные о врачах</h2>
|
||
<p class="dd-desc">Контроль заполненности профилей, онлайн-записи и отзывов. <span id="dd-count"></span> — считается по текущим данным.</p>
|
||
<div class="dd-stats" id="dd-stats"></div>
|
||
</div>
|
||
<div class="dd-overview">
|
||
<div class="dd-overview__head">
|
||
<div>
|
||
<h2>Обзор заполненности</h2>
|
||
<select id="dd-spec" aria-label="Специальность"></select>
|
||
</div>
|
||
<div class="dd-total"><b id="dd-total"></b><span>Карточки в системе</span></div>
|
||
</div>
|
||
<div class="chart dd-chart" id="dd-chart"></div>
|
||
<div class="dd-attention">
|
||
<h3>Требуют внимания</h3>
|
||
<div id="dd-attention"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card dd-details-card">
|
||
<h2 class="dd-h2">Детализация данных</h2>
|
||
<div class="dd-details" id="dd-details"></div>
|
||
</div>
|
||
<nav class="dd-actions" id="dd-actions" aria-label="Быстрые действия"></nav>
|
||
</section>
|
||
|
||
<section id="tab-onec" hidden data-hero>
|
||
<div class="dd-top">
|
||
<h1 class="dd-title">Запись через 1С</h1>
|
||
<div data-subtabs-slot></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="oc-head">
|
||
<h2>Связь с 1С</h2>
|
||
<span class="spacer"></span>
|
||
<button type="button" class="btn" id="oc-sync">Синхронизировать сейчас</button>
|
||
</div>
|
||
<div id="oc-status"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="oc-head"><h2>Подтверждение записи</h2></div>
|
||
<div id="oc-confirm"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="oc-head"><h2>Врачи из 1С</h2><span class="muted" id="oc-doctors-count"></span></div>
|
||
<p class="muted">Привяжите врача из 1С к его карточке на сайте и включите запись — тогда на карточке появятся свободные окна. Без привязки врач принимает обычные заявки.</p>
|
||
<div class="oc-scroll">
|
||
<table class="dr-table">
|
||
<thead><tr><th>Врач в 1С</th><th>Карточка на сайте</th><th>Способ записи</th><th>Свободных окон</th></tr></thead>
|
||
<tbody id="oc-doctors"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="oc-head"><h2>Сетка окон</h2><span class="spacer"></span>
|
||
<input type="date" id="oc-date" aria-label="Дата"></div>
|
||
<div id="oc-grid"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="oc-head"><h2>Записи с сайта</h2></div>
|
||
<div class="oc-scroll">
|
||
<table class="dr-table">
|
||
<thead><tr><th>Создана</th><th>Пациент</th><th>Врач</th><th>Приём</th><th>Статус</th><th>Подтверждение</th><th>№ в 1С</th></tr></thead>
|
||
<tbody id="oc-appts"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="tab-doctors" hidden>
|
||
<div class="dr-layout">
|
||
<div class="card dr-card">
|
||
<div class="dr-toolbar">
|
||
<label class="dr-search"><span id="dr-search-icon"></span>
|
||
<input type="search" id="dr-q" placeholder="Поиск по фамилии, имени или должности…" aria-label="Поиск врача"></label>
|
||
<select id="dr-filter" aria-label="Фильтр врачей"></select>
|
||
<span class="dr-count" id="dr-count"></span>
|
||
</div>
|
||
<div class="dr-note" id="dr-note">
|
||
<span class="dr-note__icon" id="dr-note-icon"></span>
|
||
<p>«Услуги и филиалы» — какие услуги ведёт врач (вкладка «Услуги» в его карточке) и где принимает
|
||
(адрес под именем и фильтр «Все филиалы» на странице врачей). Специальность задаёт адрес страницы врача и здесь не меняется.</p>
|
||
<button type="button" class="dr-note__close" id="dr-note-close" aria-label="Скрыть подсказку"></button>
|
||
</div>
|
||
<div class="dr-scroll">
|
||
<table class="dr-table">
|
||
<thead><tr>
|
||
<th data-sort="name" aria-sort="none"><button type="button">Врач</button></th>
|
||
<th>Специальность</th>
|
||
<th data-sort="services" aria-sort="none"><button type="button">Услуг</button></th>
|
||
<th>Филиалы</th><th>Статус</th><th>Действия</th>
|
||
</tr></thead>
|
||
<tbody id="dr-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<aside class="dr-side" aria-label="Быстрые действия и сводка">
|
||
<div class="card dr-quick">
|
||
<h2>Быстрые действия</h2>
|
||
<button type="button" class="quick primary" id="dr-add">Добавить врача</button>
|
||
<button type="button" class="quick" id="dr-export">Экспорт списка</button>
|
||
<button type="button" class="quick" id="dr-spec">Управление специальностями</button>
|
||
</div>
|
||
<div class="card dr-summary">
|
||
<div class="dr-summary__head">
|
||
<div><span>Всего врачей</span><b id="dr-total">—</b></div>
|
||
<span class="dr-summary__icon" id="dr-total-icon"></span>
|
||
</div>
|
||
<div class="dr-stats" id="dr-stats"></div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ПРАЙС -->
|
||
<section id="tab-prices" hidden data-hero>
|
||
<div class="card bn-card">
|
||
<div class="toolbar">
|
||
<select id="p-section"><option value="0">Все разделы</option></select>
|
||
<input type="search" id="p-q" placeholder="Поиск позиции…">
|
||
<span class="muted" id="p-count"></span>
|
||
<div class="spacer"></div>
|
||
<button class="btn" id="p-add" hidden>Добавить позицию</button>
|
||
</div>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:42%">Название</th><th style="width:14%">Раздел</th>
|
||
<th style="width:12%">Цена, ₽</th><th style="width:8%">Ед.</th>
|
||
<th style="width:7%">«от»</th><th style="width:8%">Статус</th><th style="width:9%"></th>
|
||
</tr></thead>
|
||
<tbody id="p-body"></tbody>
|
||
</table>
|
||
<div class="pager">
|
||
<button class="btn ghost" id="p-prev">← Назад</button>
|
||
<span class="muted" id="p-page"></span>
|
||
<button class="btn ghost" id="p-next">Вперёд →</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- АКЦИИ -->
|
||
<section id="tab-promos" hidden data-hero>
|
||
<div class="card bn-card">
|
||
<div class="toolbar">
|
||
<span class="muted" id="pr-count"></span>
|
||
<div class="spacer"></div>
|
||
<button class="btn" id="pr-add" hidden>Добавить акцию</button>
|
||
</div>
|
||
<p class="note">Это реклама медицинских услуг: у акции на сайте должны быть условия и срок.
|
||
После даты окончания акция сама пропадает с сайта.</p>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:26%">Название</th><th style="width:8%">Скидка</th>
|
||
<th style="width:24%">Чек-ап</th><th style="width:18%">Срок</th>
|
||
<th style="width:12%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="pr-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ЧЕК-АПЫ -->
|
||
<section id="tab-checkups" hidden data-hero>
|
||
<div class="card bn-card ck-card">
|
||
<div class="sv-toolbar">
|
||
<label class="dr-search"><span id="ck-search-icon"></span>
|
||
<input type="search" id="ck-q" placeholder="Поиск по названию программы…" aria-label="Поиск чек-апа"></label>
|
||
<select id="ck-aud" aria-label="Какие программы">
|
||
<option value="">Все программы</option>
|
||
<option value="adults">Взрослые</option>
|
||
<option value="kids">Детские</option>
|
||
<option value="hidden">Скрытые</option>
|
||
</select>
|
||
<div class="spacer"></div>
|
||
<span class="sv-count" id="ck-count"></span>
|
||
<select id="ck-sort" aria-label="Сортировка">
|
||
<option value="">По умолчанию</option>
|
||
<option value="name">По названию</option>
|
||
<option value="price">По цене</option>
|
||
</select>
|
||
<button class="btn" id="ck-add" hidden>Добавить чек-ап</button>
|
||
</div>
|
||
<div class="dr-scroll">
|
||
<table class="ck-table">
|
||
<thead><tr>
|
||
<th>Программа</th><th>Для кого</th><th>Цена</th><th>Состав</th><th>Статус</th><th>Действия</th>
|
||
</tr></thead>
|
||
<tbody id="ck-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- общая шапка раздела «Контент сайта» -->
|
||
<div class="bn-hero bn-hero--group has-photo" id="bn-hero" data-hero-for="posts banners pages faq documents vacancies partners" hidden>
|
||
<div class="bn-hero__text">
|
||
<h1>Контент сайта</h1>
|
||
<div class="hero-subtabs" data-subtabs-slot></div>
|
||
</div>
|
||
<button type="button" class="bn-add" data-hero-add hidden></button>
|
||
</div>
|
||
|
||
<!-- БАННЕРЫ -->
|
||
<section id="tab-banners" class="hero-body" hidden data-hero>
|
||
<div class="card bn-card">
|
||
<div class="bn-toolbar">
|
||
<b id="bn-count"></b><span class="muted" id="bn-live"></span>
|
||
<div class="spacer"></div>
|
||
<button type="button" id="bn-add" hidden>Добавить слайд</button>
|
||
<select id="bn-filter" aria-label="Статус слайдов">
|
||
<option value="">Все статусы</option>
|
||
<option value="live">На сайте</option>
|
||
<option value="waiting">Скоро</option>
|
||
<option value="expired">Показ окончен</option>
|
||
<option value="hidden">Скрытые</option>
|
||
</select>
|
||
</div>
|
||
<div class="dr-scroll">
|
||
<table class="bn-table">
|
||
<thead><tr>
|
||
<th style="width:34px"></th><th>Слайд</th><th>Заголовок</th><th>Вид</th><th>Ссылка</th>
|
||
<th>Показ</th><th>Порядок</th><th>Статус</th><th>Действия</th>
|
||
</tr></thead>
|
||
<tbody id="bn-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- НОВОСТИ И СТАТЬИ -->
|
||
<section id="tab-posts" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<select id="po-kind">
|
||
<option value="news">Новости</option>
|
||
<option value="articles">Статьи</option>
|
||
</select>
|
||
<input type="search" id="po-q" placeholder="Поиск по заголовку…">
|
||
<span class="muted" id="po-count"></span>
|
||
<div class="spacer"></div>
|
||
<button class="btn" hidden id="po-add">Добавить</button>
|
||
</div>
|
||
<p class="note" id="po-note"></p>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:46%">Заголовок</th><th style="width:14%">Дата</th>
|
||
<th style="width:10%">Обложка</th><th style="width:12%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="po-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ВАКАНСИИ -->
|
||
<section id="tab-vacancies" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<span class="muted" id="va-count"></span>
|
||
<div class="spacer"></div>
|
||
<button class="btn" hidden id="va-add">Добавить вакансию</button>
|
||
</div>
|
||
<p class="note">Закрытую на время вакансию лучше скрыть, а не удалять — её можно будет вернуть одной галочкой.</p>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:34%">Вакансия</th><th style="width:22%">Зарплата</th>
|
||
<th style="width:14%">Описание</th><th style="width:8%">Порядок</th><th style="width:10%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="va-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ДОКУМЕНТЫ -->
|
||
<section id="tab-documents" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<select id="dc-kind">
|
||
<option value="">Все документы</option>
|
||
<option value="license">Лицензии</option>
|
||
<option value="certificate">Сертификаты</option>
|
||
<option value="award">Награды</option>
|
||
<option value="other">Другие документы</option>
|
||
<option value="supervisor">Надзорные органы</option>
|
||
</select>
|
||
<span class="muted" id="dc-count"></span>
|
||
<div class="spacer"></div>
|
||
<button class="btn" hidden id="dc-add">Добавить документ</button>
|
||
</div>
|
||
<p class="note">Лицензии, сертификаты и награды выводятся на странице «Лицензии и награды», надзорные органы — на своей странице.
|
||
Номер действующей лицензии в подвале и реквизитах берётся из настроек сайта, а не отсюда.</p>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:40%">Название</th><th style="width:14%">Вид</th>
|
||
<th style="width:16%">Файл</th><th style="width:8%">Порядок</th><th style="width:10%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="dc-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ВОПРОСЫ И ОТВЕТЫ -->
|
||
<section id="tab-faq" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<span class="muted" id="fq-count"></span>
|
||
<div class="spacer"></div>
|
||
<button class="btn" hidden id="fq-add">Добавить вопрос</button>
|
||
</div>
|
||
<p class="note">Страница «Вопросы и ответы». Каждая строка ответа на сайте — отдельный абзац.</p>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:40%">Вопрос</th><th style="width:30%">Ответ</th>
|
||
<th style="width:8%">Порядок</th><th style="width:10%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="fq-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ОТЗЫВЫ -->
|
||
<div class="bn-hero bn-hero--group bn-hero--lobby has-photo" id="reviews-hero" data-hero-for="reviews" hidden>
|
||
<div class="bn-hero__text">
|
||
<h1>Отзывы</h1>
|
||
<div class="hero-subtabs" data-subtabs-slot></div>
|
||
</div>
|
||
<button type="button" class="bn-add" data-hero-add hidden></button>
|
||
</div>
|
||
<section id="tab-reviews" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<select id="rv-status">
|
||
<option value="pending">Ждут проверки</option>
|
||
<option value="published">Опубликованы</option>
|
||
<option value="rejected">Отклонены</option>
|
||
<option value="">Все</option>
|
||
</select>
|
||
<input type="search" id="rv-q" placeholder="Поиск по автору или тексту…">
|
||
<span class="muted" id="rv-count"></span>
|
||
<div class="spacer"></div>
|
||
<button class="btn" hidden id="rv-add">Добавить отзыв</button>
|
||
</div>
|
||
<p class="note">Отзыв появляется на сайте только после публикации: на странице «Отзывы» и, если выбран врач, на его странице.
|
||
Перед публикацией уберите из текста телефоны, фамилии и диагнозы, если автор их указал.</p>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:16%">Автор</th><th style="width:36%">Отзыв</th><th style="width:16%">Врач</th>
|
||
<th style="width:9%">Дата</th><th style="width:9%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="rv-body"></tbody>
|
||
</table>
|
||
<div class="pager">
|
||
<button class="btn ghost" id="rv-prev">← Назад</button>
|
||
<span class="muted" id="rv-page"></span>
|
||
<button class="btn ghost" id="rv-next">Вперёд →</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ПАРТНЁРЫ -->
|
||
<section id="tab-partners" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<span class="muted" id="pt-count"></span>
|
||
<div class="spacer"></div>
|
||
<button class="btn" hidden id="pt-add">Добавить партнёра</button>
|
||
</div>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:14%">Логотип</th><th style="width:30%">Название</th><th style="width:26%">Сайт</th>
|
||
<th style="width:8%">Порядок</th><th style="width:10%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="pt-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- СТРАНИЦЫ -->
|
||
<section id="tab-pages" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<p class="note">Тексты разделов сайта. Адреса страниц не меняются. Правовые документы (политика, согласие, cookie)
|
||
скрыть нельзя: на них ссылаются формы заявок. Меняя их текст, обновите дату редакции в документе.</p>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:30%">Страница</th><th style="width:26%">Адрес на сайте</th>
|
||
<th style="width:16%">Изменена</th><th style="width:12%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="pg-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- НАСТРОЙКИ -->
|
||
<!-- ОТЧЁТЫ -->
|
||
<section id="tab-reports" hidden data-hero>
|
||
<!-- атмосферный фон: перетекающие полупрозрачные ленты за карточками -->
|
||
<svg class="rp-waves" viewBox="0 0 720 420" preserveAspectRatio="xMaxYMin slice" aria-hidden="true" focusable="false">
|
||
<defs>
|
||
<linearGradient id="rpw-rose" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0" stop-color="#f7b6c8"/><stop offset=".55" stop-color="#f3a7c0"/><stop offset="1" stop-color="#d9b8f0"/>
|
||
</linearGradient>
|
||
<linearGradient id="rpw-peach" x1="0" y1="0" x2="1" y2="0">
|
||
<stop offset="0" stop-color="#ffd2b0"/><stop offset=".6" stop-color="#f9b28e"/><stop offset="1" stop-color="#f6a3a9"/>
|
||
</linearGradient>
|
||
<linearGradient id="rpw-sky" x1="0" y1="1" x2="1" y2="0">
|
||
<stop offset="0" stop-color="#a9cdf6"/><stop offset=".6" stop-color="#b9c4f5"/><stop offset="1" stop-color="#d4c2f3"/>
|
||
</linearGradient>
|
||
<linearGradient id="rpw-lav" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0" stop-color="#e2d4fa"/><stop offset="1" stop-color="#c6dcf7"/>
|
||
</linearGradient>
|
||
<filter id="rpw-soft" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="2.2"/></filter>
|
||
<filter id="rpw-glow" x="-30%" y="-30%" width="160%" height="160%"><feGaussianBlur stdDeviation="18"/></filter>
|
||
</defs>
|
||
<!-- мягкое свечение позади -->
|
||
<ellipse cx="560" cy="120" rx="170" ry="110" fill="#f6c6d6" opacity=".28" filter="url(#rpw-glow)"/>
|
||
<ellipse cx="430" cy="210" rx="150" ry="90" fill="#c3dbf7" opacity=".3" filter="url(#rpw-glow)"/>
|
||
<!-- ленты: от дальней к ближней -->
|
||
<path d="M250 330 C 330 250, 400 300, 470 220 S 600 60, 720 70 L 720 190 C 640 170, 590 260, 520 300 S 360 380, 250 330 Z"
|
||
fill="url(#rpw-sky)" opacity=".44" filter="url(#rpw-soft)"/>
|
||
<path d="M340 250 C 420 150, 470 210, 540 140 S 650 -10, 720 0 L 720 120 C 660 110, 620 190, 560 230 S 430 300, 340 250 Z"
|
||
fill="url(#rpw-rose)" opacity=".45" filter="url(#rpw-soft)"/>
|
||
<path d="M430 210 C 500 120, 560 170, 610 110 S 690 30, 720 40 L 720 250 C 690 240, 650 290, 600 300 S 470 280, 430 210 Z"
|
||
fill="url(#rpw-peach)" opacity=".42" filter="url(#rpw-soft)"/>
|
||
<path d="M480 60 C 540 20, 600 70, 650 40 S 710 -10, 720 -10 L 720 70 C 690 80, 660 120, 610 130 S 500 120, 480 60 Z"
|
||
fill="url(#rpw-lav)" opacity=".4" filter="url(#rpw-soft)"/>
|
||
<path d="M260 326 C 340 248, 405 296, 472 218 S 602 62, 720 72" fill="none" stroke="#fff" stroke-opacity=".55" stroke-width="1.2" filter="url(#rpw-soft)"/>
|
||
<!-- тонкий блик по гребню розовой ленты -->
|
||
<path d="M360 240 C 430 160, 480 200, 545 142 S 650 5, 720 8" fill="none" stroke="#fff" stroke-opacity=".7" stroke-width="1.6" filter="url(#rpw-soft)"/>
|
||
</svg>
|
||
<div class="rp-head">
|
||
<h1>Отчёты</h1>
|
||
<div class="rp-clinic" id="rp-clinic" role="group" aria-label="Клиника">
|
||
<button type="button" data-clinic="dana" aria-pressed="true">DANA</button>
|
||
<button type="button" data-clinic="ochevidno" aria-pressed="false">Очевидно</button>
|
||
</div>
|
||
<button type="button" class="rp-link rp-share-open" id="rp-share">Ссылка для маркетолога</button>
|
||
</div>
|
||
<div class="rp-tabs" id="rp-tabs" role="tablist" aria-label="Отчёты">
|
||
<button type="button" role="tab" data-view="new" aria-selected="true">Новый отчёт</button>
|
||
<button type="button" role="tab" data-view="history" aria-selected="false" hidden>История отчётов</button>
|
||
<button type="button" role="tab" data-view="weekly" data-mode="weeks" aria-selected="false">По неделям</button>
|
||
<button type="button" role="tab" data-view="weekly" data-mode="months" aria-selected="false">По месяцам</button>
|
||
<button type="button" role="tab" data-view="pbx" aria-selected="false" hidden>Звонки АТС</button>
|
||
</div>
|
||
|
||
<div data-rp-view="new">
|
||
<div class="rp-grid">
|
||
<div class="card rp-upload">
|
||
<div class="rp-drop" id="rp-drop" role="button" tabindex="0" aria-label="Выбрать книгу Excel"></div>
|
||
<input type="file" id="rp-input" accept=".xlsx,.csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv" hidden>
|
||
<button type="button" class="rp-link" id="rp-replace" hidden>Заменить файл</button>
|
||
<div class="rp-partners" id="rp-partners">
|
||
<div class="rp-partner">
|
||
<div class="rp-partner__head"><b>СберЗдоровье</b><small>Файл Report_….xls за период отчёта (необязательно)</small></div>
|
||
<div class="rp-partner__files" id="rp-sber-files"></div>
|
||
<button type="button" class="rp-link" id="rp-sber-add">Добавить файл</button>
|
||
<input type="file" id="rp-sber-input" accept=".xls" hidden>
|
||
</div>
|
||
<div class="rp-partner">
|
||
<div class="rp-partner__head"><b>ПроДокторов</b><small>Файлы Appointment-….xlsx за каждый месяц периода: «Онлайн-запись» и, если есть, «Телефон» (необязательно)</small><small id="rp-prodoc-need"></small></div>
|
||
<div class="rp-partner__files" id="rp-prodoc-files"></div>
|
||
<button type="button" class="rp-link" id="rp-prodoc-add">Добавить файл</button>
|
||
<input type="file" id="rp-prodoc-input" accept=".xlsx" multiple hidden>
|
||
</div>
|
||
</div>
|
||
<p class="rp-file-period" id="rp-file-period" role="status" aria-live="polite" hidden></p>
|
||
<button type="button" class="rp-start" id="rp-start" disabled>Запустить отчёт</button>
|
||
</div>
|
||
<div class="card rp-progress" id="rp-progress" data-state="idle" aria-live="polite">
|
||
<div class="rp-progress__head">
|
||
<div><h2 id="rp-title"></h2><p id="rp-sub"></p></div>
|
||
<span class="rp-clock" id="rp-clock" hidden><span></span></span>
|
||
</div>
|
||
<div class="rp-progress__body">
|
||
<div class="rp-ring">
|
||
<svg viewBox="0 0 180 180" aria-hidden="true">
|
||
<defs>
|
||
<linearGradient id="rp-grad" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0" stop-color="#3f8df0"/><stop offset=".45" stop-color="#8a7cf0"/>
|
||
<stop offset=".75" stop-color="#ee8fb0"/><stop offset="1" stop-color="#f6c08c"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<circle cx="90" cy="90" r="70" fill="none" stroke="#eef2f8" stroke-width="16"/>
|
||
<circle id="rp-ring-bar" cx="90" cy="90" r="70" fill="none" stroke="url(#rp-grad)" stroke-width="16"
|
||
stroke-linecap="round" stroke-dasharray="0 440" transform="rotate(-90 90 90)"/>
|
||
</svg>
|
||
<div class="rp-ring__text"><b id="rp-pct">0%</b><span id="rp-pct-label"></span></div>
|
||
</div>
|
||
<ol class="rp-stages" id="rp-stages"></ol>
|
||
</div>
|
||
<div class="rp-result" id="rp-result" hidden></div>
|
||
</div>
|
||
</div>
|
||
<div class="card rp-recent">
|
||
<div class="rp-recent__head"><h2>Последние отчёты</h2><button type="button" class="card-link" id="rp-all" hidden>Вся история →</button></div>
|
||
<div class="dr-scroll">
|
||
<table class="rp-table">
|
||
<thead><tr><th>Название</th><th>Файл</th><th>Дата создания</th><th>Статус</th><th>Действия</th></tr></thead>
|
||
<tbody id="rp-recent"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div data-rp-view="weekly" hidden>
|
||
<div class="card rp-progress" id="wk-status" data-state="idle">
|
||
<div class="rp-progress__head">
|
||
<div><h2 id="wk-title">Периодов пока нет</h2>
|
||
<p id="wk-sub">Периоды появляются сами: загрузите выгрузку 1С на вкладке «Новый отчёт» — если в ней есть строка «Период ДД.ММ.ГГГГ - ДД.ММ.ГГГГ», результат попадёт и сюда.</p></div>
|
||
</div>
|
||
<div class="rp-pbx-dates" id="wk-filter">
|
||
<input type="date" id="wk-filter-from" aria-label="Показать с"><span>—</span><input type="date" id="wk-filter-to" aria-label="Показать по">
|
||
<button type="button" class="rp-link" id="wk-filter-reset">Показать все</button>
|
||
</div>
|
||
</div>
|
||
<div class="card wk-board" id="wk-board" hidden>
|
||
<div class="dr-scroll">
|
||
<table class="rp-table wk-table" id="wk-table"></table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div data-rp-view="pbx" hidden>
|
||
<div class="card px-card px-ats">
|
||
<span class="px-ats__icon" data-px-icon="phone"></span>
|
||
<div class="px-ats__body">
|
||
<h2>Виртуальная АТС МегаФон</h2>
|
||
<p class="px-conn" id="px-conn"><i class="px-dot"></i><span id="px-conn-text">Проверяем подключение…</span>
|
||
<button type="button" class="px-copy" id="px-copy" aria-label="Скопировать адрес АТС" title="Скопировать адрес АТС" hidden><span data-px-icon="copy"></span></button></p>
|
||
<p class="muted px-state" id="px-state"></p>
|
||
</div>
|
||
<button type="button" class="bn-edit" id="px-check">Проверить ключ</button>
|
||
<div class="px-setup" id="px-setup" hidden>
|
||
<p><b>Как подключить.</b> В ВАТС: «Настройки» → «Интеграция с CRM» → «Ваша CRM / REST API» → включите интеграцию и скопируйте
|
||
«Ключ для авторизации в ВАТС». Затем на сервере в файле <code>.env</code> (рядом с программой сайта) добавьте две строки
|
||
и перезапустите сервер:</p>
|
||
<pre id="px-setup-env">MEGAFON_API_URL=vats970345.megapbx.ru
|
||
MEGAFON_API_KEY=ваш ключ</pre>
|
||
<p class="muted">Ключ не вводится в админке и не хранится в базе — только на сервере.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card px-card px-syncbar">
|
||
<span>Загрузить звонки за период</span>
|
||
<div class="px-range" id="px-sync-range" data-from="px-sync-from" data-to="px-sync-to"></div>
|
||
<button type="button" class="px-btn" id="px-sync">Загрузить</button>
|
||
<span class="muted px-note">Последние двое суток загружаются сами каждые 10 минут.</span>
|
||
<input type="hidden" id="px-sync-from"><input type="hidden" id="px-sync-to">
|
||
</div>
|
||
|
||
<div class="card px-card">
|
||
<div class="px-head">
|
||
<h2>Отчёт по звонкам</h2>
|
||
<div class="px-period">
|
||
<div class="px-range" id="px-range" data-from="px-from" data-to="px-to"></div>
|
||
<button type="button" class="px-show" id="px-show">Показать</button>
|
||
<input type="hidden" id="px-from"><input type="hidden" id="px-to">
|
||
</div>
|
||
</div>
|
||
<div class="px-kpis" id="px-kpis"></div>
|
||
<p class="px-cmp" id="px-cmp" hidden></p>
|
||
<div class="dr-scroll"><table class="rp-table px-table px-channels">
|
||
<thead><tr><th>Канал / номер</th><th>Входящих</th><th>Клиентов</th><th>Принято</th><th>Пропущено</th><th>Исходящих</th><th>Время разговоров</th><th>Ожидание</th><th><span class="sr-only">Действия</span></th></tr></thead>
|
||
<tbody id="px-channels"></tbody></table></div>
|
||
<p class="px-foot"><span data-px-icon="info"></span>«Не перезвонили» — клиенты, чей звонок пропустили и с кем за период так и не поговорили: ни входящего разговора, ни исходящего.</p>
|
||
</div>
|
||
|
||
<div class="px-grid">
|
||
<div class="card px-card" id="px-lost-card">
|
||
<div class="px-head"><h2 id="px-lost-title">Кому перезвонить</h2><button type="button" class="bn-edit" id="px-lost-all" hidden>Все каналы</button></div>
|
||
<div class="dr-scroll"><table class="rp-table px-table">
|
||
<thead><tr><th>Номер клиента</th><th>Канал</th><th>Последний звонок</th><th>Попыток</th></tr></thead>
|
||
<tbody id="px-lost"></tbody></table></div>
|
||
</div>
|
||
<div class="card px-card">
|
||
<h2>Сотрудники</h2>
|
||
<div class="dr-scroll"><table class="rp-table px-table">
|
||
<thead><tr><th>Сотрудник АТС</th><th>Принято</th><th>Исходящих</th><th>Разговоры</th></tr></thead>
|
||
<tbody id="px-users"></tbody></table></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card px-card" id="px-lines-card">
|
||
<h2>Номера клиники и каналы</h2>
|
||
<p class="note">По номеру, на который позвонил клиент («Через» в ВАТС), звонок относится к каналу. Выберите канал для каждого номера —
|
||
тогда звонки попадут в отчёт по звонкам и в «Отчёт по каналам».</p>
|
||
<div class="dr-scroll"><table class="rp-table px-table">
|
||
<thead><tr><th>Номер клиники</th><th>Сотрудники АТС на номере</th><th>Звонков</th><th>Канал</th></tr></thead>
|
||
<tbody id="px-lines"></tbody></table></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div data-rp-view="history" hidden>
|
||
<div class="rh-toolbar">
|
||
<label class="rh-search"><span data-rh-icon="search"></span><input id="rh-search" type="search" placeholder="Найти отчёт или файл…" aria-label="Поиск по истории отчётов"></label>
|
||
<select id="rh-status" aria-label="Статус отчёта"><option value="">Все статусы</option><option value="done">Готов</option><option value="running">В обработке</option><option value="failed">Ошибка</option><option value="cancelled">Отменён</option></select>
|
||
<div class="rh-dates" role="group" aria-label="Дата создания отчёта"><span data-rh-icon="calendar"></span><input id="rh-from" type="date" aria-label="Дата создания с"><span>—</span><input id="rh-to" type="date" aria-label="Дата создания по"></div>
|
||
<button id="rh-reset" class="rh-reset" type="button">Сбросить</button>
|
||
<button id="rh-refresh" class="rh-icon-button" type="button" aria-label="Обновить историю" title="Обновить историю"><span data-rh-icon="refresh"></span></button>
|
||
</div>
|
||
<p id="rh-feedback" class="rh-feedback" role="status" hidden></p>
|
||
<div class="rh-selection">
|
||
<div><label for="rh-report">Отчёт по каналам</label></div>
|
||
<select id="rh-report" aria-label="Выбрать отчёт по каналам"></select>
|
||
<a id="rh-download" class="rh-download" hidden><span data-rh-icon="down"></span>Скачать отчёт</a>
|
||
</div>
|
||
<div id="rh-dashboard" hidden>
|
||
<div id="rh-kpis" class="rh-kpis"></div>
|
||
<div class="rh-card rh-result">
|
||
<div class="rh-card-head"><h2>Результат по каналам</h2><span class="rh-tag">По загруженной книге</span></div>
|
||
<div class="rh-result-grid">
|
||
<div class="rh-conversion"><div id="rh-donut" class="rh-donut"></div><div id="rh-internet" class="rh-facts"></div></div>
|
||
<div class="rh-control"><h3>Сверка выручки с 1С</h3><div id="rh-control"></div></div>
|
||
</div>
|
||
</div>
|
||
<div class="rh-rankings">
|
||
<div class="rh-card"><div class="rh-card-head"><h2>Лиды по каналам</h2><span class="rh-tag">Все каналы</span></div><ol id="rh-leads" class="rh-ranking"></ol></div>
|
||
<div class="rh-card"><div class="rh-card-head"><h2>Выручка по каналам</h2><span class="rh-tag">Все каналы</span></div><ol id="rh-revenue" class="rh-ranking"></ol></div>
|
||
</div>
|
||
<p class="rh-note">Каналы могут пересекаться: один номер может встречаться в нескольких источниках. Показатели каналов не суммируются.</p>
|
||
</div>
|
||
<div id="rh-empty" class="rh-card rh-empty" hidden><span data-rh-icon="file"></span><h2>Пока нет готового отчёта по каналам</h2><p>После обработки книги здесь появятся показатели и сравнение каналов.</p><button class="rh-download" id="rh-new" type="button">Создать отчёт</button></div>
|
||
<div class="card rp-recent rh-history-card">
|
||
<div class="rp-recent__head"><div><h2>История файлов</h2><p id="rh-count" class="rh-note"></p></div><span class="rh-retention">Последние 50 · хранятся без срока</span></div>
|
||
<div class="dr-scroll">
|
||
<table class="rp-table">
|
||
<thead><tr><th>Название</th><th>Файл</th><th>Дата создания</th><th>Статус</th><th>Действия</th></tr></thead>
|
||
<tbody id="rp-history"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<!-- СТАТИСТИКА -->
|
||
<section id="tab-stats" hidden data-hero>
|
||
<div class="bn-hero bn-hero--stats has-photo">
|
||
<div class="bn-hero__text">
|
||
<h1>Статистика</h1>
|
||
</div>
|
||
<div class="st-controls">
|
||
<div class="st-presets" id="st-presets" role="group" aria-label="Период"></div>
|
||
<div class="st-dates">
|
||
<span id="st-cal"></span>
|
||
<input type="date" id="st-from" aria-label="С даты">
|
||
<span aria-hidden="true">—</span>
|
||
<input type="date" id="st-to" aria-label="По дату">
|
||
</div>
|
||
<select id="st-branch" aria-label="Филиал"><option value="">Все филиалы</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="st-kpis" id="st-kpis"></div>
|
||
<div class="st-grid2">
|
||
<div class="card st-card">
|
||
<div class="st-head"><h2>Динамика заявок</h2>
|
||
<select id="st-group" aria-label="Группировка"><option value="day">По дням</option><option value="week">По неделям</option></select></div>
|
||
<div class="chart st-chart" id="st-chart"></div>
|
||
</div>
|
||
<div class="card st-card"><div class="st-head"><h2>Источники заявок</h2></div><div id="st-sources"></div></div>
|
||
</div>
|
||
<div class="st-grid3">
|
||
<div class="card st-card"><div class="st-head"><h2>Популярные услуги</h2></div><div id="st-services"></div></div>
|
||
<div class="card st-card"><div class="st-head"><h2>Заявки по статусам</h2></div><div id="st-status"></div></div>
|
||
<div class="card st-card"><div class="st-head"><h2>Заявки по филиалам</h2></div><div id="st-branches"></div></div>
|
||
</div>
|
||
<div class="st-grid3">
|
||
<div class="card st-card"><div class="st-head"><h2>Популярные врачи</h2></div><div id="st-doctors"></div></div>
|
||
<div class="card st-card"><div class="st-head"><h2>Какие формы заполняют</h2></div><div id="st-forms"></div></div>
|
||
<div class="card st-card"><div class="st-head"><h2>С каких страниц</h2></div><div id="st-pages"></div></div>
|
||
</div>
|
||
<p class="note st-footnote">Здесь только данные, которые сайт сохраняет сам: заявки с форм (без спама). Звонков, посещений
|
||
и выручки сайт не знает — посещения смотрите в Яндекс Метрике, выручку — в медицинской системе клиники.</p>
|
||
</section>
|
||
|
||
<div class="bn-hero bn-hero--group bn-hero--lobby has-photo" id="settings-hero" data-hero-for="settings redirects" hidden>
|
||
<div class="bn-hero__text">
|
||
<h1>Настройки</h1>
|
||
<div class="hero-subtabs" data-subtabs-slot></div>
|
||
</div>
|
||
<button type="button" class="bn-add" data-hero-add hidden></button>
|
||
</div>
|
||
<section id="tab-settings" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<h3 class="sub">Правовые документы</h3>
|
||
<p class="note">Политика обработки персональных данных, согласие и политика cookie. На них ссылаются формы заявок
|
||
и баннер cookie, поэтому скрыть эти страницы нельзя — только изменить текст.</p>
|
||
<div class="legal-list" id="legal-list"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="sub">Реквизиты и связь</h3>
|
||
<p class="note">Выводятся в подвале, на странице контактов и на странице лицензий. Текст на странице
|
||
«Правовая информация» — отдельный, его правьте во вкладке «Страницы».</p>
|
||
<div class="form-grid" id="st-form"></div>
|
||
<button class="btn" id="st-save">Сохранить реквизиты</button>
|
||
</div>
|
||
<div class="card">
|
||
<div class="toolbar">
|
||
<h3 class="sub" style="margin:0">Филиалы</h3>
|
||
<div class="spacer"></div>
|
||
<button class="btn" id="br-add">Добавить филиал</button>
|
||
</div>
|
||
<p class="note">Телефон и часы работы первого филиала стоят в шапке сайта. Без координат филиал не отмечается на карте.</p>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:30%">Адрес</th><th style="width:20%">Телефоны</th><th style="width:20%">Часы работы</th>
|
||
<th style="width:10%">Карта</th><th style="width:8%">Статус</th><th></th>
|
||
</tr></thead>
|
||
<tbody id="br-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- РЕДИРЕКТЫ -->
|
||
<section id="tab-redirects" class="hero-body" hidden data-hero>
|
||
<div class="card">
|
||
<h3 class="sub">Перенаправить адрес</h3>
|
||
<p class="note">Редирект срабатывает, только если по старому адресу на сайте ничего нет. Старые адреса Bitrix
|
||
(удалённые врачи, позиции прайса, лицензии) уже перенаправляются автоматически, а при смене адреса новости,
|
||
статьи, чек-апа или врача редирект со старого адреса добавляется сам.</p>
|
||
<div class="toolbar">
|
||
<input id="rd-from" placeholder="Старый адрес: /services/uzi/ или полная ссылка" style="max-width:420px">
|
||
<span>→</span>
|
||
<input id="rd-to" placeholder="Новый адрес: /services/diagnostika/" style="max-width:420px">
|
||
<button class="btn" id="rd-add">Добавить</button>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th style="width:40%">Старый адрес</th><th style="width:40%">Новый адрес</th><th style="width:10%">Переходов</th><th></th></tr></thead>
|
||
<tbody id="rd-body"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="card">
|
||
<h3 class="sub">Не найдено на сайте</h3>
|
||
<p class="note">Адреса, по которым посетители получили «страница не найдена». Частые — повод добавить редирект.
|
||
Мусорные запросы ботов (/wp-login.php и т. п.) можно просто убрать из списка.</p>
|
||
<table>
|
||
<thead><tr><th style="width:42%">Адрес</th><th style="width:8%">Раз</th><th style="width:12%">Последний</th><th style="width:22%">Откуда пришли</th><th></th></tr></thead>
|
||
<tbody id="nf-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
<dialog id="sp-dlg" class="sp-dlg" aria-labelledby="sp-title">
|
||
<form method="dialog">
|
||
<div class="dlg-head" id="sp-title">Специальности</div>
|
||
<div class="dlg-body">
|
||
<p class="note">Специальность — раздел на странице «Врачи» и часть адреса страницы врача. Поэтому адрес раздела
|
||
после создания не меняется, а удалить можно только раздел без врачей.</p>
|
||
<div id="sp-list"></div>
|
||
</div>
|
||
<div class="dlg-foot">
|
||
<button type="button" class="btn ghost" id="sp-add">Добавить специальность</button>
|
||
<button class="btn" value="close">Готово</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<dialog id="dlg">
|
||
<form method="dialog">
|
||
<div class="dlg-head" id="dlg-title">Редактирование</div>
|
||
<div class="dlg-body" id="dlg-body"></div>
|
||
<div class="dlg-foot">
|
||
<button class="btn ghost" value="cancel">Отмена</button>
|
||
<button class="btn" id="dlg-save" value="save">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<!-- карточка заявки («Заявки» → строка) -->
|
||
<dialog id="ld-dlg" class="ldd" aria-labelledby="ldd-title">
|
||
<form method="dialog" class="ldd__form">
|
||
<div class="ldd__head">
|
||
<span class="ldd__icon" data-ldd-icon="doc"></span>
|
||
<div><h2 id="ldd-title">Заявка</h2><p>Просмотр и редактирование информации о заявке</p></div>
|
||
<button type="button" class="ldd__close" id="ldd-close" aria-label="Закрыть"><span data-ldd-icon="close"></span></button>
|
||
</div>
|
||
<div class="ldd__card">
|
||
<h3 class="ldd__sec"><span data-ldd-icon="user"></span>Информация о заявке</h3>
|
||
<div class="ldd__grid"><div class="ldd__col" id="ldd-left"></div><div class="ldd__col" id="ldd-right"></div></div>
|
||
<h3 class="ldd__sec ldd__sec--line"><span data-ldd-icon="gear"></span>Дополнительные настройки</h3>
|
||
<div class="ldd__grid">
|
||
<label class="ldd__field"><span>Статус</span>
|
||
<span class="ldd__select"><i class="ldd__dot" id="ldd-dot"></i><select id="ldd-status"></select></span></label>
|
||
<label class="ldd__field"><span>Комментарий администратора (виден только в админке)</span>
|
||
<textarea id="ldd-comment" rows="3" placeholder="Добавьте комментарий…"></textarea></label>
|
||
</div>
|
||
</div>
|
||
<div class="ldd__foot">
|
||
<button type="button" class="ldd__cancel" id="ldd-cancel">Отмена</button>
|
||
<button type="submit" class="ldd__save" id="ldd-save"><span data-ldd-icon="save"></span>Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</dialog>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const API = '/api/v1';
|
||
const $ = s => document.querySelector(s);
|
||
const el = (t, p = {}) => Object.assign(document.createElement(t), p);
|
||
|
||
function toast(msg, bad) {
|
||
const t = $('#toast');
|
||
t.textContent = msg; t.className = 'toast show' + (bad ? ' bad' : '');
|
||
setTimeout(() => t.className = 'toast', 2600);
|
||
}
|
||
|
||
async function api(path, opts = {}) {
|
||
const r = await fetch(API + path, {
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...opts,
|
||
});
|
||
const body = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(body.error?.message || 'Ошибка ' + r.status);
|
||
return body;
|
||
}
|
||
|
||
/* ---------- вход ---------- */
|
||
async function boot() {
|
||
try {
|
||
const me = await api('/admin/me');
|
||
showApp(me.data);
|
||
} catch {
|
||
$('#login').hidden = false;
|
||
}
|
||
}
|
||
function showApp(user) {
|
||
$('#login').hidden = true;
|
||
$('#app').hidden = false;
|
||
$('#who').textContent = user.email;
|
||
loadServices().then(() => loadBranchList()).then(loadAdminDoctors).catch(e => toast(e.message, true));
|
||
loadSections().then(loadPrices);
|
||
// акциям нужен список чек-апов для выбора программы
|
||
loadCheckups().then(loadPromos).catch(e => toast(e.message, true));
|
||
loadBanners().catch(e => toast(e.message, true));
|
||
loadPosts().catch(e => toast(e.message, true));
|
||
loadVacancies().catch(e => toast(e.message, true));
|
||
loadDocuments().catch(e => toast(e.message, true));
|
||
loadFAQ().catch(e => toast(e.message, true));
|
||
loadDoctorsList().then(loadReviews).catch(e => toast(e.message, true));
|
||
loadPartners().catch(e => toast(e.message, true));
|
||
loadPages().catch(e => toast(e.message, true));
|
||
loadSettings().catch(e => toast(e.message, true));
|
||
loadBranches().catch(e => toast(e.message, true));
|
||
loadRedirects().catch(e => toast(e.message, true));
|
||
}
|
||
$('#l-btn').onclick = async () => {
|
||
$('#l-err').textContent = '';
|
||
try {
|
||
const r = await api('/admin/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ email: $('#l-email').value, password: $('#l-pass').value }),
|
||
});
|
||
showApp(r.data);
|
||
} catch (e) { $('#l-err').textContent = e.message; }
|
||
};
|
||
$('#l-pass').addEventListener('keydown', e => { if (e.key === 'Enter') $('#l-btn').click(); });
|
||
$('#logout').onclick = async () => { await api('/admin/logout', { method: 'POST' }); location.reload(); };
|
||
|
||
|
||
/* ---------- услуги ---------- */
|
||
let servicesCache = [];
|
||
function editService(s) {
|
||
openDialog('Услуга: ' + s.name, [
|
||
{ key: 'name', label: 'Название', value: s.name },
|
||
{ key: 'short_name', label: 'Короткое название', value: s.short_name },
|
||
{ key: 'lead', label: 'Краткое описание', value: s.lead, type: 'textarea' },
|
||
{ key: 'sort', label: 'Порядок', value: s.sort, type: 'number' },
|
||
{ key: 'published', label: 'Опубликована', value: s.published, type: 'checkbox' },
|
||
{ key: 'seo_title', label: 'SEO title', value: s.seo_title },
|
||
{ key: 'seo_description', label: 'SEO description', value: s.seo_description, type: 'textarea' },
|
||
{ key: 'seo_h1', label: 'SEO h1', value: s.seo_h1 },
|
||
], async data => {
|
||
data.sort = Number(data.sort) || 100;
|
||
await api('/admin/services/' + s.id, { method: 'PUT', body: JSON.stringify(data) });
|
||
toast('Услуга сохранена'); loadServices();
|
||
});
|
||
}
|
||
|
||
/* ---------- прайс ---------- */
|
||
let sections = [], pOffset = 0, pTotal = 0;
|
||
const PLIMIT = 50;
|
||
|
||
async function loadSections() {
|
||
const r = await api('/admin/price-sections');
|
||
sections = r.data || [];
|
||
const sel = $('#p-section');
|
||
sel.textContent = '';
|
||
sel.append(el('option', { value: '0', textContent: 'Все разделы' }));
|
||
// разделы с позициями — по алфавиту; пустые (их нет и на старом сайте) — отдельной группой в конце
|
||
const byName = (a, b) => a.name.localeCompare(b.name, 'ru');
|
||
for (const s of sections.filter(x => x.count).sort(byName)) {
|
||
sel.append(el('option', { value: s.id, textContent: s.name + ' (' + s.count + ')' }));
|
||
}
|
||
const empty = sections.filter(x => !x.count).sort(byName);
|
||
if (empty.length) {
|
||
const g = el('optgroup', { label: 'Пустые разделы' });
|
||
for (const s of empty) g.append(el('option', { value: s.id, textContent: s.name + ' — пусто' }));
|
||
sel.append(g);
|
||
}
|
||
}
|
||
|
||
async function loadPrices() {
|
||
const sid = $('#p-section').value || 0;
|
||
const q = encodeURIComponent($('#p-q').value.trim());
|
||
const r = await api(`/admin/price-items?section_id=${sid}&q=${q}&limit=${PLIMIT}&offset=${pOffset}`);
|
||
pTotal = r.meta?.total || 0;
|
||
const tb = $('#p-body'); tb.textContent = '';
|
||
$('#p-count').textContent = pTotal + ' ' + plural(pTotal, 'позиция', 'позиции', 'позиций');
|
||
if (!(r.data || []).length) {
|
||
const tr = el('tr');
|
||
const td = el('td', { colSpan: 7, className: 'muted dr-empty' });
|
||
td.textContent = String(sid) !== '0' && !q ? 'В этом разделе пока нет позиций. Добавьте позицию и выберите этот раздел.' : 'Ничего не нашлось — измените поиск или раздел.';
|
||
tr.append(td); tb.append(tr);
|
||
}
|
||
for (const p of (r.data || [])) {
|
||
const tr = el('tr');
|
||
tr.append(
|
||
el('td', { textContent: p.name }),
|
||
el('td', { className: 'muted', textContent: p.section_name }),
|
||
el('td', { textContent: Number(p.price).toLocaleString('ru-RU') }),
|
||
el('td', { className: 'muted', textContent: p.unit || '—' }),
|
||
el('td', { textContent: p.price_from ? 'да' : '' }),
|
||
);
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (p.published ? '' : ' off'), textContent: p.published ? 'вкл' : 'выкл' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editPrice(p);
|
||
const d = el('button', { className: 'btn danger', textContent: '✕', style: 'margin-left:6px' });
|
||
d.onclick = () => delPrice(p);
|
||
act.append(e, d); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
const from = pTotal ? pOffset + 1 : 0;
|
||
$('#p-page').textContent = `${from}–${Math.min(pOffset + PLIMIT, pTotal)} из ${pTotal}`;
|
||
$('#p-prev').disabled = pOffset === 0;
|
||
$('#p-next').disabled = pOffset + PLIMIT >= pTotal;
|
||
}
|
||
$('#p-section').onchange = () => { pOffset = 0; loadPrices(); };
|
||
$('#p-q').addEventListener('input', debounce(() => { pOffset = 0; loadPrices(); }, 300));
|
||
$('#p-prev').onclick = () => { pOffset = Math.max(0, pOffset - PLIMIT); loadPrices(); };
|
||
$('#p-next').onclick = () => { pOffset += PLIMIT; loadPrices(); };
|
||
|
||
function priceFields(p) {
|
||
return [
|
||
{ key: 'name', label: 'Название', value: p.name || '' },
|
||
{ key: 'price', label: 'Цена, ₽', value: p.price ?? 0, type: 'number' },
|
||
{ key: 'section_id', label: 'Раздел', value: p.section_id || (sections[0] && sections[0].id),
|
||
type: 'select', options: sections.map(s => ({ value: s.id, label: s.name })) },
|
||
{ key: 'service_id', label: 'Привязать к услуге', value: p.service_id || '',
|
||
type: 'select', options: [{ value: '', label: '— не привязана —' }]
|
||
.concat(servicesCache.filter(s => s.parent_id).map(s => ({ value: s.id, label: s.name }))) },
|
||
{ key: 'unit', label: 'Единица (напр. «за сеанс»)', value: p.unit || '' },
|
||
{ key: 'price_from', label: 'Цена «от»', value: !!p.price_from, type: 'checkbox' },
|
||
{ key: 'sort', label: 'Порядок', value: p.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Показывать', value: p.published !== false, type: 'checkbox' },
|
||
];
|
||
}
|
||
function normalizePrice(d) {
|
||
d.price = Number(d.price) || 0;
|
||
d.sort = Number(d.sort) || 100;
|
||
d.section_id = Number(d.section_id) || 0;
|
||
d.service_id = d.service_id ? Number(d.service_id) : null;
|
||
return d;
|
||
}
|
||
function editPrice(p) {
|
||
openDialog('Позиция прайса', priceFields(p), async d => {
|
||
await api('/admin/price-items/' + p.id, { method: 'PUT', body: JSON.stringify(normalizePrice(d)) });
|
||
toast('Сохранено'); loadPrices();
|
||
});
|
||
}
|
||
$('#p-add').onclick = () => {
|
||
openDialog('Новая позиция', priceFields({ published: true, sort: 100 }), async d => {
|
||
await api('/admin/price-items', { method: 'POST', body: JSON.stringify(normalizePrice(d)) });
|
||
toast('Позиция добавлена'); loadSections(); loadPrices();
|
||
});
|
||
};
|
||
async function delPrice(p) {
|
||
if (!confirm('Удалить позицию «' + p.name + '»? Это действие необратимо.')) return;
|
||
await api('/admin/price-items/' + p.id, { method: 'DELETE' });
|
||
toast('Удалено'); loadSections(); loadPrices();
|
||
}
|
||
|
||
/* ---------- акции ---------- */
|
||
let checkupsCache = [];
|
||
const isoDate = v => (v ? String(v).slice(0, 10) : '');
|
||
const ruDate = v => (v ? isoDate(v).split('-').reverse().join('.') : '');
|
||
const rub = v => Number(v).toLocaleString('ru-RU') + ' ₽';
|
||
|
||
async function loadPromos() {
|
||
const r = await api('/admin/promos');
|
||
const list = r.data || [];
|
||
$('#pr-count').textContent = list.length + ' акций';
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const tb = $('#pr-body'); tb.textContent = '';
|
||
for (const p of list) {
|
||
const tr = el('tr');
|
||
const term = p.starts_at || p.ends_at ? `${ruDate(p.starts_at) || '…'} — ${ruDate(p.ends_at) || '…'}` : 'без срока';
|
||
tr.append(
|
||
el('td', { textContent: p.title }),
|
||
el('td', { textContent: p.discount_percent ? p.discount_percent + '%' : '—' }),
|
||
el('td', { className: 'muted', textContent: p.checkup_name || '—' }),
|
||
el('td', { className: 'muted', textContent: term }),
|
||
);
|
||
const expired = p.ends_at && isoDate(p.ends_at) < today;
|
||
const st = el('td');
|
||
st.append(el('span', {
|
||
className: 'pill' + (p.published && !expired ? '' : ' off'),
|
||
textContent: !p.published ? 'скрыта' : expired ? 'завершена' : 'на сайте',
|
||
}));
|
||
if (p.published && !p.terms) st.append(el('div', { className: 'warn', textContent: 'нет условий' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editPromo(p);
|
||
const d = el('button', { className: 'btn danger', textContent: '✕', style: 'margin-left:6px' });
|
||
d.onclick = () => delPromo(p);
|
||
act.append(e, d); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
|
||
function promoFields(p) {
|
||
return [
|
||
{ key: 'title', label: 'Название (например, «Я в сад»)', value: p.title || '' },
|
||
{ key: 'slug', label: 'Адрес латиницей (пусто — сформируется из названия)', value: p.slug || '' },
|
||
{ key: 'discount_percent', label: 'Скидка, %', value: p.discount_percent ?? '', type: 'number' },
|
||
{ key: 'terms', label: 'Условия акции: на что скидка, для кого, ограничения (обязательно для показа на сайте)',
|
||
value: p.terms || '', type: 'textarea' },
|
||
{ key: 'checkup_id', label: 'Чек-ап, на который действует акция', value: p.checkup_id || '', type: 'select',
|
||
options: [{ value: '', label: '— не связана с чек-апом —' }]
|
||
.concat(checkupsCache.map(c => ({ value: c.id, label: c.name }))) },
|
||
{ key: 'starts_at', label: 'Начало', value: isoDate(p.starts_at), type: 'date' },
|
||
{ key: 'ends_at', label: 'Последний день акции (после него акция скроется с сайта)', value: isoDate(p.ends_at), type: 'date' },
|
||
{ key: 'lead', label: 'Краткое описание', value: p.lead || '', type: 'textarea' },
|
||
{ key: 'image_id', label: 'Картинка', value: p.image_id || '', preview: p.image, type: 'image' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: p.published !== false, type: 'checkbox' },
|
||
{ key: 'seo_title', label: 'SEO title', value: p.seo_title || '' },
|
||
{ key: 'seo_description', label: 'SEO description', value: p.seo_description || '', type: 'textarea' },
|
||
{ key: 'seo_h1', label: 'SEO h1', value: p.seo_h1 || '' },
|
||
];
|
||
}
|
||
function normalizePromo(d) {
|
||
d.discount_percent = d.discount_percent === '' ? null : Number(d.discount_percent);
|
||
d.checkup_id = d.checkup_id ? Number(d.checkup_id) : null;
|
||
d.image_id = d.image_id ? Number(d.image_id) : null;
|
||
d.starts_at = d.starts_at || null;
|
||
d.ends_at = d.ends_at || null;
|
||
return d;
|
||
}
|
||
function editPromo(p) {
|
||
openDialog('Акция: ' + p.title, promoFields(p), async d => {
|
||
await api('/admin/promos/' + p.id, { method: 'PUT', body: JSON.stringify(normalizePromo(d)) });
|
||
toast('Акция сохранена'); loadPromos();
|
||
});
|
||
}
|
||
$('#pr-add').onclick = () => {
|
||
openDialog('Новая акция', promoFields({ published: true }), async d => {
|
||
await api('/admin/promos', { method: 'POST', body: JSON.stringify(normalizePromo(d)) });
|
||
toast('Акция добавлена'); loadPromos();
|
||
});
|
||
};
|
||
async function delPromo(p) {
|
||
if (!confirm('Удалить акцию «' + p.title + '»? Она пропадёт с сайта.')) return;
|
||
try {
|
||
await api('/admin/promos/' + p.id, { method: 'DELETE' });
|
||
toast('Акция удалена'); loadPromos();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
/* ---------- чек-апы ---------- */
|
||
async function loadCheckups() {
|
||
const r = await api('/admin/checkups');
|
||
checkupsCache = r.data || [];
|
||
renderCheckups();
|
||
}
|
||
// Картинки — те же, что у программ на сайте (перенесены со старого danamedical.ru).
|
||
function renderCheckups() {
|
||
const aud = $('#ck-aud').value, q = $('#ck-q').value.trim().toLowerCase(), sort = $('#ck-sort').value;
|
||
let list = checkupsCache.filter(c => (aud === 'hidden' ? !c.published : !aud || c.audience === aud) &&
|
||
(!q || c.name.toLowerCase().replace(/ĸ/g, 'к').includes(q)));
|
||
if (sort === 'name') list = list.slice().sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
||
if (sort === 'price') list = list.slice().sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity));
|
||
$('#ck-count').textContent = list.length + ' ' + plural(list.length, 'программа', 'программы', 'программ');
|
||
const tb = $('#ck-body'); tb.textContent = '';
|
||
if (!list.length) {
|
||
const tr = el('tr'); tr.append(el('td', { colSpan: 6, className: 'muted dr-empty', textContent: 'Программ не нашлось.' })); tb.append(tr);
|
||
}
|
||
for (const c of list) {
|
||
const tr = el('tr');
|
||
const prog = el('td');
|
||
const box = el('div', { className: 'ck-prog' });
|
||
box.append(c.image ? el('img', { className: 'ck-thumb', src: c.image, alt: '', loading: 'lazy' }) : el('span', { className: 'ck-thumb ck-thumb--empty', textContent: 'нет фото' }));
|
||
const name = el('button', { type: 'button', className: 'dr-name', textContent: c.name });
|
||
name.onclick = () => editCheckup(c);
|
||
box.append(name); prog.append(box);
|
||
const price = el('td');
|
||
if (c.price) {
|
||
price.append(el('b', { className: 'ck-price', textContent: rub(c.price) }));
|
||
if (c.old_price) price.append(el('s', { className: 'muted ck-old', textContent: rub(c.old_price) }));
|
||
} else price.append(el('span', { textContent: 'по запросу' }));
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill dot ' + (c.published ? '' : 'off'), textContent: c.published ? 'опубл.' : 'скрыт' }));
|
||
const act = el('td', { className: 'dr-actions' });
|
||
const e = el('button', { type: 'button', className: 'bn-edit' });
|
||
e.innerHTML = svIcon(BN_ICONS.pencil) + '<span>Изменить</span>';
|
||
e.onclick = () => editCheckup(c);
|
||
const more = el('button', { type: 'button', className: 'icon-btn', title: 'Ещё' });
|
||
more.setAttribute('aria-label', 'Ещё действия: ' + c.name);
|
||
more.setAttribute('aria-haspopup', 'menu');
|
||
more.innerHTML = svIcon(BN_ICONS.dots);
|
||
more.onclick = ev => { ev.stopPropagation(); checkupMenu(c, more); };
|
||
act.append(e, more);
|
||
tr.append(prog,
|
||
el('td', { className: 'muted', textContent: c.audience === 'kids' ? 'дети' : 'взрослые' }),
|
||
price,
|
||
el('td', { className: 'muted', textContent: c.items.length ? c.items.length + ' ' + plural(c.items.length, 'пункт', 'пункта', 'пунктов') : 'из описания' }),
|
||
st, act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
function checkupMenu(c, 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);
|
||
};
|
||
const url = location.origin + '/checkup/' + c.slug + '/';
|
||
if (c.published) item('Открыть страницу на сайте', () => window.open(url, '_blank', 'noopener'));
|
||
item('Скопировать ссылку', () => navigator.clipboard.writeText(url).then(() => toast('Ссылка скопирована'), () => toast(url)));
|
||
item('Изменить программу', () => editCheckup(c));
|
||
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;
|
||
}
|
||
$('#ck-aud').onchange = renderCheckups;
|
||
$('#ck-sort').onchange = renderCheckups;
|
||
$('#ck-q').addEventListener('input', debounce(renderCheckups, 200));
|
||
|
||
// Состав старых программ записан в описании строками «✅ …». Пока его не сохранили
|
||
// списком, подставляем эти строки в поле — сохранение превратит их в состав.
|
||
function itemsFromBlocks(blocks) {
|
||
const html = (blocks || []).map(b => b.html || '').join('\n').replace(/<br\s*\/?>/gi, '\n');
|
||
const text = new DOMParser().parseFromString(html, 'text/html').body.textContent || '';
|
||
return text.split('\n').map(s => s.trim()).filter(s => s.startsWith('✅'))
|
||
.map(s => s.replace(/^✅\s*/, '').replace(/ĸ/g, 'к'));
|
||
}
|
||
|
||
function checkupFields(c) {
|
||
const items = c.items && c.items.length ? c.items : itemsFromBlocks(c.blocks);
|
||
return [
|
||
{ key: 'name', label: 'Название программы', value: c.name || '' },
|
||
{ key: 'slug', label: 'Адрес латиницей (пусто — сформируется из названия)', value: c.slug || '' },
|
||
{ key: 'audience', label: 'Для кого', value: c.audience || 'adults', type: 'select',
|
||
options: [{ value: 'adults', label: 'Взрослые' }, { value: 'kids', label: 'Дети' }] },
|
||
{ key: 'lead', label: 'Короткое описание', value: c.lead || '', type: 'textarea' },
|
||
{ key: 'price', label: 'Цена, ₽ (пусто — на сайте «по запросу»)', value: c.price ?? '', type: 'number' },
|
||
{ key: 'old_price', label: 'Цена без скидки, ₽ (необязательно)', value: c.old_price ?? '', type: 'number' },
|
||
{ key: 'items', label: 'Состав программы — по одному исследованию на строку', value: items.join('\n'), type: 'textarea', rows: 10 },
|
||
{ key: 'image_id', label: 'Фото', value: c.image_id || '', preview: c.image, type: 'image' },
|
||
{ key: 'sort', label: 'Порядок', value: c.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Опубликован', value: c.published !== false, type: 'checkbox' },
|
||
{ key: 'seo_title', label: 'SEO title', value: c.seo_title || '' },
|
||
{ key: 'seo_description', label: 'SEO description', value: c.seo_description || '', type: 'textarea' },
|
||
{ key: 'seo_h1', label: 'SEO h1', value: c.seo_h1 || '' },
|
||
];
|
||
}
|
||
function normalizeCheckup(d) {
|
||
d.price = d.price === '' ? null : Number(d.price);
|
||
d.old_price = d.old_price === '' ? null : Number(d.old_price);
|
||
d.image_id = d.image_id ? Number(d.image_id) : null;
|
||
d.sort = Number(d.sort) || 100;
|
||
d.items = d.items.split('\n').map(s => s.trim()).filter(Boolean);
|
||
return d;
|
||
}
|
||
function editCheckup(c) {
|
||
openDialog('Чек-ап: ' + c.name, checkupFields(c), async d => {
|
||
await api('/admin/checkups/' + c.id, { method: 'PUT', body: JSON.stringify(normalizeCheckup(d)) });
|
||
toast('Чек-ап сохранён'); await loadCheckups(); loadPromos();
|
||
});
|
||
}
|
||
$('#ck-add').onclick = () => {
|
||
openDialog('Новый чек-ап', checkupFields({ published: true, sort: 100, audience: 'adults', items: [] }), async d => {
|
||
await api('/admin/checkups', { method: 'POST', body: JSON.stringify(normalizeCheckup(d)) });
|
||
toast('Чек-ап добавлен'); loadCheckups();
|
||
});
|
||
};
|
||
|
||
/* ---------- баннеры ---------- */
|
||
const LAYOUTS = { text: 'Текст на цветном фоне', doctor: 'Врач с фото', image: 'Готовая картинка' };
|
||
const THEMES = { navy: 'Тёмно-синий', lilac: 'Сиреневый', orange: 'Оранжевый' };
|
||
const pad2 = n => String(n).padStart(2, '0');
|
||
// timestamptz приходит в UTC — показываем дату по часам браузера (Москва)
|
||
const localDate = v => {
|
||
if (!v) return '';
|
||
const d = new Date(v);
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||
};
|
||
|
||
function bannerFields(b) {
|
||
return [
|
||
{ key: 'layout', label: 'Вид слайда', value: b.layout || 'text', type: 'select',
|
||
options: Object.entries(LAYOUTS).map(([value, label]) => ({ value, label })) },
|
||
{ key: 'theme', label: 'Цвет фона', value: b.theme || 'navy', type: 'select',
|
||
options: Object.entries(THEMES).map(([value, label]) => ({ value, label })) },
|
||
{ key: 'title', label: 'Заголовок (у слайда с врачом первое слово будет жирным)', value: b.title || '' },
|
||
{ key: 'subtitle', label: 'Подзаголовок, у слайда с врачом — ФИО', value: b.subtitle || '' },
|
||
{ key: 'badge', label: 'Плашка сверху, например дата приёма', value: b.badge || '' },
|
||
{ key: 'chips', label: 'Плашки-преимущества — по одной на строку, до 6', value: (b.chips || []).join('\n'), type: 'textarea', rows: 4 },
|
||
{ key: 'image_id', label: 'Картинка или фото врача', value: b.image_id || '', preview: b.image, type: 'image' },
|
||
{ key: 'mobile_image_id', label: 'Картинка для телефона — горизонтальная 724×440 (слайд 362×220). Пусто — на телефоне покажется основная картинка целиком', value: b.mobile_image_id || '', preview: b.mobile_image, type: 'image' },
|
||
{ key: 'link_url', label: 'Ссылка (/checkup/ или https://…)', value: b.link_url || '' },
|
||
{ key: 'button_text', label: 'Текст кнопки (для текстового слайда)', value: b.button_text || '' },
|
||
{ key: 'disclaimer', label: 'Предупреждение (пусто — стандартное о противопоказаниях)', value: b.disclaimer || '' },
|
||
{ key: 'starts_at', label: 'Показывать с', value: localDate(b.starts_at), type: 'date' },
|
||
{ key: 'ends_at', label: 'Показывать по (включительно)', value: localDate(b.ends_at), type: 'date' },
|
||
{ key: 'sort', label: 'Порядок', value: b.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: b.published !== false, type: 'checkbox' },
|
||
];
|
||
}
|
||
function normalizeBanner(d) {
|
||
d.position = 'home_hero';
|
||
d.image_id = d.image_id ? Number(d.image_id) : null;
|
||
d.mobile_image_id = d.mobile_image_id ? Number(d.mobile_image_id) : null;
|
||
d.chips = d.chips.split('\n').map(s => s.trim()).filter(Boolean);
|
||
// даты — по московскому времени: с начала первого дня до конца последнего
|
||
d.starts_at = d.starts_at ? `${d.starts_at}T00:00:00+03:00` : null;
|
||
d.ends_at = d.ends_at ? `${d.ends_at}T23:59:59+03:00` : null;
|
||
d.sort = Number(d.sort) || 100;
|
||
return d;
|
||
}
|
||
function editBanner(b) {
|
||
openDialog('Слайд: ' + (b.title || 'картинка'), bannerFields(b), async d => {
|
||
await api('/admin/banners/' + b.id, { method: 'PUT', body: JSON.stringify(normalizeBanner(d)) });
|
||
toast('Слайд сохранён'); loadBanners();
|
||
});
|
||
}
|
||
async function delBanner(b) {
|
||
if (!confirm('Удалить слайд «' + (b.title || 'картинка') + '»? Это действие необратимо.')) return;
|
||
try {
|
||
await api('/admin/banners/' + b.id, { method: 'DELETE' });
|
||
toast('Слайд удалён'); loadBanners();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
/* ---------- новости и статьи ---------- */
|
||
const POST_KINDS = {
|
||
news: { one: 'Новость', add: 'Новая новость', path: '/news/',
|
||
note: 'Новости клиники: события, приезд врачей, мероприятия. Дата публикации показывается на сайте и задаёт порядок.' },
|
||
articles: { one: 'Статья', add: 'Новая статья', path: '/articles/',
|
||
note: 'Статьи о здоровье. Под каждой статьёй сайт сам добавляет пометку, что информация справочная и не заменяет консультацию врача.' },
|
||
};
|
||
let postsCache = [];
|
||
|
||
async function loadPosts() {
|
||
const kind = $('#po-kind').value;
|
||
$('#po-note').textContent = POST_KINDS[kind].note;
|
||
const r = await api('/admin/' + kind);
|
||
postsCache = r.data || [];
|
||
renderPosts();
|
||
}
|
||
function renderPosts() {
|
||
const kind = $('#po-kind').value;
|
||
const q = $('#po-q').value.trim().toLowerCase();
|
||
const list = postsCache.filter(p => !q || p.title.toLowerCase().includes(q));
|
||
$('#po-count').textContent = list.length + ' записей';
|
||
const tb = $('#po-body'); tb.textContent = '';
|
||
for (const p of list) {
|
||
const tr = el('tr');
|
||
const title = el('td');
|
||
title.append(el('div', { textContent: p.title }), el('div', { className: 'muted', textContent: POST_KINDS[kind].path + p.slug + '/' }));
|
||
tr.append(
|
||
title,
|
||
el('td', { className: 'muted', textContent: ruDate(localDate(p.published_at)) }),
|
||
el('td', { className: 'muted', textContent: p.image ? 'есть' : '—' }),
|
||
);
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (p.published ? '' : ' off'), textContent: p.published ? 'на сайте' : 'скрыта' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editPost(p);
|
||
const d = el('button', { className: 'btn danger', textContent: '✕', style: 'margin-left:6px' });
|
||
d.onclick = () => delPost(p);
|
||
act.append(e, d); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
$('#po-kind').onchange = () => { $('#po-q').value = ''; loadPosts().catch(e => toast(e.message, true)); };
|
||
$('#po-q').addEventListener('input', debounce(renderPosts, 200));
|
||
|
||
function postFields(p) {
|
||
const html = (p.blocks || []).map(b => b.html || '').join('\n');
|
||
return [
|
||
{ key: 'title', label: 'Заголовок', value: p.title || '' },
|
||
{ key: 'slug', label: 'Адрес латиницей (пусто — сформируется из заголовка). У опубликованной записи лучше не менять: старые ссылки перестанут работать', value: p.slug || '' },
|
||
{ key: 'published_at', label: 'Дата публикации', value: localDate(p.published_at), type: 'date' },
|
||
{ key: 'image_id', label: 'Обложка (в списке и в начале страницы)', value: p.image_id || '', preview: p.image, type: 'image' },
|
||
{ key: 'html', label: 'Текст', value: html, type: 'richtext' },
|
||
{ key: 'lead', label: 'Краткое описание для поисковиков и соцсетей (1–2 предложения)', value: p.lead || '', type: 'textarea' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: p.published !== false, type: 'checkbox' },
|
||
{ key: 'seo_title', label: 'SEO title', value: p.seo_title || '' },
|
||
{ key: 'seo_description', label: 'SEO description', value: p.seo_description || '', type: 'textarea' },
|
||
{ key: 'seo_h1', label: 'SEO h1', value: p.seo_h1 || '' },
|
||
];
|
||
}
|
||
function normalizePost(d, p) {
|
||
d.image_id = d.image_id ? Number(d.image_id) : null;
|
||
// дату не трогали — отправляем исходное время, чтобы не сбить порядок записей за один день
|
||
if (p.published_at && d.published_at === localDate(p.published_at)) d.published_at = p.published_at;
|
||
return d;
|
||
}
|
||
function editPost(p) {
|
||
const kind = $('#po-kind').value;
|
||
openDialog(POST_KINDS[kind].one + ': ' + p.title, postFields(p), async d => {
|
||
await api('/admin/' + kind + '/' + p.id, { method: 'PUT', body: JSON.stringify(normalizePost(d, p)) });
|
||
toast('Сохранено'); loadPosts();
|
||
});
|
||
}
|
||
$('#po-add').onclick = () => {
|
||
const kind = $('#po-kind').value;
|
||
const fields = postFields({ published: true });
|
||
fields.find(f => f.key === 'published_at').value = localDate(new Date().toISOString());
|
||
openDialog(POST_KINDS[kind].add, fields, async d => {
|
||
await api('/admin/' + kind, { method: 'POST', body: JSON.stringify(normalizePost(d, {})) });
|
||
toast('Добавлено'); loadPosts();
|
||
});
|
||
};
|
||
async function delPost(p) {
|
||
const kind = $('#po-kind').value;
|
||
if (!confirm('Удалить «' + p.title + '»? Запись пропадёт с сайта.')) return;
|
||
try {
|
||
await api('/admin/' + kind + '/' + p.id, { method: 'DELETE' });
|
||
toast('Удалено'); loadPosts();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
/* ---------- вакансии ---------- */
|
||
async function loadVacancies() {
|
||
const r = await api('/admin/vacancies');
|
||
const list = r.data || [];
|
||
$('#va-count').textContent = list.length + ' вакансий';
|
||
const tb = $('#va-body'); tb.textContent = '';
|
||
for (const v of list) {
|
||
const tr = el('tr');
|
||
const filled = [v.description, v.duties, v.requirements, v.conditions].filter(Boolean).length;
|
||
tr.append(
|
||
el('td', { textContent: v.title }),
|
||
el('td', { className: 'muted', textContent: v.salary || '—' }),
|
||
el('td', { className: filled ? 'muted' : 'warn', textContent: filled ? filled + ' из 4 разделов' : 'пусто' }),
|
||
el('td', { textContent: v.sort }),
|
||
);
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (v.published ? '' : ' off'), textContent: v.published ? 'на сайте' : 'скрыта' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editVacancy(v);
|
||
const d = el('button', { className: 'btn danger', textContent: '✕', style: 'margin-left:6px' });
|
||
d.onclick = () => delVacancy(v);
|
||
act.append(e, d); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
function vacancyFields(v) {
|
||
return [
|
||
{ key: 'title', label: 'Название вакансии', value: v.title || '' },
|
||
{ key: 'slug', label: 'Адрес латиницей (пусто — сформируется из названия)', value: v.slug || '' },
|
||
{ key: 'salary', label: 'Зарплата («от 30 000 руб.» или «По результатам собеседования»)', value: v.salary || '' },
|
||
{ key: 'description', label: 'Коротко о вакансии', value: v.description || '', type: 'textarea' },
|
||
{ key: 'duties', label: 'Чем предстоит заниматься — по пункту на строку', value: v.duties || '', type: 'textarea', rows: 5 },
|
||
{ key: 'requirements', label: 'Требования — по пункту на строку', value: v.requirements || '', type: 'textarea', rows: 5 },
|
||
{ key: 'conditions', label: 'Условия — по пункту на строку', value: v.conditions || '', type: 'textarea', rows: 5 },
|
||
{ key: 'sort', label: 'Порядок (меньше — выше в списке)', value: v.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: v.published !== false, type: 'checkbox' },
|
||
];
|
||
}
|
||
function normalizeVacancy(d) { d.sort = Number(d.sort) || 100; return d; }
|
||
function editVacancy(v) {
|
||
openDialog('Вакансия: ' + v.title, vacancyFields(v), async d => {
|
||
await api('/admin/vacancies/' + v.id, { method: 'PUT', body: JSON.stringify(normalizeVacancy(d)) });
|
||
toast('Вакансия сохранена'); loadVacancies();
|
||
});
|
||
}
|
||
$('#va-add').onclick = () => {
|
||
openDialog('Новая вакансия', vacancyFields({ published: true, sort: 100 }), async d => {
|
||
await api('/admin/vacancies', { method: 'POST', body: JSON.stringify(normalizeVacancy(d)) });
|
||
toast('Вакансия добавлена'); loadVacancies();
|
||
});
|
||
};
|
||
async function delVacancy(v) {
|
||
if (!confirm('Удалить вакансию «' + v.title + '» насовсем? Если набор просто приостановлен, лучше снять галочку «Показывать на сайте».')) return;
|
||
try {
|
||
await api('/admin/vacancies/' + v.id, { method: 'DELETE' });
|
||
toast('Вакансия удалена'); loadVacancies();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
/* ---------- документы ---------- */
|
||
const DOC_KINDS = { license: 'Лицензия', certificate: 'Сертификат', award: 'Награда', other: 'Другой документ', supervisor: 'Надзорный орган' };
|
||
let documentsCache = [];
|
||
|
||
async function loadDocuments() {
|
||
const r = await api('/admin/documents');
|
||
documentsCache = r.data || [];
|
||
renderDocuments();
|
||
}
|
||
function renderDocuments() {
|
||
const kind = $('#dc-kind').value;
|
||
const list = documentsCache.filter(d => !kind || d.kind === kind);
|
||
$('#dc-count').textContent = list.length + ' документов';
|
||
const tb = $('#dc-body'); tb.textContent = '';
|
||
for (const d of list) {
|
||
const tr = el('tr');
|
||
const file = d.file ? (d.file_mime === 'application/pdf' ? 'PDF' : 'картинка') : '';
|
||
tr.append(
|
||
el('td', { textContent: d.title }),
|
||
el('td', { className: 'muted', textContent: DOC_KINDS[d.kind] || d.kind }),
|
||
el('td', { className: 'muted', textContent: [d.preview ? 'скан' : '', file].filter(Boolean).join(' + ') || '—' }),
|
||
el('td', { textContent: d.sort }),
|
||
);
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (d.published ? '' : ' off'), textContent: d.published ? 'на сайте' : 'скрыт' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editDocument(d);
|
||
const x = el('button', { className: 'btn danger', textContent: '✕', style: 'margin-left:6px' });
|
||
x.onclick = () => delDocument(d);
|
||
act.append(e, x); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
$('#dc-kind').onchange = renderDocuments;
|
||
|
||
function documentFields(d) {
|
||
return [
|
||
{ key: 'kind', label: 'Вид', value: d.kind || 'license', type: 'select',
|
||
options: Object.entries(DOC_KINDS).map(([value, label]) => ({ value, label })) },
|
||
{ key: 'title', label: 'Название (например, «Лицензия на медицинскую деятельность» или «Премия ПроДокторов 2024»)', value: d.title || '' },
|
||
{ key: 'body', label: 'Описание. Для надзорного органа — по строке: «Адрес: …», «Телефон: …», «Сайт: https://…»', value: d.body || '', type: 'textarea', rows: 4 },
|
||
{ key: 'preview_id', label: 'Скан или логотип — картинка, которую видно на странице', value: d.preview_id || '', preview: d.preview, type: 'image' },
|
||
{ key: 'file_id', label: 'Файл документа — PDF или скан в хорошем качестве (открывается по клику)', value: d.file_id || '', file: d.file, type: 'document' },
|
||
{ key: 'issued_at', label: 'Дата выдачи (необязательно)', value: isoDate(d.issued_at), type: 'date' },
|
||
{ key: 'sort', label: 'Порядок (меньше — выше)', value: d.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: d.published !== false, type: 'checkbox' },
|
||
];
|
||
}
|
||
function normalizeDocument(d) {
|
||
d.preview_id = d.preview_id ? Number(d.preview_id) : null;
|
||
d.file_id = d.file_id ? Number(d.file_id) : null;
|
||
d.issued_at = d.issued_at || null;
|
||
d.sort = Number(d.sort) || 100;
|
||
return d;
|
||
}
|
||
function editDocument(d) {
|
||
openDialog('Документ: ' + d.title, documentFields(d), async data => {
|
||
await api('/admin/documents/' + d.id, { method: 'PUT', body: JSON.stringify(normalizeDocument(data)) });
|
||
toast('Документ сохранён'); loadDocuments();
|
||
});
|
||
}
|
||
$('#dc-add').onclick = () => {
|
||
const kind = $('#dc-kind').value || 'license';
|
||
openDialog('Новый документ', documentFields({ kind, published: true, sort: 100 }), async data => {
|
||
await api('/admin/documents', { method: 'POST', body: JSON.stringify(normalizeDocument(data)) });
|
||
toast('Документ добавлен'); loadDocuments();
|
||
});
|
||
};
|
||
async function delDocument(d) {
|
||
if (!confirm('Удалить «' + d.title + '» насовсем? Если документ просто устарел, можно снять галочку «Показывать на сайте».')) return;
|
||
try {
|
||
await api('/admin/documents/' + d.id, { method: 'DELETE' });
|
||
toast('Документ удалён'); loadDocuments();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
/* ---------- вопросы и ответы ---------- */
|
||
async function loadFAQ() {
|
||
const r = await api('/admin/faq');
|
||
const list = r.data || [];
|
||
$('#fq-count').textContent = list.length + ' вопросов';
|
||
const tb = $('#fq-body'); tb.textContent = '';
|
||
for (const f of list) {
|
||
const tr = el('tr');
|
||
const short = f.answer.length > 90 ? f.answer.slice(0, 90) + '…' : f.answer;
|
||
tr.append(
|
||
el('td', { textContent: f.question }),
|
||
el('td', { className: f.answer ? 'muted' : 'warn', textContent: short || 'нет ответа' }),
|
||
el('td', { textContent: f.sort }),
|
||
);
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (f.published ? '' : ' off'), textContent: f.published ? 'на сайте' : 'скрыт' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editFAQ(f);
|
||
const x = el('button', { className: 'btn danger', textContent: '✕', style: 'margin-left:6px' });
|
||
x.onclick = () => delFAQ(f);
|
||
act.append(e, x); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
function faqFields(f) {
|
||
return [
|
||
{ key: 'question', label: 'Вопрос', value: f.question || '' },
|
||
{ key: 'answer', label: 'Ответ (каждая строка — отдельный абзац)', value: f.answer || '', type: 'textarea', rows: 7 },
|
||
{ key: 'sort', label: 'Порядок (меньше — выше)', value: f.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: f.published !== false, type: 'checkbox' },
|
||
];
|
||
}
|
||
// привязку к услуге в форме не показываем: на страницах услуг вопросы пока не выводятся, но и не теряем её
|
||
const normalizeFAQ = (d, f) => ({ ...d, sort: Number(d.sort) || 100, service_id: f.service_id ?? null });
|
||
function editFAQ(f) {
|
||
openDialog('Вопрос', faqFields(f), async d => {
|
||
await api('/admin/faq/' + f.id, { method: 'PUT', body: JSON.stringify(normalizeFAQ(d, f)) });
|
||
toast('Сохранено'); loadFAQ();
|
||
});
|
||
}
|
||
$('#fq-add').onclick = () => {
|
||
openDialog('Новый вопрос', faqFields({ published: true, sort: 100 }), async d => {
|
||
await api('/admin/faq', { method: 'POST', body: JSON.stringify(normalizeFAQ(d, {})) });
|
||
toast('Вопрос добавлен'); loadFAQ();
|
||
});
|
||
};
|
||
async function delFAQ(f) {
|
||
if (!confirm('Удалить вопрос «' + f.question + '»?')) return;
|
||
try {
|
||
await api('/admin/faq/' + f.id, { method: 'DELETE' });
|
||
toast('Вопрос удалён'); loadFAQ();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
/* ---------- врачи (список — в doctors.js) ---------- */
|
||
let branchList = [];
|
||
async function loadBranchList() {
|
||
const r = await api('/admin/branches');
|
||
branchList = r.data || [];
|
||
}
|
||
const shortAddress = a => a.replace(/^г\.\s*Махачкала,\s*/, '').replace(/(\d)\(/g, '$1 (');
|
||
const fullName = d => [d.last_name, d.first_name, d.middle_name].filter(Boolean).join(' ');
|
||
|
||
function editDoctor(d) {
|
||
openDialog('Врач: ' + fullName(d), [
|
||
{ key: 'last_name', label: 'Фамилия', value: d.last_name },
|
||
{ key: 'first_name', label: 'Имя', value: d.first_name },
|
||
{ key: 'middle_name', label: 'Отчество', value: d.middle_name },
|
||
{ key: 'photo_id', label: 'Фото (вертикальное, не меньше 840×1000 — на странице врача оно крупное)', value: d.photo_id || '', preview: d.photo, type: 'image' },
|
||
{ key: 'position', label: 'Должность, как на сайте (например, «Акушер-гинеколог, врач УЗИ»)', value: d.position },
|
||
{ key: 'category', label: 'Категория', value: d.category },
|
||
{ key: 'degree', label: 'Учёная степень', value: d.degree },
|
||
{ key: 'practice_start_year', label: 'Год начала практики (стаж считается сам)', value: d.practice_start_year ?? '', type: 'number' },
|
||
{ key: 'rating', label: 'Рейтинг на сайте (1,0–5,0; оставьте пустым, чтобы считать по отзывам)', value: d.rating ?? '', type: 'number', min: 1, max: 5, step: 0.1 },
|
||
{ key: 'bio', label: 'О враче — абзацы через пустую строку, пункты списка начинайте с «•»', value: d.bio, type: 'textarea', rows: 8 },
|
||
{ key: 'quote', label: 'Цитата врача (необязательно)', value: d.quote, type: 'textarea', rows: 2 },
|
||
{ key: 'booking_provider', label: 'Запись на приём', value: d.booking_provider || 'none', type: 'select',
|
||
options: [{ value: 'none', label: 'Заявка на сайте (перезвонит администратор)' }, { value: 'native', label: 'Онлайн-запись через 1С' }] },
|
||
{ key: 'absent', label: 'Временно не принимает (кнопка записи скрывается)', value: d.absent, type: 'checkbox' },
|
||
{ key: 'show_on_home', label: 'Показывать на главной', value: d.show_on_home, type: 'checkbox' },
|
||
{ key: 'sort', label: 'Порядок (меньше — выше в списке)', value: d.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: d.published, type: 'checkbox' },
|
||
{ key: 'slug', label: 'Адрес латиницей — у опубликованного врача лучше не менять', value: d.slug },
|
||
{ key: 'seo_title', label: 'SEO title', value: d.seo_title || '' },
|
||
{ key: 'seo_description', label: 'SEO description', value: d.seo_description || '', type: 'textarea' },
|
||
{ key: 'seo_h1', label: 'SEO h1', value: d.seo_h1 || '' },
|
||
], async data => {
|
||
data.photo_id = data.photo_id ? Number(data.photo_id) : null;
|
||
data.practice_start_year = data.practice_start_year ? Number(data.practice_start_year) : null;
|
||
data.rating = data.rating ? Number(data.rating) : null;
|
||
data.sort = Number(data.sort) || 100;
|
||
await api('/admin/doctors/' + d.id, { method: 'PUT', body: JSON.stringify(data) });
|
||
toast('Врач сохранён'); loadAdminDoctors();
|
||
});
|
||
}
|
||
|
||
function editDoctorLinks(d) {
|
||
// услуги сгруппированы по направлениям: направление — строка без parent_id
|
||
const dirs = servicesCache.filter(s => !s.parent_id);
|
||
const options = [];
|
||
for (const dir of dirs) {
|
||
options.push({ value: dir.id, label: dir.name + ' (направление целиком)', group: dir.name });
|
||
for (const s of servicesCache.filter(x => x.parent_id === dir.id)) options.push({ value: s.id, label: s.name, group: dir.name });
|
||
}
|
||
openDialog('Услуги и филиалы: ' + fullName(d), [
|
||
{ key: 'branch_ids', label: 'Где принимает', type: 'checklist', value: d.branch_ids,
|
||
options: branchList.map(b => ({ value: b.id, label: shortAddress(b.address) + (b.published ? '' : ' (скрыт)') })) },
|
||
{ key: 'service_ids', label: 'Какие услуги ведёт', type: 'checklist', value: d.service_ids, options, search: true },
|
||
], async data => {
|
||
await api('/admin/doctors/' + d.id + '/links', { method: 'PUT', body: JSON.stringify(data) });
|
||
toast('Сохранено: ' + data.service_ids.length + ' услуг, ' + data.branch_ids.length + ' филиал(а)');
|
||
loadAdminDoctors();
|
||
});
|
||
}
|
||
|
||
// Список галочек с группами и поиском. Значение — массив id.
|
||
function checklist(f) {
|
||
const box = el('div', { className: 'checklist' });
|
||
const selected = new Set(f.value || []);
|
||
const list = el('div', { className: 'checklist-box' });
|
||
const inputs = [];
|
||
let group = null, groupWrap = null;
|
||
for (const o of f.options) {
|
||
if (o.group !== undefined && o.group !== group) {
|
||
group = o.group;
|
||
groupWrap = el('div');
|
||
const head = el('div', { className: 'checklist-group', textContent: group });
|
||
const count = el('span', { className: 'count' });
|
||
head.append(count);
|
||
groupWrap.append(head);
|
||
groupWrap.count = count;
|
||
list.append(groupWrap);
|
||
}
|
||
const label = el('label');
|
||
const cb = el('input', { type: 'checkbox', checked: selected.has(o.value) });
|
||
cb.value = o.value;
|
||
label.append(cb, el('span', { textContent: o.label }));
|
||
label.dataset.text = o.label.toLowerCase();
|
||
(groupWrap || list).append(label);
|
||
inputs.push({ cb, label, wrap: groupWrap });
|
||
}
|
||
const recount = () => {
|
||
for (const g of list.children) if (g.count) {
|
||
const n = [...g.querySelectorAll('input:checked')].length;
|
||
g.count.textContent = n ? `выбрано ${n}` : '';
|
||
}
|
||
};
|
||
list.addEventListener('change', recount);
|
||
recount();
|
||
if (f.search) {
|
||
const search = el('input', { type: 'search', placeholder: 'Найти услугу…' });
|
||
search.oninput = () => {
|
||
const q = search.value.trim().toLowerCase();
|
||
for (const { label } of inputs) label.hidden = q && !label.dataset.text.includes(q);
|
||
for (const g of list.children) if (g.count) g.hidden = q && ![...g.querySelectorAll('label')].some(l => !l.hidden);
|
||
};
|
||
box.append(search);
|
||
}
|
||
box.append(list);
|
||
box.getValue = () => inputs.filter(i => i.cb.checked).map(i => Number(i.cb.value));
|
||
return box;
|
||
}
|
||
|
||
/* ---------- отзывы ---------- */
|
||
const REVIEW_STATUS = { pending: 'ждёт проверки', published: 'на сайте', rejected: 'отклонён' };
|
||
let doctorsList = [], rvOffset = 0, rvTotal = 0;
|
||
const RVLIMIT = 50;
|
||
|
||
async function loadDoctorsList() {
|
||
const r = await api('/admin/doctors');
|
||
doctorsList = (r.data || []).map(d => ({ id: d.id, name: [d.last_name, d.first_name, d.middle_name].filter(Boolean).join(' ') }))
|
||
.sort((a, b) => a.name.localeCompare(b.name, 'ru'));
|
||
}
|
||
|
||
async function loadReviews() {
|
||
const status = $('#rv-status').value;
|
||
const q = encodeURIComponent($('#rv-q').value.trim());
|
||
const r = await api(`/admin/reviews?status=${status}&q=${q}&limit=${RVLIMIT}&offset=${rvOffset}`);
|
||
rvTotal = r.meta?.total || 0;
|
||
const counts = r.meta?.counts || {};
|
||
const badge = $('#rv-badge');
|
||
badge.textContent = counts.pending || ''; badge.hidden = !counts.pending;
|
||
for (const o of $('#rv-status').options) {
|
||
const n = o.value ? counts[o.value] || 0 : Object.values(counts).reduce((a, b) => a + b, 0);
|
||
o.textContent = o.textContent.replace(/ \(\d+\)$/, '') + ` (${n})`;
|
||
}
|
||
$('#rv-count').textContent = rvTotal + ' отзывов';
|
||
const tb = $('#rv-body'); tb.textContent = '';
|
||
for (const v of (r.data || [])) {
|
||
const tr = el('tr');
|
||
const text = el('td');
|
||
if (v.video_url) text.append(el('div', { className: 'muted', textContent: '▶ Видеоотзыв' }));
|
||
if (v.text) text.append(el('div', { className: 'rv-text', textContent: v.text, title: v.text }));
|
||
if (v.reply) text.append(el('div', { className: 'muted', textContent: 'Ответ клиники: ' + v.reply }));
|
||
tr.append(
|
||
el('td', { textContent: v.author_name + (v.rating ? ' · ' + '★'.repeat(v.rating) : '') }),
|
||
text,
|
||
el('td', { className: 'muted', textContent: v.doctor_name || '—' }),
|
||
el('td', { className: 'muted', textContent: ruDate(localDate(v.created_at)) }),
|
||
);
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (v.status === 'published' ? '' : v.status === 'pending' ? ' wait' : ' off'), textContent: REVIEW_STATUS[v.status] }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right', style: 'white-space:nowrap' });
|
||
if (v.status !== 'published') {
|
||
const pub = el('button', { className: 'btn', textContent: 'Опубликовать' });
|
||
pub.onclick = () => setReviewStatus(v, 'published');
|
||
act.append(pub);
|
||
}
|
||
if (v.status !== 'rejected') {
|
||
const rej = el('button', { className: 'btn ghost', textContent: v.status === 'published' ? 'Снять' : 'Отклонить', style: 'margin-left:6px' });
|
||
rej.onclick = () => setReviewStatus(v, 'rejected');
|
||
act.append(rej);
|
||
}
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.', style: 'margin-left:6px' });
|
||
e.onclick = () => editReview(v);
|
||
const x = el('button', { className: 'btn danger', textContent: '✕', style: 'margin-left:6px' });
|
||
x.onclick = () => delReview(v);
|
||
act.append(e, x); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
const from = rvTotal ? rvOffset + 1 : 0;
|
||
$('#rv-page').textContent = `${from}–${Math.min(rvOffset + RVLIMIT, rvTotal)} из ${rvTotal}`;
|
||
$('#rv-prev').disabled = rvOffset === 0;
|
||
$('#rv-next').disabled = rvOffset + RVLIMIT >= rvTotal;
|
||
}
|
||
$('#rv-status').onchange = () => { rvOffset = 0; loadReviews().catch(e => toast(e.message, true)); };
|
||
$('#rv-q').addEventListener('input', debounce(() => { rvOffset = 0; loadReviews().catch(e => toast(e.message, true)); }, 300));
|
||
$('#rv-prev').onclick = () => { rvOffset = Math.max(0, rvOffset - RVLIMIT); loadReviews(); };
|
||
$('#rv-next').onclick = () => { rvOffset += RVLIMIT; loadReviews(); };
|
||
|
||
const reviewPayload = v => ({ author_name: v.author_name, rating: v.rating ?? null, text: v.text, video_url: v.video_url,
|
||
doctor_id: v.doctor_id ?? null, status: v.status, reply: v.reply });
|
||
async function setReviewStatus(v, status) {
|
||
try {
|
||
await api('/admin/reviews/' + v.id, { method: 'PUT', body: JSON.stringify({ ...reviewPayload(v), status }) });
|
||
toast(status === 'published' ? 'Отзыв опубликован' : 'Отзыв скрыт с сайта');
|
||
loadReviews();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
function reviewFields(v) {
|
||
return [
|
||
{ key: 'status', label: 'Статус', value: v.status || 'pending', type: 'select',
|
||
options: Object.entries(REVIEW_STATUS).map(([value, label]) => ({ value, label })) },
|
||
{ key: 'author_name', label: 'Автор — как подписать отзыв на сайте (лучше только имя)', value: v.author_name || '' },
|
||
{ key: 'doctor_id', label: 'Врач', value: v.doctor_id || '', type: 'select',
|
||
options: [{ value: '', label: '— о клинике в целом —' }].concat(doctorsList.map(d => ({ value: d.id, label: d.name }))) },
|
||
{ key: 'rating', label: 'Оценка', value: v.rating ?? '', type: 'select',
|
||
options: [{ value: '', label: 'без оценки' }, 5, 4, 3, 2, 1].map(o => typeof o === 'object' ? o : { value: o, label: '★'.repeat(o) }) },
|
||
{ key: 'text', label: 'Текст отзыва', value: v.text || '', type: 'textarea', rows: 6 },
|
||
{ key: 'video_url', label: 'Видео — ссылка на YouTube или Rutube (необязательно)', value: v.video_url || '' },
|
||
{ key: 'reply', label: 'Ответ клиники (необязательно, показывается под отзывом)', value: v.reply || '', type: 'textarea', rows: 3 },
|
||
];
|
||
}
|
||
function normalizeReview(d) {
|
||
d.doctor_id = d.doctor_id ? Number(d.doctor_id) : null;
|
||
d.rating = d.rating ? Number(d.rating) : null;
|
||
return d;
|
||
}
|
||
function editReview(v) {
|
||
openDialog('Отзыв: ' + v.author_name, reviewFields(v), async d => {
|
||
await api('/admin/reviews/' + v.id, { method: 'PUT', body: JSON.stringify(normalizeReview(d)) });
|
||
toast('Отзыв сохранён'); loadReviews();
|
||
});
|
||
}
|
||
$('#rv-add').onclick = () => {
|
||
openDialog('Новый отзыв', reviewFields({ status: 'published' }), async d => {
|
||
await api('/admin/reviews', { method: 'POST', body: JSON.stringify(normalizeReview(d)) });
|
||
toast('Отзыв добавлен'); loadReviews();
|
||
});
|
||
};
|
||
async function delReview(v) {
|
||
if (!confirm('Удалить отзыв «' + v.author_name + '» насовсем? Чтобы просто убрать его с сайта, нажмите «Отклонить».')) return;
|
||
try {
|
||
await api('/admin/reviews/' + v.id, { method: 'DELETE' });
|
||
toast('Отзыв удалён'); loadReviews();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
/* ---------- партнёры ---------- */
|
||
async function loadPartners() {
|
||
const r = await api('/admin/partners');
|
||
const list = r.data || [];
|
||
$('#pt-count').textContent = list.length + ' партнёров';
|
||
const tb = $('#pt-body'); tb.textContent = '';
|
||
for (const p of list) {
|
||
const tr = el('tr');
|
||
const logo = el('td', { className: 'logo-cell' });
|
||
if (p.logo) logo.append(el('img', { src: p.logo, alt: '' })); else logo.textContent = '—';
|
||
const site = el('td', { className: 'muted' });
|
||
if (p.url) site.append(el('a', { href: p.url, target: '_blank', rel: 'noopener', textContent: p.url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '') }));
|
||
else site.textContent = '—';
|
||
tr.append(logo, el('td', { textContent: p.name }), site, el('td', { textContent: p.sort }));
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (p.published ? '' : ' off'), textContent: p.published ? 'на сайте' : 'скрыт' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editPartner(p);
|
||
const x = el('button', { className: 'btn danger', textContent: '✕', style: 'margin-left:6px' });
|
||
x.onclick = () => delPartner(p);
|
||
act.append(e, x); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
function partnerFields(p) {
|
||
return [
|
||
{ key: 'name', label: 'Название', value: p.name || '' },
|
||
{ key: 'url', label: 'Сайт (необязательно), например https://abilitycenter.ru', value: p.url || '' },
|
||
{ key: 'logo_id', label: 'Логотип', value: p.logo_id || '', preview: p.logo, type: 'image' },
|
||
{ key: 'sort', label: 'Порядок (меньше — выше)', value: p.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: p.published !== false, type: 'checkbox' },
|
||
];
|
||
}
|
||
function normalizePartner(d) {
|
||
d.logo_id = d.logo_id ? Number(d.logo_id) : null;
|
||
d.sort = Number(d.sort) || 100;
|
||
return d;
|
||
}
|
||
function editPartner(p) {
|
||
openDialog('Партнёр: ' + p.name, partnerFields(p), async d => {
|
||
await api('/admin/partners/' + p.id, { method: 'PUT', body: JSON.stringify(normalizePartner(d)) });
|
||
toast('Партнёр сохранён'); loadPartners();
|
||
});
|
||
}
|
||
$('#pt-add').onclick = () => {
|
||
openDialog('Новый партнёр', partnerFields({ published: true, sort: 100 }), async d => {
|
||
await api('/admin/partners', { method: 'POST', body: JSON.stringify(normalizePartner(d)) });
|
||
toast('Партнёр добавлен'); loadPartners();
|
||
});
|
||
};
|
||
async function delPartner(p) {
|
||
if (!confirm('Удалить партнёра «' + p.name + '»?')) return;
|
||
try {
|
||
await api('/admin/partners/' + p.id, { method: 'DELETE' });
|
||
toast('Партнёр удалён'); loadPartners();
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
/* ---------- страницы ---------- */
|
||
// Где страница видна на сайте. Раздел «История» — демо-текст шаблона, адреса у него нет.
|
||
const PAGE_PATHS = {
|
||
about: '/company/', legaldocs: '/legaldocs/', privacy: '/legaldocs/privacy/', consent: '/legaldocs/consent/',
|
||
cookie: '/legaldocs/cookie/', news: '/news/', articles: '/articles/', reviews: '/reviews/', partners: '/company/partners/',
|
||
nadzor: '/company/nadzor/', vacancy: '/company/vacancy/', faq: '/info/faq/', licenses: '/company/licenses/',
|
||
patients: '/patients/',
|
||
};
|
||
const REQUIRED_PAGES = ['legaldocs', 'privacy', 'consent', 'cookie'];
|
||
// у разделов со списками текст страницы — короткое вступление над списком
|
||
const INTRO_PAGES = ['news', 'articles', 'reviews', 'partners', 'nadzor', 'vacancy', 'faq', 'licenses'];
|
||
|
||
let pagesCache = [];
|
||
async function loadPages() {
|
||
const r = await api('/admin/pages');
|
||
pagesCache = r.data || [];
|
||
renderLegal();
|
||
const tb = $('#pg-body'); tb.textContent = '';
|
||
const list = (r.data || []).slice().sort((a, b) => (b.published - a.published) || a.title.localeCompare(b.title, 'ru'));
|
||
for (const p of list) {
|
||
const tr = el('tr');
|
||
const path = PAGE_PATHS[p.slug];
|
||
const link = el('td', { className: 'muted' });
|
||
if (path) link.append(el('a', { href: path, target: '_blank', rel: 'noopener', textContent: path }));
|
||
else link.textContent = 'не выводится на сайте';
|
||
tr.append(el('td', { textContent: p.title }), link, el('td', { className: 'muted', textContent: ruDate(localDate(p.updated_at)) }));
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (p.published ? '' : ' off'), textContent: p.published ? 'на сайте' : 'скрыта' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editPage(p);
|
||
act.append(e); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
function editPage(p) {
|
||
const html = (p.blocks || []).filter(b => b.html !== undefined).map(b => b.html).join('\n');
|
||
const hasImage = (p.blocks || []).some(b => b.type === 'image');
|
||
const fields = [
|
||
{ key: 'title', label: 'Заголовок', value: p.title },
|
||
{ key: 'html', label: INTRO_PAGES.includes(p.slug)
|
||
? 'Вступление над списком (коротко, 1–2 предложения; пусто — стандартный текст)'
|
||
: 'Текст страницы' + (hasImage ? ' (фото над текстом меняется отдельно, через разработчика)' : ''),
|
||
value: html, type: 'richtext' },
|
||
];
|
||
if (!REQUIRED_PAGES.includes(p.slug)) fields.push({ key: 'published', label: 'Показывать на сайте', value: p.published, type: 'checkbox' });
|
||
fields.push(
|
||
{ key: 'seo_title', label: 'SEO title', value: p.seo_title || '' },
|
||
{ key: 'seo_description', label: 'SEO description', value: p.seo_description || '', type: 'textarea' },
|
||
{ key: 'seo_h1', label: 'SEO h1', value: p.seo_h1 || '' },
|
||
);
|
||
openDialog('Страница: ' + p.title, fields, async d => {
|
||
if (REQUIRED_PAGES.includes(p.slug)) d.published = true;
|
||
await api('/admin/pages/' + p.id, { method: 'PUT', body: JSON.stringify(d) });
|
||
toast('Страница сохранена'); loadPages();
|
||
});
|
||
}
|
||
|
||
// «Настройки» → «Правовые документы»: те же страницы и тот же редактор, что во вкладке «Страницы»
|
||
function renderLegal() {
|
||
const box = $('#legal-list'); box.textContent = '';
|
||
for (const slug of ['privacy', 'consent', 'cookie', 'legaldocs']) {
|
||
const p = pagesCache.find(x => x.slug === slug);
|
||
if (!p) continue;
|
||
const row = el('div', { className: 'legal-row' });
|
||
const txt = el('div');
|
||
txt.append(el('b', { textContent: p.title }));
|
||
const meta = el('small');
|
||
meta.append(el('a', { href: PAGE_PATHS[slug], target: '_blank', rel: 'noopener', textContent: PAGE_PATHS[slug] }),
|
||
document.createTextNode(' · изменено ' + ruDate(localDate(p.updated_at))));
|
||
txt.append(meta);
|
||
const e = el('button', { type: 'button', className: 'bn-edit', textContent: 'Изменить текст' });
|
||
e.onclick = () => editPage(p);
|
||
row.append(txt, e);
|
||
box.append(row);
|
||
}
|
||
}
|
||
|
||
/* ---------- настройки и филиалы ---------- */
|
||
const SETTINGS_FIELDS = [
|
||
{ key: 'legal.org_name', label: 'Полное название организации', wide: true },
|
||
{ key: 'legal.org_short', label: 'Краткое название' },
|
||
{ key: 'legal.email', label: 'Почта в реквизитах' },
|
||
{ key: 'legal.inn', label: 'ИНН' },
|
||
{ key: 'legal.ogrn', label: 'ОГРН' },
|
||
{ key: 'legal.address', label: 'Юридический адрес', wide: true },
|
||
{ key: 'legal.license', label: 'Лицензия: номер, дата и кем выдана', wide: true, type: 'textarea' },
|
||
{ key: 'email', label: 'Почта для связи на сайте' },
|
||
{ key: 'max_url', label: 'Ссылка на чат в MAX (https://max.ru/…)' },
|
||
];
|
||
const getPath = (o, path) => path.split('.').reduce((v, k) => (v || {})[k], o);
|
||
|
||
async function loadSettings() {
|
||
const r = await api('/admin/settings');
|
||
const data = r.data || {};
|
||
const form = $('#st-form'); form.textContent = '';
|
||
for (const f of SETTINGS_FIELDS) {
|
||
const wrap = el('label', { className: 'field' + (f.wide ? ' wide' : '') });
|
||
wrap.append(el('span', { textContent: f.label }));
|
||
const inp = f.type === 'textarea' ? el('textarea', { rows: 2 }) : el('input', { type: 'text' });
|
||
inp.value = getPath(data, f.key) ?? '';
|
||
inp.dataset.key = f.key;
|
||
wrap.append(inp); form.append(wrap);
|
||
}
|
||
}
|
||
$('#st-save').onclick = async () => {
|
||
const body = { legal: {} };
|
||
for (const inp of document.querySelectorAll('#st-form [data-key]')) {
|
||
const [a, b] = inp.dataset.key.split('.');
|
||
if (b) body[a][b] = inp.value; else body[a] = inp.value;
|
||
}
|
||
try {
|
||
await api('/admin/settings', { method: 'PUT', body: JSON.stringify(body) });
|
||
toast('Реквизиты сохранены'); loadSettings();
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
|
||
async function loadBranches() {
|
||
const r = await api('/admin/branches');
|
||
const tb = $('#br-body'); tb.textContent = '';
|
||
for (const b of (r.data || [])) {
|
||
const tr = el('tr');
|
||
tr.append(
|
||
el('td', { textContent: b.address }),
|
||
el('td', { className: 'muted', textContent: (b.phones || []).join(', ') || '—' }),
|
||
el('td', { className: 'muted', textContent: (b.work_hours || []).join('; ') || '—' }),
|
||
el('td', { className: b.lat != null ? 'muted' : 'warn', textContent: b.lat != null ? 'есть' : 'нет координат' }),
|
||
);
|
||
const st = el('td');
|
||
st.append(el('span', { className: 'pill' + (b.published ? '' : ' off'), textContent: b.published ? 'на сайте' : 'скрыт' }));
|
||
tr.append(st);
|
||
const act = el('td', { className: 'right' });
|
||
const e = el('button', { className: 'btn ghost', textContent: 'Изм.' });
|
||
e.onclick = () => editBranch(b);
|
||
act.append(e); tr.append(act);
|
||
tb.append(tr);
|
||
}
|
||
}
|
||
function branchFields(b) {
|
||
return [
|
||
{ key: 'name', label: 'Название (например, «Ляхова, 22»)', value: b.name || '' },
|
||
{ key: 'slug', label: 'Код латиницей (пусто — сформируется из названия)', value: b.slug || '' },
|
||
{ key: 'address', label: 'Адрес, как на сайте', value: b.address || '' },
|
||
{ key: 'phones', label: 'Телефоны — по одному на строку', value: (b.phones || []).join('\n'), type: 'textarea', rows: 2 },
|
||
{ key: 'work_hours', label: 'Часы работы — по строке, например «Пн–Вс: 08:00–20:00»', value: (b.work_hours || []).join('\n'), type: 'textarea', rows: 2 },
|
||
{ key: 'coords', label: 'Координаты для карты: широта, долгота (из Яндекс Карт, например 42.976451, 47.491531)',
|
||
value: b.lat != null ? `${b.lat}, ${b.lon}` : '' },
|
||
{ key: 'sort', label: 'Порядок (первый филиал — в шапке сайта)', value: b.sort ?? 100, type: 'number' },
|
||
{ key: 'published', label: 'Показывать на сайте', value: b.published !== false, type: 'checkbox' },
|
||
];
|
||
}
|
||
function normalizeBranch(d) {
|
||
const lines = v => v.split('\n').map(x => x.trim()).filter(Boolean);
|
||
d.phones = lines(d.phones);
|
||
d.work_hours = lines(d.work_hours);
|
||
d.sort = Number(d.sort) || 100;
|
||
const c = d.coords.trim();
|
||
delete d.coords;
|
||
if (c) {
|
||
const m = c.match(/^(-?\d+(?:[.,]\d+)?)\s*[,; ]\s*(-?\d+(?:[.,]\d+)?)$/);
|
||
if (!m) throw new Error('Координаты — два числа через запятую: широта, долгота');
|
||
d.lat = Number(m[1].replace(',', '.')); d.lon = Number(m[2].replace(',', '.'));
|
||
} else { d.lat = null; d.lon = null; }
|
||
return d;
|
||
}
|
||
function editBranch(b) {
|
||
openDialog('Филиал: ' + b.name, branchFields(b), async d => {
|
||
await api('/admin/branches/' + b.id, { method: 'PUT', body: JSON.stringify(normalizeBranch(d)) });
|
||
toast('Филиал сохранён'); loadBranches();
|
||
});
|
||
}
|
||
$('#br-add').onclick = () => {
|
||
openDialog('Новый филиал', branchFields({ published: false, sort: 100 }), async d => {
|
||
await api('/admin/branches', { method: 'POST', body: JSON.stringify(normalizeBranch(d)) });
|
||
toast('Филиал добавлен'); loadBranches();
|
||
});
|
||
};
|
||
|
||
/* ---------- редиректы ---------- */
|
||
async function loadRedirects() {
|
||
const r = await api('/admin/redirects');
|
||
const { redirects, not_found: missing } = r.data;
|
||
const tb = $('#rd-body'); tb.textContent = '';
|
||
const empty = (body, cols, text) => { const tr = el('tr'); tr.append(el('td', { className: 'muted', colSpan: cols, textContent: text })); body.append(tr); };
|
||
if (!redirects.length) empty(tb, 4, 'Ручных редиректов пока нет');
|
||
for (const x of redirects) {
|
||
const tr = el('tr');
|
||
const to = el('td');
|
||
to.append(el('a', { href: x.to_path, target: '_blank', rel: 'noopener', textContent: x.to_path }));
|
||
tr.append(el('td', { textContent: x.from_path }), to, el('td', { className: 'muted', textContent: x.hits }));
|
||
const act = el('td', { className: 'right' });
|
||
const d = el('button', { className: 'btn danger', textContent: '✕' });
|
||
d.onclick = async () => {
|
||
if (!confirm('Удалить редирект с ' + x.from_path + '?')) return;
|
||
try { await api('/admin/redirects/' + x.id, { method: 'DELETE' }); toast('Редирект удалён'); loadRedirects(); }
|
||
catch (e) { toast(e.message, true); }
|
||
};
|
||
act.append(d); tr.append(act); tb.append(tr);
|
||
}
|
||
const nb = $('#nf-body'); nb.textContent = '';
|
||
if (!missing.length) empty(nb, 5, 'Пока пусто');
|
||
for (const n of missing) {
|
||
const tr = el('tr');
|
||
tr.append(el('td', { textContent: n.path }), el('td', { textContent: n.hits }),
|
||
el('td', { className: 'muted', textContent: ruDate(localDate(n.last_seen)) }),
|
||
el('td', { className: 'muted', textContent: n.referer || '—', style: 'word-break:break-all' }));
|
||
const act = el('td', { className: 'right', style: 'white-space:nowrap' });
|
||
const fix = el('button', { className: 'btn ghost', textContent: 'Перенаправить' });
|
||
fix.onclick = () => { $('#rd-from').value = n.path; $('#rd-to').focus(); window.scrollTo({ top: 0, behavior: 'smooth' }); };
|
||
const hide = el('button', { className: 'btn danger', textContent: 'Убрать', style: 'margin-left:6px' });
|
||
hide.onclick = async () => {
|
||
try { await api('/admin/not-found?path=' + encodeURIComponent(n.path), { method: 'DELETE' }); loadRedirects(); }
|
||
catch (e) { toast(e.message, true); }
|
||
};
|
||
act.append(fix, hide); tr.append(act); nb.append(tr);
|
||
}
|
||
}
|
||
$('#rd-add').onclick = async () => {
|
||
try {
|
||
const r = await api('/admin/redirects', { method: 'POST', body: JSON.stringify({ from_path: $('#rd-from').value, to_path: $('#rd-to').value }) });
|
||
toast('Редирект: ' + r.data.from_path + ' → ' + r.data.to_path);
|
||
$('#rd-from').value = ''; $('#rd-to').value = '';
|
||
loadRedirects();
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
|
||
/* ---------- редактор текста ---------- */
|
||
// Белый список тегов как на сайте: вставка из Word или со старого сайта
|
||
// не притащит стили, скрипты и обработчики событий.
|
||
const RTE_TAGS = new Set('p h2 h3 h4 ul ol li strong em b i br a blockquote table thead tbody tr th td img'.split(' '));
|
||
const RTE_DROP = new Set('script style iframe object embed svg math form input button textarea select template noscript meta link title'.split(' '));
|
||
function cleanHTML(html) {
|
||
const doc = new DOMParser().parseFromString('<body>' + (html || '') + '</body>', 'text/html');
|
||
const walk = parent => {
|
||
for (const node of [...parent.childNodes]) {
|
||
if (node.nodeType === 3) continue;
|
||
if (node.nodeType !== 1) { node.remove(); continue; }
|
||
const tag = node.localName;
|
||
if (RTE_DROP.has(tag)) { node.remove(); continue; }
|
||
walk(node);
|
||
if (!RTE_TAGS.has(tag)) {
|
||
// div со старого сайта — это абзац: оставляем содержимое в <p>, span просто разворачиваем
|
||
if (tag === 'div' && node.textContent.trim() && !node.querySelector('p,ul,ol,h2,h3,h4,table,blockquote')) {
|
||
const p = doc.createElement('p'); p.append(...node.childNodes); node.replaceWith(p);
|
||
} else node.replaceWith(...node.childNodes);
|
||
continue;
|
||
}
|
||
for (const attr of [...node.attributes]) {
|
||
const v = attr.value.trim();
|
||
const keep = (tag === 'a' && attr.name === 'href' && /^(https?:|mailto:|tel:|\/(?!\/)|#)/i.test(v)) ||
|
||
(tag === 'img' && attr.name === 'src' && /^(https?:|\/(?!\/))/i.test(v)) ||
|
||
(tag === 'img' && attr.name === 'alt') || ((tag === 'td' || tag === 'th') && /^(colspan|rowspan)$/.test(attr.name));
|
||
if (!keep) node.removeAttribute(attr.name);
|
||
}
|
||
if (tag === 'img' && !node.getAttribute('src')) node.remove();
|
||
}
|
||
};
|
||
walk(doc.body);
|
||
return doc.body.innerHTML.replace(/<p>(\s| |<br>)*<\/p>/g, '').trim();
|
||
}
|
||
|
||
function richEditor(value) {
|
||
const box = el('div', { className: 'rte' });
|
||
const bar = el('div', { className: 'rte-bar' });
|
||
const area = el('div', { className: 'rte-area', contentEditable: 'true' });
|
||
const src = el('textarea', { className: 'rte-src', hidden: true });
|
||
area.innerHTML = cleanHTML(value);
|
||
const exec = (cmd, arg) => { area.focus(); document.execCommand(cmd, false, arg); };
|
||
const button = (label, title, fn) => {
|
||
const b = el('button', { type: 'button', textContent: label, title });
|
||
// mousedown не забирает фокус и выделение из текста
|
||
b.onmousedown = e => e.preventDefault();
|
||
b.onclick = fn;
|
||
bar.append(b);
|
||
};
|
||
const sep = () => bar.append(el('span', { className: 'sep' }));
|
||
button('Абзац', 'Обычный текст', () => exec('formatBlock', 'p'));
|
||
button('Заголовок', 'Подзаголовок раздела', () => exec('formatBlock', 'h2'));
|
||
button('Подзаголовок', 'Заголовок поменьше', () => exec('formatBlock', 'h3'));
|
||
sep();
|
||
button('Ж', 'Жирный', () => exec('bold'));
|
||
button('К', 'Курсив', () => exec('italic'));
|
||
sep();
|
||
button('• Список', 'Маркированный список', () => exec('insertUnorderedList'));
|
||
button('1. Список', 'Нумерованный список', () => exec('insertOrderedList'));
|
||
sep();
|
||
button('Ссылка', 'Сделать выделенный текст ссылкой', () => {
|
||
const url = prompt('Адрес ссылки: /company/staff/… для страниц сайта или https://…');
|
||
if (!url) return;
|
||
if (/^(https?:\/\/|\/(?!\/)|mailto:|tel:)/i.test(url.trim())) exec('createLink', url.trim());
|
||
else toast('Ссылка должна начинаться с / или https://', true);
|
||
});
|
||
button('Убрать ссылку', 'Убрать ссылку с выделенного текста', () => exec('unlink'));
|
||
button('Картинка', 'Загрузить картинку в текст', () => {
|
||
const file = el('input', { type: 'file', accept: 'image/jpeg,image/png,image/webp,image/gif' });
|
||
file.onchange = async () => {
|
||
if (!file.files[0]) return;
|
||
try {
|
||
const up = await uploadImage(file.files[0]);
|
||
exec('insertHTML', '<p><img src="' + up.path + '" alt=""></p>');
|
||
} catch (err) { toast(err.message, true); }
|
||
};
|
||
file.click();
|
||
});
|
||
sep();
|
||
button('Очистить', 'Убрать оформление у выделенного текста', () => { exec('removeFormat'); exec('unlink'); });
|
||
button('HTML', 'Показать или скрыть разметку', () => {
|
||
if (src.hidden) { src.value = cleanHTML(area.innerHTML); area.hidden = true; src.hidden = false; }
|
||
else { area.innerHTML = cleanHTML(src.value); src.hidden = true; area.hidden = false; }
|
||
});
|
||
|
||
// вставка из Word и браузера — через белый список, иначе в текст попадут чужие стили
|
||
area.addEventListener('paste', e => {
|
||
const html = e.clipboardData.getData('text/html');
|
||
const text = e.clipboardData.getData('text/plain');
|
||
e.preventDefault();
|
||
if (html) document.execCommand('insertHTML', false, cleanHTML(html));
|
||
else document.execCommand('insertText', false, text);
|
||
});
|
||
box.append(bar, area, src);
|
||
box.getValue = () => cleanHTML(src.hidden ? area.innerHTML : src.value);
|
||
return box;
|
||
}
|
||
|
||
async function uploadImage(file, accept) {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
const r = await fetch(API + '/admin/media' + (accept ? '?accept=' + accept : ''), { method: 'POST', body: fd, credentials: 'same-origin' });
|
||
const body = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(body.error?.message || 'Не удалось загрузить картинку');
|
||
return body.data;
|
||
}
|
||
|
||
/* ---------- диалог ---------- */
|
||
function openDialog(title, fields, onSave) {
|
||
const dlg = $('#dlg');
|
||
$('#dlg-title').textContent = title;
|
||
const body = $('#dlg-body'); body.textContent = '';
|
||
const inputs = {};
|
||
dlg.classList.toggle('wide', fields.some(f => f.type === 'richtext' || f.type === 'checklist'));
|
||
for (const f of fields) {
|
||
const wrap = el('div', { className: 'field' });
|
||
wrap.append(el('span', { textContent: f.label }));
|
||
let inp;
|
||
if (f.type === 'info') {
|
||
const dl = el('dl', { className: 'lead-detail' });
|
||
for (const [k, v] of f.rows) dl.append(el('dt', { textContent: k }), el('dd', { textContent: v }));
|
||
body.append(dl);
|
||
continue;
|
||
}
|
||
if (f.type === 'textarea') { inp = el('textarea', { rows: f.rows || 3, value: f.value || '' }); }
|
||
else if (f.type === 'richtext') { inp = richEditor(f.value); }
|
||
else if (f.type === 'checklist') { inp = checklist(f); }
|
||
else if (f.type === 'image') {
|
||
// картинка грузится сразу при выборе файла, в форму уходит её id из медиатеки
|
||
inp = el('input', { type: 'hidden', value: f.value ?? '' });
|
||
const box = el('div', { className: 'img-field' });
|
||
const img = el('img', { className: 'img-preview', alt: '' });
|
||
if (f.preview) img.src = f.preview; else img.hidden = true;
|
||
const file = el('input', { type: 'file', accept: 'image/jpeg,image/png,image/webp,image/gif' });
|
||
const clear = el('button', { type: 'button', className: 'btn ghost', textContent: 'Убрать' });
|
||
file.onchange = async () => {
|
||
if (!file.files[0]) return;
|
||
try {
|
||
const up = await uploadImage(file.files[0]);
|
||
inp.value = up.id; img.src = up.path; img.hidden = false;
|
||
toast('Картинка загружена');
|
||
} catch (err) { toast(err.message, true); }
|
||
finally { file.value = ''; }
|
||
};
|
||
clear.onclick = () => { inp.value = ''; img.hidden = true; img.removeAttribute('src'); };
|
||
box.append(img, file, clear);
|
||
wrap.append(box);
|
||
}
|
||
else if (f.type === 'document') {
|
||
// файл документа: PDF или картинка, в форму уходит id из медиатеки
|
||
inp = el('input', { type: 'hidden', value: f.value ?? '' });
|
||
const box = el('div', { className: 'img-field' });
|
||
const link = el('a', { className: 'file-link', target: '_blank', rel: 'noopener', textContent: f.file ? f.file.split('/').pop() : '' });
|
||
if (f.file) link.href = f.file; else link.hidden = true;
|
||
const file = el('input', { type: 'file', accept: 'application/pdf,image/jpeg,image/png,image/webp' });
|
||
const clear = el('button', { type: 'button', className: 'btn ghost', textContent: 'Убрать' });
|
||
file.onchange = async () => {
|
||
if (!file.files[0]) return;
|
||
try {
|
||
const up = await uploadImage(file.files[0], 'document');
|
||
inp.value = up.id; link.href = up.path; link.textContent = file.files[0].name; link.hidden = false;
|
||
toast('Файл загружен');
|
||
} catch (err) { toast(err.message, true); }
|
||
finally { file.value = ''; }
|
||
};
|
||
clear.onclick = () => { inp.value = ''; link.hidden = true; link.removeAttribute('href'); };
|
||
box.append(link, file, clear);
|
||
wrap.append(box);
|
||
}
|
||
else if (f.type === 'checkbox') { inp = el('input', { type: 'checkbox', checked: !!f.value }); inp.style.width = 'auto'; }
|
||
else if (f.type === 'select') {
|
||
inp = el('select');
|
||
for (const o of f.options) inp.append(el('option', { value: o.value, textContent: o.label }));
|
||
inp.value = f.value ?? '';
|
||
} else {
|
||
inp = el('input', { type: f.type || 'text', value: f.value ?? '' });
|
||
if (f.min != null) inp.min = f.min;
|
||
if (f.max != null) inp.max = f.max;
|
||
if (f.step != null) inp.step = f.step;
|
||
}
|
||
inputs[f.key] = { inp, type: f.type };
|
||
wrap.append(inp); body.append(wrap);
|
||
}
|
||
const save = $('#dlg-save');
|
||
save.onclick = async e => {
|
||
e.preventDefault();
|
||
const data = {};
|
||
for (const [k, { inp, type }] of Object.entries(inputs)) {
|
||
data[k] = type === 'checkbox' ? inp.checked : (type === 'richtext' || type === 'checklist') ? inp.getValue() : inp.value;
|
||
}
|
||
try { await onSave(data); dlg.close(); }
|
||
catch (err) { toast(err.message, true); }
|
||
};
|
||
dlg.showModal();
|
||
}
|
||
|
||
function debounce(fn, ms) {
|
||
let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
|
||
}
|
||
|
||
</script>
|
||
<script src="dashboard.js"></script>
|
||
<script src="leads.js?v=20261014-wish"></script>
|
||
<script src="doctors.js?v=20261006-rating"></script>
|
||
<script src="doctors-dash.js"></script>
|
||
<script src="banners.js"></script>
|
||
<script src="services.js"></script>
|
||
<script src="stats.js"></script>
|
||
<script src="reports.js?v=20261014-share"></script>
|
||
<script src="pbx.js?v=20261014-clinics"></script>
|
||
<script src="weekly.js?v=20261014-insta"></script>
|
||
<script src="weekly-experiment.js?v=20261014-clinics"></script>
|
||
<script src="clinic.js?v=20261014-share"></script>
|
||
<script src="onec.js"></script>
|
||
<script src="reports-history.js"></script>
|
||
<script src="shell.js"></script>
|
||
</body>
|
||
</html>
|