dana-medical-site/internal/api/adminui/index.html

2428 lines
136 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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?v=20261023-refresh">
<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="Обновить заявки"><span aria-hidden="true">↻</span></button>
</div>
<p id="ld-updated" class="ld-updated" role="status" aria-live="polite"></p>
<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|&nbsp;|<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=20261023-refresh"></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>