Анализ сайта fkr-lenobl.ru
Основное Готовность: 100%
Домен
fkr-lenobl.ru
Состояние доменного имени
?
Проверяем корректность доменного имени и наличие технических проблем на уровне домена.
Домен второго уровня идеален для продвижения.
Отличный запоминающийся домен.
Регистратор домена - BEGET-RU
Дата регистрации домена 31.07.2026 (более 2 недель назад)
Ответ сервера
200 Успешный ответ
HTTP-код ответа и цепочка редиректов
?
Код 200 — страница доступна. Коды 3xx — редиректы (цепочки замедляют загрузку и размывают ссылочный вес). Коды 4xx/5xx — ошибки, поисковик не сможет проиндексировать страницу.
Сервер настроен корректно.
Цепочка редиректов:
http://fkr-lenobl.ru
301 MovedPermanently
https://fkr-lenobl.ru/
200 OK
Безопасность
Сайт безопасен
Использование HTTPS и SSL-сертификат
?
HTTPS — обязательный стандарт. Google и Яндекс отдают предпочтение защищённым сайтам. Отсутствие SSL или просроченный сертификат ведут к предупреждениям в браузере и снижению позиций.
Не настроен HSTS (Strict-Transport-Security) — рекомендуется включить.
На сайте работает защищенный протокол ssl и сайт открывается по https.
Ssl-сертификат действителен до 29.10.2026 15:55:29.
HTTP автоматически перенаправляется на HTTPS.
Поздравляем! Сайт не содержится в реестре РКН.
Кодировка
utf-8
Кодировка символов страницы
?
Стандарт — UTF-8. Неправильная кодировка вызывает нечитаемые символы и мешает поисковику корректно распознать текст страницы.
Указана кодировка на странице utf-8.
Язык
ru
Атрибут lang в HTML-теге
?
Атрибут lang (<html lang="ru">) сообщает поисковикам и браузерам, на каком языке написана страница. Помогает при ранжировании в региональном поиске.
Указан язык документа ru.
Скорость загрузки
~0,13сек
Время отклика сервера (TTFB)
?
Time To First Byte — время до получения первого байта от сервера. Норма до 200 мс. Медленный отклик ухудшает пользовательский опыт и ранжирование: Яндекс и Google учитывают скорость страниц.
Скорость загрузки сайта 0,13сек оптимальна.
Объем документа
314Кб
Размер HTML-кода страницы
?
Слишком большой HTML замедляет парсинг браузером и сканирование поисковым роботом. Рекомендуется не более 200 Кб.
Объем html-документа 314Кб оптимален.
Структура html-документа корректна.
Ресурсы
Ресурсы: 6
Внешние ресурсы страницы (CSS, JS, изображения)
?
Количество и тип подключённых ресурсов влияют на скорость загрузки. Большое число запросов увеличивает время рендеринга страницы.
Кол-во файлов ресурсов 6 достаточно.
Показать полный список ресурсов
| Тип | Название | Значение |
|---|---|---|
| stylesheet | assets/material-symbols.css | |
| js | assets/vendor/tailwind.js | |
| js | text/javascript | https://api-maps.yandex.ru/2.1/?lang=ru_RU |
| js | assets/vendor/xlsx.full.min.js | |
| js | assets/vendor/mammoth.browser.min.js | |
| js | assets/vendor/pdf.min.js |
Серверные заголовки
Кол-во: 5
HTTP-заголовки ответа сервера
?
Заголовки сервера передают браузеру и поисковику служебную информацию: кеширование, безопасность (CSP, HSTS), сжатие (gzip). Правильная настройка ускоряет загрузку и повышает защищённость.
Найдены серверные заголовки 5шт. Подробнее про серверные заголовки.
Показать полный список серверных заголовков
| Ключ | Значение |
|---|---|
| Server | nginx/1.24.0 |
| Date | Wed, 19 Aug 2026 11:10:39 GMT |
| Connection | keep-alive |
| ETag | "6a858df2-5287b" |
| Accept-Ranges | bytes |
CMS
Не определена
Система управления сайтом (движок)
?
CMS — это движок, на котором работает сайт (WordPress, 1C-Bitrix, Tilda и др.). Знание CMS помогает понять возможности SEO-оптимизации и подобрать подходящие инструменты. «Не определена» — вероятно, самописный сайт или нестандартная сборка.
CMS не определена. Вероятно, сайт самописный либо движок надёжно скрыт. Это не ошибка.
Веб-сервер
Программное обеспечение сервера
?
Веб-сервер — это ПО, которое отдаёт страницы посетителям (nginx, Apache, IIS, LiteSpeed и др.). Определяется по серверным заголовкам ответа (Server, X-Powered-By и т.п.). «Не определён» — сервер намеренно скрывает эти заголовки, это нормальная практика безопасности.
Сайт работает на веб-сервере nginx.
Мета-теги Готовность: 32%
Title
Портал ФКР
Заголовок страницы в браузере и поисковой выдаче
?
Title — главный SEO-заголовок страницы. Влияет на CTR в поиске и ранжирование. Оптимальная длина: 50–70 символов. Ключевые слова — ближе к началу.
Необходимо увеличить число символов в title (текущее значение мало: 10, минимум: 25, оптимально: от 40 до 45)
Дублей словоформ в title не найдено.
Description
Описание страницы в поисковой выдаче (сниппет)
?
Meta Description — текст под заголовком в выдаче. Напрямую на позиции не влияет, но влияет на CTR. Оптимальная длина: 120–160 символов.
Установите мета-тег description!
Keywords
Список ключевых слов страницы (устаревший тег)
?
Meta Keywords не учитывается Яндексом и Google для ранжирования с 2009–2012 годов. Заполнение не обязательно, но не вредит. Конкурент может использовать содержимое для анализа.
Установите мета-тег keywords!
Канонический Url
Указывает поисковику основную версию страницы
?
Canonical (rel=canonical) предотвращает проблему дублей страниц. Должен точно совпадать с URL проверяемой страницы. Неправильный canonical может передать ссылочный вес на другую страницу.
Рекомендуем прописать канонический Url.
Robots
Ошибок нет
Директивы для поисковых роботов на уровне страницы
?
Meta Robots управляет индексацией конкретной страницы: index/noindex — индексировать ли, follow/nofollow — следовать ли по ссылкам. Noindex полностью исключает страницу из поиска.
Meta-тег robots не указан. Страница свободна для индексации.
Адаптивность
width=device-width, initial-scale=1.0
Настройка масштабирования на мобильных устройствах
?
Тег viewport (<meta name="viewport">) сообщает браузеру, как масштабировать страницу на мобильных. Стандарт: width=device-width, initial-scale=1. Отсутствие — признак отсутствия мобильной версии.
Meta-тег viewport со значением-константой width=device-width задаёт ширину страницы в соответствии с размером экрана.
Meta-тег viewport со значением initial-scale=1.0 определяет масштаб 1:1, т.е. «не масштабировать».
Разметка OpenGraph
Не найдено
Мета-теги для красивых превью в соцсетях
?
OpenGraph (og:title, og:description, og:image) управляет тем, как страница выглядит при репосте в социальных сетях и мессенджерах. Отсутствие OG-тегов — невзрачный превью при шеринге.
Разметка OpenGraph не задана. Страница не оптимизирована под социальные сети. Мета-теги с разметкой Og помогают социальным роботам лучше структурировать Ваш сайт.
Все мета-теги
Кол-во: 1
Полный список мета-тегов страницы
?
Таблица всех meta-тегов, включая нестандартные. Позволяет найти опечатки, дубли и лишние теги.
Найдены мета-теги 1шт. Мета-теги не видимы для человека и предназначены для обмена информацией между веб-страницей и поисковыми системами, браузерами и другими веб-службами. С ними роботы 🤖 и устройства ведут себя более ожидаемо.
Показать полный список мета-тегов
| Тип | Название | Значение |
|---|---|---|
| name | viewport | width=device-width, initial-scale=1.0 |
Оптимизация Готовность: 65%
Структура
Ошибок нет
Семантические HTML-элементы страницы
?
Проверяет наличие основных структурных элементов: nav, header, footer, main. Корректная семантическая структура помогает поисковику понять архитектуру страницы.
Структура документа корректна (теги <html> и <body> присутствуют по одному на документ).
Контент
Есть ошибки
Объём и качество текстового содержимого
?
Анализирует объём полезного текста на странице. Слишком мало — страница может считаться малополезной. Слишком много — ухудшается читаемость и восприятие.
Слова из title 2 встречаются в тексте редко. Добавьте в контент страницы слова из тега <title>!
Среднее число слов в абзаце 2 слишком мало. Сделайте контент более читаемым!
Кол-во слов 498 не очень много. Добавьте побольше текста (хотя бы 800 слов)!
Абзацев с текстом 24 достаточно.
Кол-во знаков контента 3792 на странице оптимально.
Заголовки
Ошибок нет
Иерархия заголовков H1–H6
?
H1 должен быть один и содержать ключевой запрос. H2–H6 описывают подразделы. Пропуск уровней (H1 → H3) и несколько H1 — типичные ошибки, снижающие понятность страницы для поисковика.
На странице присутствуют заголовки <h1> 8. Это прекрасно.
На странице присутствуют заголовки <h2> 3. Это хорошо.
На странице присутствуют заголовки <h3> 5.
Тошнота
2,83
Насколько одно слово доминирует в тексте
?
Классическая тошнота = √(частота самого повторяющегося слова). Норма до 7–8: текст воспринимается естественно. Выше — поисковик может счесть страницу переспамленной.
Тошнота страницы в пределах нормы 3.
Академич. тошнота
20,68%
Насколько текст перенасыщен ключевыми словами
?
Академическая тошнота = (частота слова / общее количество слов) × 100%. Показывает долю конкретного слова в тексте. Норма 5–15%.
Академическая тошнота превышает норму 5-15%. Измените текст страницы!
Семантическое ядро
20
Наиболее часто встречающиеся слова на странице
?
Топ слов по частоте использования. Показывает, какие слова доминируют в тексте с точки зрения поисковика.
Контент страницы содержит осмысленный текст и слова.
Показать список слов
| Слово | Кол-во | Частота |
|---|---|---|
| ленинградской | 8 | 1,61% |
| области | 8 | 1,61% |
| анализ | 7 | 1,41% |
| analytics | 6 | 1,20% |
| реестр | 6 | 1,20% |
| загрузка | 6 | 1,20% |
| данных | 6 | 1,20% |
| скачать | 6 | 1,20% |
| dark_mode | 5 | 1,00% |
| главная | 5 | 1,00% |
| статистика | 5 | 1,00% |
| table_view | 5 | 1,00% |
| дефектов | 5 | 1,00% |
| организация | 4 | 0,80% |
| данные | 4 | 0,80% |
| light_mode | 4 | 0,80% |
| grid_view | 4 | 0,80% |
| table_rows | 4 | 0,80% |
| donut_small | 4 | 0,80% |
| фильтры | 4 | 0,80% |
Индексация Готовность: 80%
Индексирование
Ошибок нет
Разрешено ли индексирование страницы
?
Проверяет, не закрыта ли страница от индексации через robots.txt, meta robots или X-Robots-Tag. Страница, закрытая от индексации, не появится в поисковой выдаче.
Анкоров на странице 20 оптимально. Поисковые роботы обязательно проиндексируют сайт.
Robots.txt
Найден корректный robots.txt
Файл управления сканированием сайта роботами
?
Robots.txt указывает поисковым роботам, какие страницы сканировать, а какие — нет. Ошибки в файле могут случайно закрыть важные разделы от индексации.
Robots.txt настроен корректно. Размер файла: 322500 байт. Загружен за: 0сек.
Проверяемая страница не запрещена в robots.txt.
Robots.txt доступен по постоянному адресу
Цепочка редиректов для файла robots.txt:
http://fkr-lenobl.ru/robots.txt
301 MovedPermanently
https://fkr-lenobl.ru/robots.txt
200 OK
Показать содержимое robots.txt
<!DOCTYPE html>
<!-- portal-build: 2026-06-15-v5 -->
<html class="" lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"/>
<meta http-equiv="Pragma" content="no-cache"/>
<meta http-equiv="Expires" content="0"/>
<title>Портал ФКР</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg"/>
<link rel="shortcut icon" href="favicon.svg"/>
<script src="assets/vendor/tailwind.js"></script>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
"primary": "#0056b2",
"background-light": "#eef6ff",
"background-dark": "#0f172a",
},
fontFamily: {
"display": ["'YS Display'", "sans-serif"],
"sans": ["'YS Text'", "'YS Display'", "sans-serif"]
},
borderRadius: {
"DEFAULT": "0.375rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
},
},
}
</script>
<link href="assets/material-symbols.css" rel="stylesheet"/>
<style>
@font-face { font-family: 'YS Display'; font-weight: 100; font-style: normal;
src: url('assets/fonts/YandexSansDisplay-Thin.woff') format('woff'); }
@font-face { font-family: 'YS Display'; font-weight: 300; font-style: normal;
src: url('assets/fonts/YandexSansDisplay-Light.woff') format('woff'); }
@font-face { font-family: 'YS Display'; font-weight: 400; font-style: normal;
src: url('assets/fonts/YandexSansDisplay-Regular.woff') format('woff'); }
@font-face { font-family: 'YS Display'; font-weight: 700; font-style: normal;
src: url('assets/fonts/YandexSansDisplay-Bold.woff') format('woff'); }
@font-face { font-family: 'YS Display'; font-weight: 400; font-style: italic;
src: url('assets/fonts/YandexSansDisplay-RegularItalic.woff') format('woff'); }
@font-face { font-family: 'YS Text'; font-weight: 100; font-style: normal;
src: url('assets/fonts/YandexSansText-Thin.woff') format('woff'); }
@font-face { font-family: 'YS Text'; font-weight: 300; font-style: normal;
src: url('assets/fonts/YandexSansText-Light.woff') format('woff'); }
@font-face { font-family: 'YS Text'; font-weight: 400; font-style: normal;
src: url('assets/fonts/YandexSansText-Regular.woff') format('woff'); }
@font-face { font-family: 'YS Text'; font-weight: 500; font-style: normal;
src: url('assets/fonts/YandexSansText-Medium.woff') format('woff'); }
@font-face { font-family: 'YS Text'; font-weight: 700; font-style: normal;
src: url('assets/fonts/YandexSansText-Bold.woff') format('woff'); }
@font-face { font-family: 'YS Text'; font-weight: 400; font-style: italic;
src: url('assets/fonts/YandexSansText-RegularItalic.woff') format('woff'); }
</style>
<script src="https://api-maps.yandex.ru/2.1/?lang=ru_RU" type="text/javascript"></script>
<script src="assets/vendor/xlsx.full.min.js"></script>
<script src="assets/vendor/mammoth.browser.min.js"></script>
<script src="assets/vendor/pdf.min.js"></script>
<style>
body { font-family: 'YS Text', 'YS Display', sans-serif; }
h1, h2, h3, h4, h5, h6 { font-family: 'YS Display', sans-serif; }
select.styled-select,
.styled-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
padding-right: 36px !important;
transition: all 0.2s ease;
}
select.styled-select:hover, .styled-select:hover {
border-color: #0ea5e9;
background-color: rgba(14, 165, 233, 0.05);
}
select.styled-select:focus, .styled-select:focus {
border-color: #0ea5e9;
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.15), 0 4px 12px rgba(0, 0, 0, 0.2);
background-color: rgba(14, 165, 233, 0.05);
}
select.styled-select option, .styled-select option {
background: #1e293b; color: #e2e8f0; padding: 10px 12px; font-size: 14px;
}
select.styled-select option:checked, .styled-select option:checked {
background: #0056b2; color: #fff;
}
.sidebar-scroll::-webkit-scrollbar { width: 4px; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.table-container::-webkit-scrollbar { height: 6px; width: 6px; }
.table-container::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.glass-panel {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 86, 178, 0.1);
}
.material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; }
#map-container { height: 100% !important; width: 100% !important; min-height: 400px; }
#map-container [class*="copyrights-pane"] { display: none !important; }
#map-container [class*="copyright"] { display: none !important; }
#map-container [class*="gotoymaps"] { display: none !important; }
body {
font-family: 'YS Text', 'YS Display', sans-serif;
letter-spacing: -0.01em;
line-height: 1.5;
background-color: #eef6ff;
}
.app-page {
display: none;
opacity: 0;
transform: scale(0.96) translateY(20px);
transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1), transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.app-page.is-active {
display: flex;
opacity: 1;
transform: scale(1) translateY(0);
position: relative;
width: 100%;
height: 100vh;
}
.app-page.is-active header { animation: slideFadeIn 0.5s ease-out 0.1s both; }
.app-page.is-active main { animation: slideFadeIn 0.6s ease-out 0.15s both; }
@keyframes slideFadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.theme-toggle, .theme-btn-main { display: none !important; }
.theme-toggle { display: flex; align-items: center; gap: 10px; cursor: pointer; user-select: none; }
.theme-toggle .toggle-track {
position: relative; width: 48px; height: 26px; border-radius: 13px;
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
border: 1px solid rgba(255,255,255,0.1); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: inset 0 2px 4px rgba(0,0,0,0.3);
}
.theme-toggle .toggle-track::after {
content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px;
border-radius: 50%; background: linear-gradient(135deg, #94a3b8, #cbd5e1);
box-shadow: 0 2px 6px rgba(0,0,0,0.3); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
html.light-theme .theme-toggle .toggle-track {
background: linear-gradient(135deg, #bfdbfe 0%, #93c5fd 100%);
border-color: rgba(59,130,246,0.3);
}
html.light-theme .theme-toggle .toggle-track::after {
left: 24px; background: linear-gradient(135deg, #fbbf24, #f59e0b);
}
.theme-toggle .toggle-icon { font-size: 18px; transition: all 0.3s ease; }
.theme-toggle .icon-moon { opacity: 1; color: #94a3b8; }
.theme-toggle .icon-sun { opacity: 0.4; color: #64748b; }
html.light-theme .theme-toggle .icon-moon { opacity: 0.4; }
html.light-theme .theme-toggle .icon-sun { opacity: 1; color: #f59e0b; }
.theme-btn-main {
display: flex; align-items: center; gap: 8px; padding: 8px 16px;
border-radius: 9999px; border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05); backdrop-filter: blur(8px);
color: rgba(255,255,255,0.7); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all 0.3s ease;
}
.theme-btn-main:hover { background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.2); color: #fff; }
html.light-theme .theme-btn-main { background: rgba(0,0,0,0.05); border-color: rgba(0,0,0,0.1); color: rgba(0,0,0,0.6); }
/* Light theme overrides */
html.light-theme #page-registry > div > aside,
html.light-theme #page-dashboard > div > aside,
html.light-theme #page-map > div > aside { background: #ffffff !important; border-color: #e2e8f0 !important; }
html.light-theme aside h1 { color: #0f172a !important; }
html.light-theme aside nav a { color: #475569 !important; }
html.light-theme aside nav a:hover { color: #0f172a !important; background: rgba(0,86,178,0.06) !important; }
html.light-theme aside nav a.bg-primary\/10 { background: rgba(0,86,178,0.08) !important; color: #0056b2 !important; border-color: rgba(0,86,178,0.15) !important; }
html.light-theme aside select.styled-select { background: #f8fafc !important; border-color: #e2e8f0 !important; color: #334155 !important; }
html.light-theme #page-registry > div, html.light-theme #page-dashboard > div, html.light-theme #page-map > div { background: #f1f5f9 !important; }
html.light-theme #page-registry header, html.light-theme #page-dashboard header { background: rgba(241,245,249,0.85) !important; border-color: #e2e8f0 !important; }
html.light-theme #search-input, html.light-theme #main-search-input-new { background: #ffffff !important; border-color: #e2e8f0 !important; color: #0f172a !important; }
html.light-theme #appendix-selector { background: #ffffff !important; border-color: #e2e8f0 !important; color: #0f172a !important; }
html.light-theme h2.text-white { color: #0f172a !important; }
html.light-theme #registry-table thead th, html.light-theme .table-sticky-header th { background: #f8fafc !important; color: #64748b !important; box-shadow: 0 1px 0 0 #e2e8f0 !important; }
html.light-theme #registry-table tbody tr { border-color: #f1f5f9 !important; }
html.light-theme #registry-table tbody tr:hover { background: rgba(0,86,178,0.04) !important; }
html.light-theme #registry-table tbody td { color: #334155 !important; }
html.light-theme #pagination-footer { background: #f8fafc !important; border-color: #e2e8f0 !important; }
html.light-theme #pagination-controls button { color: #475569 !important; }
html.light-theme #pagination-controls .bg-primary { background: #0056b2 !important; color: #ffffff !important; }
html.light-theme #pagination-controls .bg-slate-800 { background: #e2e8f0 !important; color: #475569 !important; }
html.light-theme .bg-\[\#0b0f1a\] { background: #f1f5f9 !important; }
html.light-theme #addresses-container { background: #f8fafc !important; }
html.light-theme .dashboard-table-sticky th { background: #f8fafc !important; color: #64748b !important; box-shadow: 0 1px 0 0 #e2e8f0 !important; }
html.light-theme .border-slate-800 { border-color: #e2e8f0 !important; }
html.light-theme .text-white { color: #0f172a !important; }
html.light-theme .text-slate-200 { color: #334155 !important; }
html.light-theme .text-slate-300 { color: #475569 !important; }
html.light-theme .text-slate-400 { color: #64748b !important; }
html.light-theme .text-slate-500 { color: #94a3b8 !important; }
html.light-theme .text-primary { color: #0056b2 !important; }
html.light-theme .bg-primary { background: #0056b2 !important; color: #fff !important; }
html.light-theme .bg-emerald-600 { background: #059669 !important; color: #fff !important; }
html.light-theme body { background-color: #f1f5f9 !important; }
html.light-theme .custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; }
html.light-theme .custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; }
html.light-theme .dashboard-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; }
html.light-theme .dashboard-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; }
/* Card page light theme */
html.light-theme #page-card.is-active { background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%) !important; }
html.light-theme #page-card .glass-panel { background: rgba(255,255,255,0.75) !important; border-color: rgba(0,0,0,0.08) !important; }
html.light-theme #page-card .glass-button { background: rgba(0,86,178,0.06) !important; border-color: rgba(0,86,178,0.12) !important; }
html.light-theme #page-card h1, html.light-theme #page-card h3 { color: #0f172a !important; }
html.light-theme #page-card .text-white { color: #0f172a !important; }
html.light-theme #page-card .text-slate-300 { color: #475569 !important; }
html.light-theme #page-card .text-slate-400 { color: #64748b !important; }
html.light-theme #page-card .border-white\/10, html.light-theme #page-card .border-white\/20 { border-color: rgba(0,0,0,0.08) !important; }
html.light-theme #page-card .system-link { color: #475569 !important; }
html.light-theme #page-card .system-card { background: rgba(255,255,255,0.5) !important; border-color: rgba(0,0,0,0.06) !important; }
html.light-theme #page-card .system-card:hover { background: rgba(255,255,255,0.8) !important; }
html.light-theme #page-card .bg-white\/5 { background: rgba(0,0,0,0.02) !important; }
html.light-theme #page-card .border-white\/5 { border-color: rgba(0,0,0,0.06) !important; }
html.light-theme #page-card .repairs-btn { background: linear-gradient(135deg, rgba(245,158,11,0.12) 0%, rgba(234,88,12,0.08) 100%) !important; border-color: rgba(245,158,11,0.3) !important; color: #b45309 !important; }
/* Main page (results) — светлая тема: светлое стекло, тёмный текст */
html.light-theme #page-results .hero-gradient {
background: linear-gradient(to right, rgba(241,245,249,0.92) 0%, rgba(241,245,249,0.55) 55%, rgba(241,245,249,0.1) 100%) !important;
}
html.light-theme #page-results .hero-overlay-bottom {
background: linear-gradient(to top, rgba(241,245,249,1) 0%, rgba(241,245,249,0) 100%) !important;
}
html.light-theme #page-results .glass {
background: rgba(255, 255, 255, 0.75) !important;
border-color: rgba(0, 0, 0, 0.08) !important;
backdrop-filter: blur(16px);
}
html.light-theme #page-results h1 { color: #0f172a !important; }
html.light-theme #page-results h1 .text-primary { color: #0056b2 !important; }
html.light-theme #page-results p { color: rgba(15,23,42,0.65) !important; }
html.light-theme #page-results .text-white { color: #0f172a !important; }
html.light-theme #page-results .text-white\/70 { color: rgba(15,23,42,0.65) !important; }
html.light-theme #page-results .text-white\/50 { color: rgba(15,23,42,0.5) !important; }
html.light-theme #page-results .text-white\/30 { color: rgba(15,23,42,0.3) !important; }
html.light-theme #page-results .opacity-80 { color: rgba(15,23,42,0.75) !important; }
html.light-theme #page-results .opacity-90 { color: rgba(15,23,42,0.85) !important; }
html.light-theme #page-results span.text-primary { color: #0056b2 !important; }
html.light-theme #page-results .bg-background-dark { background: #f1f5f9 !important; }
html.light-theme #page-results #main-search-input-new {
background: rgba(255,255,255,0.9) !important;
border-color: rgba(0,0,0,0.1) !important;
color: #0f172a !important;
}
html.light-theme #page-results #main-search-input-new::placeholder { color: #94a3b8 !important; }
html.light-theme #page-results #main-search-btn-new {
background: rgba(0,86,178,0.08) !important;
color: #0056b2 !important;
border-color: rgba(0,86,178,0.15) !important;
}
html.light-theme #page-results #main-search-btn-new:hover { background: rgba(0,86,178,0.14) !important; }
html.light-theme #page-results nav { background: rgba(255,255,255,0.55) !important; border-color: rgba(0,0,0,0.08) !important; }
html.light-theme #page-results nav a { color: #334155 !important; }
html.light-theme #page-results nav a:hover { background: rgba(0,86,178,0.08) !important; color: #0056b2 !important; }
html.light-theme #page-results footer { background: transparent !important; border: none !important; }
html.light-theme #page-results .border-white\/10 { border-color: rgba(0,0,0,0.06) !important; }
html.light-theme #page-results .bg-white\/10 { background: rgba(0,86,178,0.08) !important; }
html.light-theme #page-results .bg-white\/5 { background: rgba(0,86,178,0.04) !important; }
html.light-theme #page-results .animate-ping { background: #0056b2 !important; }
html.light-theme #page-results .bg-primary { background: #0056b2 !important; }
/* Логотип и кнопка темы в светлой теме на главной */
html.light-theme #page-results .theme-btn-main {
background: rgba(0,0,0,0.06) !important;
border-color: rgba(0,0,0,0.12) !important;
color: #334155 !important;
}
/* Map styles */
.table-container { height: 100%; overflow: auto; }
.table-container thead th { position: sticky; top: 0; z-index: 10; }
body.auth-locked .app-page { visibility: hidden; pointer-events: none; }
#auth-gate.hidden { display: none !important; }
#auth-gate {
background: radial-gradient(ellipse at 50% 0%, rgba(0,86,178,0.25) 0%, transparent 55%),
linear-gradient(180deg, #0b0f1a 0%, #0f172a 100%);
}
.auth-card {
width: min(100%, 440px);
background: rgba(22, 27, 44, 0.92);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 24px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
backdrop-filter: blur(16px);
}
</style>
</head>
<body class="m-0 p-0 overflow-hidden h-screen auth-locked">
<div id="auth-gate" class="fixed inset-0 z-[99999] flex items-center justify-center p-6">
<div class="auth-card p-8 lg:p-10">
<div class="flex items-center gap-3 mb-8">
<div class="w-12 h-12 bg-primary/20 border border-primary/40 rounded-xl flex items-center justify-center text-primary">
<span class="material-symbols-outlined text-[28px]">apartment</span>
</div>
<div class="text-[10px] font-bold uppercase tracking-widest text-slate-500">Портал ФКР</div>
</div>
<h1 class="text-xl lg:text-2xl font-bold text-white leading-snug mb-2">
Некоммерческая организация<br/>Фонд капитального ремонта<br/>многоквартирных домов<br/>
<span class="text-primary">Ленинградской области</span>
</h1>
<p class="text-sm text-slate-400 mb-8">Введите данные для входа в систему</p>
<form id="auth-form" class="space-y-4" autocomplete="on">
<div>
<label class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2 block" for="auth-login">Логин</label>
<input id="auth-login" name="login" type="text" required autocomplete="username"
class="w-full bg-slate-900/60 border border-slate-700 rounded-xl py-3 px-4 text-sm text-white outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="Введите логин"/>
</div>
<div>
<label class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2 block" for="auth-password">Пароль</label>
<input id="auth-password" name="password" type="password" required autocomplete="current-password"
class="w-full bg-slate-900/60 border border-slate-700 rounded-xl py-3 px-4 text-sm text-white outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/20"
placeholder="Введите пароль"/>
</div>
<p id="auth-error" class="hidden text-sm text-red-400 font-medium"></p>
<button id="auth-submit" type="submit"
class="w-full py-3.5 mt-2 bg-primary hover:bg-primary/90 text-white text-sm font-bold uppercase tracking-wider rounded-xl transition-colors shadow-lg shadow-primary/25">
Войти
</button>
</form>
</div>
</div>
<script>
/* portal-auth-inline v8 */
(function(){'use strict';
var LOGIN_URL='/api/auth/login',ME_URL='/api/auth/me',TOKEN_KEY='fkr_lenobl_auth_token',USER_KEY='fkr_lenobl_auth_user';
function getToken(){return localStorage.getItem(TOKEN_KEY)||sessionStorage.getItem(TOKEN_KEY)||'';}
function setSession(t,u){localStorage.setItem(TOKEN_KEY,t);localStorage.setItem(USER_KEY,JSON.stringify(u||{}));sessionStorage.removeItem(TOKEN_KEY);sessionStorage.removeItem(USER_KEY);}
function clearSession(){localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(USER_KEY);sessionStorage.removeItem(TOKEN_KEY);sessionStorage.removeItem(USER_KEY);}
function authHeaders(){var t=getToken();return t?{Authorization:'Bearer '+t}:{};}
var nf=window.fetch.bind(window);
window.fetch=function(url,opts){opts=opts||{};var u=String(url);
if(u.indexOf('/api/')===0&&u.indexOf(LOGIN_URL)!==0)opts.headers=Object.assign({},opts.headers||{},authHeaders());
return nf(url,opts).then(function(res){if(res.status===401&&u.indexOf(ME_URL)<0&&u.indexOf('/api/yadisk/')<0&&!window.__authJustLoggedIn){clearSession();showGate();showError('Сессия истекла');}return res;});};
function showGate(){var g=document.getElementById('auth-gate');if(g)g.classList.remove('hidden');document.body.classList.add('auth-locked');}
function hideGate(){var g=document.getElementById('auth-gate');if(g)g.classList.add('hidden');document.body.classList.remove('auth-locked');}
function errMsg(d,st,f){if(st===405||d.detail==='Method Not Allowed')return 'На сервере не обновлён API. Запустите deploy_all.bat';return(d&&d.detail&&typeof d.detail==='string')?d.detail:(f||'Неверный логин или пароль');}
function postLogin(login,password){return nf(LOGIN_URL,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({login:login.trim(),password:password})}).then(function(r){return r.text().then(function(text){var d={};try{d=text?JSON.parse(text):{};}catch(e){}if(!r.ok)throw new Error(errMsg(d,r.status,'Неверный логин или пароль'));if(!d.token)throw new Error('Неверный логин или пароль');return d;});});}
function showError(m){var el=document.getElementById('auth-error');if(!el)return;el.textContent=m||'';el.classList.toggle('hidden',!m);}
function ensureMain(){if(!location.hash||location.hash==='#')location.hash='#results';var id=(location.hash||'#results').replace('#','').split('/')[0]||'results';var t=document.getElementById('page-'+id)||document.getElementById('page-results');if(!t)return;document.querySelectorAll('.app-page').forEach(function(p){p.classList.remove('is-active');if(p!==t)p.style.display='none';});t.style.display='flex';t.classList.add('is-active');}
function runStart(){if(typeof window.startPortalApp==='function'){window.startPortalApp();return;}var n=0,t=setInterval(function(){n++;if(typeof window.startPortalApp==='function'){clearInterval(t);window.startPortalApp();}else if(n>80)clearInterval(t);},50);}
function startPortal(){hideGate();ensureMain();window.__authJustLoggedIn=true;setTimeout(function(){window.__authJustLoggedIn=false;},5000);runStart();}
function verifySession(){var token=getToken();if(!token){showGate();return Promise.resolve(false);}return nf(ME_URL,{headers:authHeaders()}).then(function(r){if(!r.ok)throw 0;return r.json();}).then(function(d){if(d&&d.user)setSession(token,d.user);startPortal();return true;}).catch(function(){clearSession();showGate();return false;});}
function bindForm(){var form=document.getElementById('auth-form');if(!form)return;form.addEventListener('submit',function(e){e.preventDefault();showError('');var login=(document.getElementById('auth-login')||{}).value||'';var password=(document.getElementById('auth-password')||{}).value||'';var btn=document.getElementById('auth-submit');if(btn)btn.disabled=true;postLogin(login,password).then(function(d){setSession(d.token,d.user);startPortal();}).catch(function(err){showError(err.message||'Неверный логин или пароль');}).finally(function(){if(btn)btn.disabled=false;});});}
window.fkrLogout=function(){clearSession();window.__portalStarted=false;showGate();showError('');location.hash='#results';};
document.addEventListener('DOMContentLoaded',function(){bindForm();verifySession();});
})();
</script>
<section class="app-page" id="page-registry">
<style>
.custom-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #475569; border-radius: 9999px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #0ea5e9; }
.table-sticky-header th { position: sticky; top: 0; z-index: 20; background: #161b2c; box-shadow: 0 1px 0 0 #1e293b; }
</style>
<div class="flex w-full h-full bg-[#0b0f1a] overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-[#161b2c] border-r border-slate-800 flex flex-col shrink-0 z-30">
<div class="p-6 flex items-center gap-3">
<div class="w-10 h-10 bg-primary/20 border border-primary/40 rounded-xl flex items-center justify-center text-primary shadow-lg shadow-primary/10">
<span class="material-symbols-outlined text-[24px]">analytics</span>
</div>
<div class="flex flex-col">
<h1 class="text-sm font-bold leading-tight text-white uppercase tracking-wider">ФКР Ленинградской области</h1>
</div>
</div>
<div class="px-6 pb-2">
<label class="theme-toggle" onclick="toggleTheme()">
<span class="material-symbols-outlined toggle-icon icon-moon">dark_mode</span>
<div class="toggle-track"></div>
<span class="material-symbols-outlined toggle-icon icon-sun">light_mode</span>
</label>
</div>
<nav class="flex-1 px-4 space-y-2 mt-4">
<a href="#results" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">grid_view</span>
<span class="text-sm font-medium">Главная</span>
</a>
<a href="#registry" class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-sm shadow-primary/5 transition-all">
<span class="material-symbols-outlined fill-current">table_rows</span>
<span class="text-sm font-bold">Реестр МКД</span>
</a>
<a href="#map" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">map</span>
<span class="text-sm font-medium">Карта</span>
</a>
<a href="#statuses" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">donut_small</span>
<span class="text-sm font-medium">Статистика</span>
</a>
</nav>
<!-- Registry Filters -->
<div class="px-4 pb-6">
<div class="text-[10px] font-bold text-slate-600 uppercase tracking-widest px-4 mb-4">Фильтры</div>
<div class="mb-3 px-4">
<label class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2 block">Статус</label>
<select id="reg-filter-status" class="styled-select w-full bg-slate-900/50 border border-slate-800 rounded-xl py-2.5 px-4 text-sm text-slate-200 outline-none">
<option value="">Все</option>
<option value="Не обследовано">Не обследовано</option>
<option value="Полевые работы">Полевые работы</option>
<option value="Камеральная обработка">Камеральная обработка</option>
<option value="Передано заказчику">Передано заказчику</option>
<option value="Согласовано">Согласовано</option>
</select>
</div>
<div class="px-4">
<button id="reg-reset-filters" class="w-full py-2.5 px-4 bg-primary text-white text-xs font-bold uppercase tracking-wider rounded-xl hover:bg-primary/90 transition-colors shadow-lg shadow-primary/20">
Сбросить фильтры
</button>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col min-w-0 bg-[#0b0f1a] relative overflow-hidden">
<!-- Header -->
<header class="bg-[#0b0f1a]/80 backdrop-blur-md border-b border-slate-800 px-8 py-4 flex items-center justify-between shrink-0 z-40">
<div class="relative flex-1 max-w-xl group">
<span class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none transition-colors group-focus-within:text-primary">
<span class="material-symbols-outlined text-slate-500 !text-xl">search</span>
</span>
<input id="search-input" class="block w-full pl-12 pr-10 py-2.5 border border-slate-800 rounded-2xl bg-slate-900/50 text-slate-200 placeholder-slate-500 focus:ring-2 focus:ring-primary/20 focus:border-primary/50 transition-all text-sm" placeholder="Поиск по адресу, ID, исполнителю, статусу..." type="text"/>
<button id="search-clear" class="absolute inset-y-0 right-0 pr-3 flex items-center opacity-0 pointer-events-none transition-opacity" type="button">
<span class="material-symbols-outlined text-slate-500 hover:text-white !text-lg cursor-pointer">close</span>
</button>
</div>
<div class="flex items-center gap-3">
<button id="export-csv" class="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-primary/20">
<span class="material-symbols-outlined !text-lg">file_download</span>
<span>CSV</span>
</button>
<button id="export-excel" class="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 text-white px-5 py-2.5 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-emerald-600/20">
<span class="material-symbols-outlined !text-lg">table_view</span>
<span>Excel</span>
</button>
<button type="button" data-wear-report="pdf" class="wear-report-btn flex items-center gap-2 bg-red-600/90 hover:bg-red-500 text-white px-4 py-2.5 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-red-600/20" title="Отчёт по износу (PDF)">
<span class="material-symbols-outlined !text-lg">picture_as_pdf</span>
<span>PDF</span>
</button>
<button type="button" data-wear-report="docx" class="wear-report-btn flex items-center gap-2 bg-blue-600/90 hover:bg-blue-500 text-white px-4 py-2.5 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-blue-600/20" title="Отчёт по износу (DOCX)">
<span class="material-symbols-outlined !text-lg">description</span>
<span>DOCX</span>
</button>
<button type="button" data-wear-report="xlsx" class="wear-report-btn flex items-center gap-2 bg-violet-600/90 hover:bg-violet-500 text-white px-4 py-2.5 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-violet-600/20" title="Отчёт по износу (XLSX)">
<span class="material-symbols-outlined !text-lg">analytics</span>
<span>Отчёт</span>
</button>
<button type="button" data-wear-report="xml" class="wear-report-btn flex items-center gap-2 bg-slate-600 hover:bg-slate-500 text-white px-4 py-2.5 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-slate-600/20" title="Отчёт по износу (XML)">
<span class="material-symbols-outlined !text-lg">code</span>
<span>XML</span>
</button>
</div>
</header>
<!-- Table Section -->
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<div class="flex items-center justify-between px-8 py-3 shrink-0 border-b border-slate-800/50">
<div class="flex items-center gap-4">
<h2 class="text-sm font-bold text-white tracking-tight uppercase">Реестр объектов</h2><span id="total-count" class="hidden"></span>
<span id="selected-count" class="text-xs font-bold text-emerald-400 hidden">Выбрано: <span id="selected-num">0</span></span>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<button id="clear-selection" class="hidden items-center gap-2 bg-slate-800 hover:bg-slate-700 text-slate-300 px-4 py-2 rounded-xl text-[11px] font-bold uppercase tracking-wider transition-all">
<span class="material-symbols-outlined !text-base">deselect</span>
<span>Снять выбор</span>
</button>
</div>
</div>
<div class="flex-1 bg-[#0b0f1a] overflow-hidden flex flex-col relative min-h-0">
<div class="overflow-auto custom-scrollbar flex-1 min-h-0">
<table class="w-full border-collapse text-[12px] text-left" id="registry-table">
<thead class="table-sticky-header">
<tr class="text-slate-400 uppercase tracking-wider font-bold">
<th class="px-3 py-4 w-10"><input type="checkbox" id="select-all-checkbox" class="w-4 h-4 rounded bg-slate-800 border-slate-600 text-primary focus:ring-primary/30 cursor-pointer accent-[#0056b2]" title="Выбрать все на странице"/></th>
<th class="px-6 py-4 min-w-[100px] cursor-pointer hover:text-primary" onclick="sortBy('ID')">ID <span id="sort-ID" class="text-slate-600">↕</span></th>
<th class="px-6 py-4 min-w-[280px] cursor-pointer hover:text-primary" onclick="sortBy('Адрес')">Адрес <span id="sort-Адрес" class="text-slate-600">↕</span></th>
<th class="px-4 py-4 text-center min-w-[140px] cursor-pointer hover:text-primary" onclick="sortBy('Статус')">Статус <span id="sort-Статус" class="text-slate-600">↕</span></th>
<th class="px-6 py-4 text-center w-24 cursor-pointer hover:text-primary" onclick="sortBy('Год постройки')">Год <span id="sort-Год постройки" class="text-slate-600">↕</span></th>
<th class="px-6 py-4 text-center w-20 cursor-pointer hover:text-primary" onclick="sortBy('Число этажей')">Этажи <span id="sort-Число этажей" class="text-slate-600">↕</span></th>
<th class="px-6 py-4 text-right min-w-[120px] cursor-pointer hover:text-primary" onclick="sortBy('Общая площадь, м²')">Площадь (м²) <span id="sort-Общая площадь, м²" class="text-slate-600">↕</span></th>
<th class="px-6 py-4 min-w-[200px]">Управляющая организация</th>
<th class="px-6 py-4 w-12"></th>
</tr>
</thead>
<tbody id="registry-tbody" class="divide-y divide-slate-800/50">
<tr><td colspan="9" style="padding:48px;text-align:center;">
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;">
<div style="width:36px;height:36px;border:3px solid #1e3a5f;border-top-color:#0ea5e9;border-radius:50%;animation:spin 0.8s linear infinite;"></div>
<span style="color:#475569;font-size:12px;text-transform:uppercase;letter-spacing:.08em;font-weight:600;">Загрузка данных...</span>
</div>
</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div id="pagination-footer" class="bg-[#0b0f1a] border-t border-slate-800 px-8 py-3 flex items-center justify-between shrink-0">
<div class="flex items-center gap-6">
<span id="pagination-info" class="text-[11px] text-slate-500 font-bold uppercase tracking-widest">Показано 0 из 0</span>
<div class="h-4 w-px bg-slate-700"></div>
<div class="flex items-center gap-2">
<span class="text-[11px] text-slate-500 uppercase">Строк:</span>
<select id="rows-per-page" class="styled-select bg-slate-800 border border-slate-700 rounded-xl py-1.5 px-3 text-xs text-white outline-none">
<option value="25" selected>25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</div>
<div id="pagination-controls" class="flex items-center gap-1"></div>
</div>
</div>
</div>
<!-- Footer -->
<div class="bg-[#0b0f1a] border-t border-slate-800 px-8 py-2 flex items-center justify-between text-[10px] text-slate-500 font-mono tracking-tighter uppercase shrink-0">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span> Система онлайн</span>
</div>
<div class="flex items-center gap-4"><span>© 2026 ФКР Ленинградской области</span></div>
</div>
</main>
</div>
</section>
<section class="app-page" id="page-card">
<style>
.glass-panel {
background: rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.glass-button {
background: rgba(90, 153, 226, 0.15);
backdrop-filter: blur(4px);
border: 1px solid rgba(90, 153, 226, 0.3);
transition: all 0.2s ease;
}
.glass-button:hover { background: rgba(90, 153, 226, 0.25); border-color: rgba(90, 153, 226, 0.5); }
.glass-button.active { background: #5a99e2; color: #0a0f16; }
.repairs-btn {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.18) 0%, rgba(234, 88, 12, 0.12) 100%);
border: 1px solid rgba(245, 158, 11, 0.35);
color: #fbbf24; cursor: pointer !important;
}
.repairs-btn:hover {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.30) 0%, rgba(234, 88, 12, 0.22) 100%);
border-color: rgba(245, 158, 11, 0.6); color: #fde68a;
box-shadow: 0 0 14px rgba(245, 158, 11, 0.25); transform: translateY(-1px);
}
.repairs-btn.active-repairs {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.45) 0%, rgba(234, 88, 12, 0.35) 100%);
border-color: #f59e0b; color: #fff; box-shadow: 0 0 18px rgba(245, 158, 11, 0.4);
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes btnRipple {
0% { box-shadow: 0 0 0 0 rgba(90, 153, 226, 0.55); transform: scale(0.97); }
60% { box-shadow: 0 0 0 8px rgba(90, 153, 226, 0); transform: scale(1.01); }
100% { box-shadow: 0 0 0 0 rgba(90, 153, 226, 0); transform: scale(1); }
}
@keyframes btnRippleAmber {
0% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); transform: scale(0.97); }
60% { box-shadow: 0 0 0 8px rgba(245, 158, 11, 0); transform: scale(1.01); }
100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); transform: scale(1); }
}
.btn-click-anim { animation: btnRipple 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
.btn-click-anim-amber { animation: btnRippleAmber 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards; }
#page-card.is-active {
background: radial-gradient(circle at top right, #1a2a3a 0%, #0a0f16 100%);
height: 100vh; overflow: hidden;
}
.custom-scroll::-webkit-scrollbar { width: 4px; }
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
.custom-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
.custom-scroll::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.4); }
@keyframes pageIn { from { opacity: 0; transform: scale(0.98) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } }
@keyframes pageOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.98); } }
#page-card.is-active { display: flex; animation: pageIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
.app-page.is-closing { animation: pageOut 0.3s ease-in forwards; pointer-events: none; }
.no-scrollbar::-webkit-scrollbar { display: none; }
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
/* Schema cards */
.schema-card { cursor: pointer; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; transition: all 0.2s; }
.schema-card:hover { border-color: rgba(0,86,178,0.5); transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0,0,0,0.4); }
.schema-thumb-wrap { width: 100%; background: #fff; display: flex; align-items: center; justify-content: center; min-height: 80px; position: relative; overflow: hidden; }
.schema-thumb-wrap canvas { width: 100%; height: auto; display: block; }
.schema-thumb-placeholder { display: flex; align-items: center; justify-content: center; width: 100%; height: 80px; }
.schema-card-icon { font-size: 28px !important; color: #0ea5e9; opacity: 0.7; }
.schema-card-name { font-size: 10px; font-weight: 600; color: #94a3b8; text-align: center; padding: 6px 8px; word-break: break-word; line-height: 1.3; background: rgba(0,0,0,0.2); }
/* Schema viewer */
#schema-viewer-modal { background: #0a0f1e; }
#schema-viewer-modal.flex { display: flex !important; }
#schema-canvas-wrap { display: inline-block; position: relative; line-height: 0; }
/* Location-pin markers — tip точно на координате */
.schema-marker-wrap {
position: absolute;
pointer-events: all;
z-index: 20;
display: inline-block;
/* Y выравнивание: кончик pin точно в coord_y — вычисляется в JS */
transform: translateX(-50%);
cursor: pointer;
}
.schema-marker-wrap .s-pin-icon {
color: #ef4444;
font-size: 28px !important; line-height: 1; display: block;
filter: drop-shadow(0 2px 3px rgba(0,0,0,0.5));
transition: color 0.15s;
}
.schema-marker-wrap .s-pin-badge {
/* Абсолютно над иконкой, left:50% от ширины иконки (~28px) */
position: absolute;
bottom: 100%; left: 50%;
transform: translateX(-50%);
margin-bottom: 2px;
min-width: 22px; height: 22px;
background: #0d9488;
border-radius: 7px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; font-weight: 800;
padding: 0 5px;
box-shadow: 0 2px 6px rgba(0,0,0,0.45);
white-space: nowrap; line-height: 1;
transition: background 0.15s;
}
.schema-marker-wrap.hovered .s-pin-icon { color: #f97316; }
.schema-marker-wrap.hovered .s-pin-badge { background: #0f766e; }
.schema-marker-wrap.active .s-pin-badge { background: #16a34a; }
.schema-marker-wrap.active .s-pin-icon { color: #22c55e; }
/* Defect panel tabs */
.dtab-btn { transition: color 0.15s, border-color 0.15s; border-bottom: 2px solid transparent; }
.dtab-btn.dtab-active { color: #0ea5e9 !important; border-bottom-color: #0ea5e9 !important; }
</style>
<div class="flex flex-col h-screen w-full p-4 gap-4">
<header class="flex items-center justify-between glass-panel px-6 py-3 rounded-2xl shrink-0 gap-4">
<div class="flex items-center gap-4 shrink-0">
<div class="w-10 h-10 bg-primary/20 rounded-xl flex items-center justify-center text-primary">
<span class="material-symbols-outlined">apartment</span>
</div>
<h1 class="text-xl font-bold tracking-tight text-white">Карточка МКД</h1>
</div>
<div id="card-header-status-wrap" class="flex-1 flex justify-center items-center min-w-0 px-2">
<span id="card-header-status" class="hidden items-center gap-2 px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-wider border border-white/10 bg-white/5 text-slate-300"></span>
</div>
<div class="flex items-center gap-3 shrink-0">
<button id="show-on-map-btn" onclick="showBuildingOnMap()" class="flex items-center gap-2 px-5 py-2 glass-panel hover:bg-white/10 rounded-full text-sm font-medium text-slate-300 transition-all">
<span class="material-symbols-outlined text-[18px]">location_on</span>
На карте
</button>
<button id="open-schema" onclick="openBuildingDoc('schema')" class="flex items-center gap-2 px-5 py-2 glass-panel hover:bg-white/10 rounded-full text-sm font-medium text-slate-300 transition-all" style="display:none">
<span class="material-symbols-outlined text-[18px]">schema</span>
Схема
</button>
<button id="open-defect-report" onclick="openBuildingDoc('defect')" class="flex items-center gap-2 px-5 py-2 glass-panel hover:bg-white/10 rounded-full text-sm font-medium text-slate-300 transition-all" style="display:none">
<span class="material-symbols-outlined text-[18px]">assignment</span>
Дефектная ведомость
</button>
<button id="open-sogl-files" onclick="toggleSoglPanel()" class="flex items-center gap-2 px-5 py-2 rounded-full text-sm font-medium transition-all" style="display:none;background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.5);color:#a78bfa;">
<span class="material-symbols-outlined text-[18px]">folder_shared</span>
Файлы согласования
</button>
<button id="open-tzk" class="hidden flex items-center gap-2 px-6 py-2 bg-primary text-white rounded-full font-bold text-sm hover:brightness-110 transition-all shadow-lg shadow-primary/20">
<span class="material-symbols-outlined text-[20px]">description</span>
ТЗК
</button>
<button onclick="closeCardSmooth()" class="flex items-center gap-2 px-4 py-2 glass-panel hover:bg-white/10 rounded-full text-sm font-medium text-slate-300 transition-all">
<span class="material-symbols-outlined text-[20px]">close</span>
</button>
</div>
</header>
<!-- Панель файлов согласования -->
<div id="sogl-panel" style="display:none;" class="glass-panel rounded-2xl px-5 py-4 shrink-0">
<div class="flex items-center gap-3 mb-3">
<span class="material-symbols-outlined text-amber-400">folder_shared</span>
<span class="text-xs font-bold text-amber-300 uppercase tracking-wider">Файлы согласования</span>
<button onclick="toggleSoglPanel()" class="ml-auto text-slate-400 hover:text-white transition-colors"><span class="material-symbols-outlined text-base">close</span></button>
</div>
<div id="sogl-panel-content" class="flex gap-4"></div>
</div>
<main class="flex flex-1 gap-4 min-h-0 overflow-hidden">
<div class="flex-1 flex flex-col gap-4 min-w-0 overflow-y-auto custom-scroll pr-1">
<div id="card-metrics-row" class="glass-panel rounded-2xl p-4 flex items-center flex-wrap gap-y-2 shrink-0">
<div class="flex flex-col items-start gap-1 border-r border-white/10 pl-2 pr-4 shrink-0 max-w-full">
<span class="text-[10px] text-slate-500 uppercase font-bold tracking-widest">Адрес</span>
<span id="card-address-header" class="text-sm font-bold text-white text-left whitespace-nowrap max-w-[min(100%,55ch)] truncate" title="">—</span>
</div>
<div class="flex flex-col items-center gap-1 border-r border-white/10 px-4 shrink-0">
<span class="text-[10px] text-slate-500 uppercase font-bold tracking-widest">Год</span>
<span id="metric-year" class="text-lg font-bold text-white">—</span>
</div>
<div class="flex flex-col items-center gap-1 border-r border-white/10 px-4 shrink-0">
<span class="text-[10px] text-slate-500 uppercase font-bold tracking-widest">Этажи</span>
<span id="metric-floors" class="text-lg font-bold text-white">—</span>
</div>
<div class="flex flex-col items-center gap-1 border-r border-white/10 px-4 shrink-0">
<span class="text-[10px] text-slate-500 uppercase font-bold tracking-widest">Площадь</span>
<span id="metric-area" class="text-lg font-bold text-white">—</span>
</div>
<div class="flex flex-col items-start gap-1 pl-4 flex-1 min-w-[12rem] ml-auto">
<span class="text-[10px] text-slate-500 uppercase font-bold tracking-widest">Управляющая организация</span>
<span id="metric-uk" class="text-base font-bold text-white truncate w-full text-left">—</span>
</div>
</div>
<div class="glass-panel rounded-2xl p-4 flex flex-col shrink-0 h-64">
<h3 class="text-xs font-bold text-white mb-3 uppercase tracking-wider px-1">Общий вид</h3>
<div id="photo-gallery-new" class="flex-1 overflow-y-auto no-scrollbar grid grid-cols-4 gap-4 content-start"></div>
</div>
<!-- Схемы МКД -->
<div id="card-schemas-section" class="glass-panel rounded-2xl p-4 flex flex-col shrink-0" style="display:none">
<div class="flex items-center justify-between mb-3 shrink-0">
<h3 class="text-xs font-bold text-white uppercase tracking-wider px-1">Схемы МКД</h3>
<span id="schemas-gallery-count" class="text-[10px] text-slate-400 font-bold"></span>
</div>
<div id="card-schemas-gallery" class="grid grid-cols-4 gap-3"></div>
</div>
<div class="flex gap-4 shrink-0">
<div id="system-photos-section" class="glass-panel rounded-2xl p-4 flex flex-col shrink-0 w-2/5" style="display:none;">
<div class="flex items-center justify-between mb-3 shrink-0">
<h3 id="system-photos-title" class="text-xs font-bold text-white uppercase tracking-wider px-1">Фото дефектов</h3>
<span id="system-photos-count" class="text-[10px] text-slate-400 font-bold"></span>
</div>
<div id="system-photos-gallery" class="grid grid-cols-3 gap-2"></div>
</div>
<div id="system-content-new" class="glass-panel rounded-2xl flex flex-col p-6 relative flex-1 shrink-0">
<div id="system-placeholder" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="text-center">
<div id="system-placeholder-spinner" class="flex justify-center mb-4">
<div class="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
</div>
<span id="system-placeholder-icon" class="material-symbols-outlined text-7xl text-slate-700 mb-4 hidden">info</span>
<p id="system-placeholder-text" class="text-slate-500 text-sm">Загрузка данных...</p>
</div>
</div>
</div>
</div>
</div>
<aside class="w-72 flex flex-col gap-3 shrink-0">
<div class="glass-panel rounded-2xl p-4 shrink-0 bg-primary/5 border-primary/20 flex flex-col items-center justify-center text-center">
<span class="text-[10px] text-primary font-bold uppercase tracking-widest mb-1">Износ МКД</span>
<div class="flex items-baseline gap-1">
<span id="card-mkd-wear" class="text-4xl font-black text-white leading-none">—</span>
<span class="text-sm font-medium text-slate-400">%</span>
</div>
</div>
<div id="repairs-btn-wrapper" style="display:none">
<button id="btn-repairs" onclick="showRepairsInCard()" class="repairs-btn flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-semibold w-full text-left cursor-pointer transition-all duration-200">
<span class="material-symbols-outlined text-[18px]">construction</span>
<span>Ремонты</span>
</button>
</div>
<div class="flex-1 glass-panel rounded-2xl p-4 flex flex-col min-h-0">
<h3 class="text-xs font-bold text-white mb-3 uppercase tracking-wider px-1">Состояние систем</h3>
<div id="systems-horizontal" class="flex-1 overflow-y-auto custom-scroll flex flex-col gap-2 pr-1"></div>
</div>
</aside>
</main>
</div>
<!-- Модал просмотра Excel файлов -->
<!-- Универсальный модал предпросмотра документов (PDF / Word / Excel) -->
<div id="docpreview-modal" style="display:none;position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.92);backdrop-filter:blur(8px);">
<div style="position:absolute;inset:0;" onclick="closeDocModal()"></div>
<div style="position:relative;z-index:1;margin:16px;height:calc(100vh - 32px);background:rgba(8,18,45,0.99);border:1px solid rgba(100,160,255,0.2);border-radius:18px;display:flex;flex-direction:column;overflow:hidden;">
<!-- Шапка -->
<div style="display:flex;align-items:center;gap:12px;padding:13px 20px;border-bottom:1px solid rgba(255,255,255,0.08);flex-shrink:0;">
<span id="docpreview-icon" class="material-symbols-outlined" style="font-size:20px;flex-shrink:0;"></span>
<span id="docpreview-title" style="font-size:13px;font-weight:700;color:#e2e8f0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
<div id="docpreview-tabs" style="display:flex;gap:3px;flex-wrap:nowrap;overflow-x:auto;max-width:35%;"></div>
<button id="docpreview-download" style="display:flex;align-items:center;gap:6px;padding:7px 16px;background:rgba(74,222,128,0.12);border:1px solid rgba(74,222,128,0.4);border-radius:8px;color:#4ade80;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;white-space:nowrap;flex-shrink:0;transition:background .15s;" onmouseover="this.style.background='rgba(74,222,128,0.22)'" onmouseout="this.style.background='rgba(74,222,128,0.12)'">
<span class="material-symbols-outlined" style="font-size:15px;">download</span>Скачать
</button>
<button onclick="closeDocModal()" style="display:flex;align-items:center;justify-content:center;width:32px;height:32px;background:rgba(255,255,255,0.07);border:none;border-radius:8px;color:#94a3b8;cursor:pointer;flex-shrink:0;">
<span class="material-symbols-outlined" style="font-size:18px;">close</span>
</button>
</div>
<!-- Загрузка -->
<div id="docpreview-loading" style="display:flex;flex-direction:column;align-items:center;justify-content:center;flex:1;gap:14px;color:rgba(180,210,255,0.5);">
<span class="material-symbols-outlined" style="font-size:44px;animation:spin 1s linear infinite;">autorenew</span>
<span style="font-size:13px;">Загрузка файла...</span>
</div>
<!-- PDF -->
<iframe id="docpreview-pdf" style="display:none;flex:1;width:100%;border:none;" src=""></iframe>
<!-- Word -->
<div id="docpreview-word" style="display:none;flex:1;overflow:auto;padding:40px 80px;background:#fff;color:#111;font-family:Arial,sans-serif;font-size:13px;line-height:1.7;"></div>
<!-- Excel -->
<div id="docpreview-excel" style="display:none;flex:1;overflow:auto;padding:0 0 8px;">
<div id="docpreview-excel-wrap" style="padding:0 4px;"></div>
</div>
</div>
</div>
<style>
#docpreview-excel-wrap table{border-collapse:collapse;width:100%;font-size:12px;color:#cbd5e1;}
#docpreview-excel-wrap th{background:rgba(74,158,255,0.12);color:#7dd3fc;font-weight:700;padding:6px 10px;border:1px solid rgba(100,160,255,0.15);white-space:nowrap;position:sticky;top:0;z-index:1;}
#docpreview-excel-wrap td{padding:5px 10px;border:1px solid rgba(255,255,255,0.06);white-space:nowrap;}
#docpreview-excel-wrap tr:nth-child(even) td{background:rgba(255,255,255,0.03);}
#docpreview-excel-wrap tr:hover td{background:rgba(74,158,255,0.07);}
#docpreview-word h1,#docpreview-word h2,#docpreview-word h3{font-weight:bold;margin:16px 0 8px;}
#docpreview-word h1{font-size:18px;}#docpreview-word h2{font-size:15px;}#docpreview-word h3{font-size:13px;}
#docpreview-word p{margin:4px 0;}
#docpreview-word table{border-collapse:collapse;width:100%;margin:8px 0;}
#docpreview-word td,#docpreview-word th{border:1px solid #ccc;padding:4px 8px;font-size:12px;}
.doc-tab{padding:5px 12px;border:none;background:transparent;color:rgba(180,210,255,0.5);font-size:11px;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;font-family:inherit;transition:all .15s;white-space:nowrap;}
.doc-tab:hover{color:#7dd3fc;}
.doc-tab.active{color:#4a9eff;border-bottom-color:#4a9eff;}
</style>
<style>
.photo-tb-btn{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:none;background:transparent;color:rgba(255,255,255,0.65);cursor:pointer;transition:background .15s,color .15s;}
.photo-tb-btn:hover{background:rgba(255,255,255,0.15);color:#fff;}
</style>
<div id="photo-modal" class="fixed inset-0 z-[9999] hidden flex items-center justify-center transition-all duration-300 ease-out opacity-0" aria-hidden="true">
<div class="absolute inset-0 bg-black/90 backdrop-blur-md transition-opacity" onclick="closePhotoModal()"></div>
<div class="relative z-10 flex items-center justify-center w-full h-full p-6 pointer-events-none">
<div id="modal-loading-spinner" class="absolute flex flex-col items-center gap-3 text-white/60" style="display:none!important">
<span class="material-symbols-outlined text-5xl" style="animation:spin 1s linear infinite">autorenew</span>
<span class="text-sm font-medium tracking-wide">Загрузка оригинала...</span>
</div>
<img id="modal-photo" referrerpolicy="no-referrer" class="max-w-full max-h-[90vh] object-contain rounded-xl shadow-2xl transform scale-90 transition-transform duration-300 ease-out pointer-events-auto" src="" alt="Фото">
<!-- Навигация: предыдущее фото -->
<button id="modal-prev" onclick="navigatePhoto(-1)" class="absolute left-4 top-1/2 -translate-y-1/2 text-white/60 hover:text-white bg-white/10 hover:bg-white/25 rounded-full p-3 transition-all pointer-events-auto backdrop-blur-sm hidden">
<span class="material-symbols-outlined text-3xl">chevron_left</span>
</button>
<!-- Навигация: следующее фото -->
<button id="modal-next" onclick="navigatePhoto(1)" class="absolute right-16 top-1/2 -translate-y-1/2 text-white/60 hover:text-white bg-white/10 hover:bg-white/25 rounded-full p-3 transition-all pointer-events-auto backdrop-blur-sm hidden">
<span class="material-symbols-outlined text-3xl">chevron_right</span>
</button>
<!-- Счётчик фото -->
<div id="modal-counter" class="absolute bottom-6 left-1/2 -translate-x-1/2 text-white/60 text-sm bg-black/40 rounded-full px-4 py-1 pointer-events-auto backdrop-blur-sm hidden"></div>
<!-- Тулбар управления фото -->
<div class="absolute bottom-6 left-1/2" style="transform:translateX(-50%) translateY(0);pointer-events:auto;display:flex;align-items:center;gap:2px;background:rgba(20,20,30,0.82);backdrop-filter:blur(12px);border:1px solid rgba(255,255,255,0.13);border-radius:40px;padding:6px 10px;box-shadow:0 4px 24px rgba(0,0,0,0.5);">
<button onclick="photoZoom(0.25)" title="Приблизить" class="photo-tb-btn"><span class="material-symbols-outlined" style="font-size:20px;">zoom_in</span></button>
<button onclick="photoZoom(-0.25)" title="Отдалить" class="photo-tb-btn"><span class="material-symbols-outlined" style="font-size:20px;">zoom_out</span></button>
<button onclick="photoFit()" title="По экрану" class="photo-tb-btn"><span class="material-symbols-outlined" style="font-size:20px;">fit_screen</span></button>
<div style="width:1px;height:22px;background:rgba(255,255,255,0.18);margin:0 4px;"></div>
<button onclick="photoRotate(-90)" title="Повернуть влево" class="photo-tb-btn"><span class="material-symbols-outlined" style="font-size:20px;">rotate_left</span></button>
<button onclick="photoRotate(90)" title="Повернуть вправо" class="photo-tb-btn"><span class="material-symbols-outlined" style="font-size:20px;">rotate_right</span></button>
<div style="width:1px;height:22px;background:rgba(255,255,255,0.18);margin:0 4px;"></div>
<button onclick="downloadCurrentPhoto()" title="Скачать" class="photo-tb-btn"><span class="material-symbols-outlined" style="font-size:20px;">download</span></button>
</div>
<button onclick="closePhotoModal()" class="absolute top-6 right-6 text-white/50 hover:text-white bg-white/10 hover:bg-white/20 rounded-full p-2 transition-all pointer-events-auto backdrop-blur-sm group">
<span class="material-symbols-outlined text-3xl group-hover:rotate-90 transition-transform">close</span>
</button>
</div>
</div>
<!-- ═══ Schema Viewer Modal ═══ -->
<div id="schema-viewer-modal" class="fixed inset-0 z-[9998] hidden flex-col" aria-hidden="true">
<!-- Header -->
<div class="flex items-center gap-3 px-5 py-3 border-b border-white/10 shrink-0 glass-panel rounded-none">
<button onclick="closeSchemaViewer()" class="flex items-center gap-2 px-4 py-2 glass-panel hover:bg-white/10 rounded-full text-sm font-medium text-slate-300 transition-all">
<span class="material-symbols-outlined text-[18px]">arrow_back</span>
Назад
</button>
<span class="material-symbols-outlined text-primary text-[20px]">map</span>
<h2 id="schema-viewer-title" class="text-white font-bold text-sm"></h2>
<span id="schema-marker-count" class="ml-2 text-xs text-slate-400 bg-white/5 px-3 py-1 rounded-full"></span>
<!-- Zoom controls -->
<div class="ml-auto flex items-center gap-2">
<div class="flex items-center gap-0 glass-panel rounded-full overflow-hidden">
<button onclick="zoomSchema(-0.25)" class="px-3 py-1.5 text-white font-bold text-base hover:bg-white/10 transition-all border-r border-white/10" title="Уменьшить">−</button>
<span id="schema-zoom-label" class="text-xs text-slate-300 font-bold px-3 select-none">100%</span>
<button onclick="zoomSchema(+0.25)" class="px-3 py-1.5 text-white font-bold text-base hover:bg-white/10 transition-all border-l border-white/10" title="Увеличить">+</button>
</div>
<button onclick="resetSchemaZoom()" class="flex items-center gap-1 px-3 py-1.5 glass-panel hover:bg-white/10 rounded-full text-xs text-slate-400 hover:text-white transition-all" title="Сбросить масштаб">
<span class="material-symbols-outlined text-[14px]">center_focus_strong</span>
1:1
</button>
</div>
<div class="flex items-center gap-2 text-xs text-slate-500 ml-3">
<span class="material-symbols-outlined text-[14px]">touch_app</span>
Нажмите на маркер для просмотра
</div>
</div>
<!-- Body: canvas + defect panel -->
<div class="flex flex-1 min-h-0 overflow-hidden">
<div id="schema-scroll-area" class="flex-1 overflow-auto flex items-start justify-center p-6 min-h-0">
<div id="schema-canvas-wrap">
<canvas id="schema-canvas" class="block shadow-2xl rounded-lg" style="background:#fff;"></canvas>
<div id="schema-markers-layer" class="absolute inset-0" style="pointer-events:none;"></div>
</div>
</div>
<!-- ═══ Defect side panel ═══ -->
<div id="schema-defect-panel" class="w-80 shrink-0 border-l border-white/10 flex flex-col overflow-hidden" style="display:none; background:#0f172a;">
<div class="flex items-center justify-between px-4 py-3 border-b border-white/10 shrink-0">
<h3 id="defect-panel-system" class="text-white text-sm font-bold truncate max-w-[180px]">Дефект</h3>
<button onclick="closeDefectPanel()" class="text-slate-400 hover:text-white transition-colors">
<span class="material-symbols-outlined text-xl">close</span>
</button>
</div>
<!-- Photos -->
<div id="schema-defect-photos" class="shrink-0 p-3 border-b border-white/10 overflow-x-auto"></div>
<!-- Tabs -->
<div class="flex shrink-0 border-b border-white/10 bg-white/5">
<button id="dtab-defects" class="dtab-btn dtab-active flex-1 py-2.5 text-xs font-bold text-primary" onclick="switchDefectTab('defects')">Дефекты</button>
<button id="dtab-causes" class="dtab-btn flex-1 py-2.5 text-xs font-bold text-slate-400" onclick="switchDefectTab('causes')">Причины</button>
<button id="dtab-works" class="dtab-btn flex-1 py-2.5 text-xs font-bold text-slate-400" onclick="switchDefectTab('works')">Рек. работы</button>
</div>
<!-- Tab content -->
<div class="flex-1 overflow-auto p-4 space-y-2">
<div id="dtab-content-defects"></div>
<div id="dtab-content-causes" style="display:none"></div>
<div id="dtab-content-works" style="display:none"></div>
</div>
</div>
</div>
<!-- Loading overlay -->
<div id="schema-loading" class="absolute inset-0 flex items-center justify-center" style="background:rgba(10,15,30,0.92);">
<div class="flex flex-col items-center gap-3">
<div class="w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
<p class="text-slate-400 text-sm">Загрузка схемы...</p>
</div>
</div>
</div>
</section>
<section class="app-page" id="page-dashboard" style="display:none!important">
<style>
.dashboard-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.dashboard-scrollbar::-webkit-scrollbar-track { background: #0f172a; }
.dashboard-scrollbar::-webkit-scrollbar-thumb { background: #475569; border-radius: 9999px; }
.dashboard-scrollbar::-webkit-scrollbar-thumb:hover { background: #0ea5e9; }
.dashboard-table-sticky th { position: sticky; top: 0; z-index: 20; background: #161b2c; box-shadow: 0 1px 0 0 #1e293b; }
</style>
<div class="flex w-full h-full bg-[#0b0f1a] overflow-hidden">
<aside class="w-64 bg-[#161b2c] border-r border-slate-800 flex flex-col shrink-0 z-30">
<div class="p-6 flex items-center gap-3">
<div class="w-10 h-10 bg-primary/20 border border-primary/40 rounded-xl flex items-center justify-center text-primary shadow-lg shadow-primary/10">
<span class="material-symbols-outlined text-[24px]">analytics</span>
</div>
<div class="flex flex-col"><h1 class="text-sm font-bold leading-tight text-white uppercase tracking-wider">ФКР Ленинградской области</h1></div>
</div>
<div class="px-6 pb-2">
<label class="theme-toggle" onclick="toggleTheme()">
<span class="material-symbols-outlined toggle-icon icon-moon">dark_mode</span>
<div class="toggle-track"></div>
<span class="material-symbols-outlined toggle-icon icon-sun">light_mode</span>
</label>
</div>
<nav class="flex-1 px-4 space-y-2 mt-4">
<a href="#results" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">grid_view</span>
<span class="text-sm font-medium">Главная</span></a>
<a href="#registry" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">table_rows</span>
<span class="text-sm font-medium">Реестр МКД</span></a>
<a href="#map" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">map</span>
<span class="text-sm font-medium">Карта</span></a>
<a href="#statuses" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">donut_small</span>
<span class="text-sm font-medium">Статистика</span></a>
</nav>
</aside>
<main class="flex-1 flex flex-col min-w-0 bg-[#0b0f1a] relative overflow-hidden">
<header class="bg-[#0b0f1a]/80 backdrop-blur-md border-b border-slate-800 px-8 py-4 flex items-center justify-between shrink-0 z-40">
<div class="flex items-center gap-8 flex-1">
<div class="relative w-full max-w-xl">
<span class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span class="material-symbols-outlined text-slate-500 !text-xl">filter_list</span>
</span>
<select id="appendix-selector" class="styled-select block w-full pl-12 py-3 border border-slate-800 rounded-2xl bg-slate-900/50 text-slate-200 text-sm font-semibold cursor-pointer">
<option value="">Выберите приложение...</option>
</select>
</div>
</div>
<div class="flex items-center gap-3">
<button id="export-dashboard-csv" class="flex items-center gap-2 bg-primary hover:bg-primary/90 text-white px-5 py-2.5 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-primary/20">
<span class="material-symbols-outlined !text-lg">file_download</span><span>CSV</span>
</button>
<button id="export-dashboard-excel" class="flex items-center gap-2 bg-emerald-600 hover:bg-emerald-500 text-white px-5 py-2.5 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all shadow-lg shadow-emerald-600/20">
<span class="material-symbols-outlined !text-lg">table_view</span><span>Excel</span>
</button>
</div>
</header>
<div class="flex-1 overflow-hidden flex flex-col min-h-0">
<div class="flex items-center justify-between px-8 py-3 shrink-0 border-b border-slate-800/50">
<h2 class="text-sm font-bold text-white tracking-tight uppercase">Сводные данные</h2>
</div>
<div class="flex-1 bg-[#0b0f1a] overflow-hidden flex flex-col relative min-h-0">
<div id="addresses-container" class="overflow-auto dashboard-scrollbar flex-1 flex flex-col bg-[#0b0f1a]">
<div class="flex-1 flex flex-col items-center justify-center text-slate-500">
<span class="material-symbols-outlined text-7xl mb-4 opacity-30">database</span>
<p class="text-sm font-bold uppercase tracking-widest text-slate-400">Нет данных</p>
<p class="text-xs mt-2 text-slate-600">Выберите приложение в списке выше</p>
</div>
</div>
</div>
</div>
<div class="bg-[#0b0f1a] border-t border-slate-800 px-8 py-2 flex items-center justify-between text-[10px] text-slate-500 font-mono tracking-tighter uppercase shrink-0">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span> Система онлайн</span>
</div>
<div class="flex items-center gap-4"><span>© 2026 ФКР Ленинградской области</span></div>
</div>
</main>
</div>
</section>
<section class="app-page" id="page-statuses" style="display:none!important">
<style>
.dash-kpi { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 16px 20px; }
.dash-panel { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 20px; }
.dash-bar-row { display: grid; grid-template-columns: minmax(120px, 28%) 1fr 48px; gap: 12px; align-items: center; margin-bottom: 10px; }
.dash-bar-label { font-size: 11px; color: #94a3b8; font-weight: 600; line-height: 1.3; }
.dash-bar-track { height: 10px; background: rgba(255,255,255,0.06); border-radius: 999px; overflow: hidden; }
.dash-bar-fill { height: 100%; border-radius: 999px; transition: width .4s ease; }
.dash-bar-val { font-size: 12px; font-weight: 700; color: #e2e8f0; text-align: right; }
.dash-cat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.dash-cat-card { border-radius: 12px; padding: 14px; text-align: center; border: 1px solid rgba(255,255,255,0.08); }
.dash-sys-card { border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 14px; background: rgba(255,255,255,0.02); }
.dash-stack { display: flex; height: 8px; border-radius: 999px; overflow: hidden; background: rgba(255,255,255,0.06); margin-top: 8px; }
.dash-stack > span { display: block; height: 100%; }
html.light-theme .dash-kpi, html.light-theme .dash-panel, html.light-theme .dash-sys-card { background: #fff; border-color: #e2e8f0; }
html.light-theme .dash-bar-label { color: #64748b; }
html.light-theme .dash-bar-val { color: #0f172a; }
</style>
<div class="flex w-full h-full bg-[#0b0f1a] overflow-hidden">
<aside class="w-64 bg-[#161b2c] border-r border-slate-800 flex flex-col shrink-0 z-30">
<div class="p-6 flex items-center gap-3">
<div class="w-10 h-10 bg-primary/20 border border-primary/40 rounded-xl flex items-center justify-center text-primary shadow-lg shadow-primary/10">
<span class="material-symbols-outlined text-[24px]">analytics</span>
</div>
<div class="flex flex-col"><h1 class="text-sm font-bold leading-tight text-white uppercase tracking-wider">ФКР Ленинградской области</h1></div>
</div>
<div class="px-6 pb-2">
<label class="theme-toggle" onclick="toggleTheme()">
<span class="material-symbols-outlined toggle-icon icon-moon">dark_mode</span>
<div class="toggle-track"></div>
<span class="material-symbols-outlined toggle-icon icon-sun">light_mode</span>
</label>
</div>
<nav class="flex-1 px-4 space-y-2 mt-4">
<a href="#results" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">grid_view</span>
<span class="text-sm font-medium">Главная</span></a>
<a href="#registry" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">table_rows</span>
<span class="text-sm font-medium">Реестр МКД</span></a>
<a href="#map" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">map</span>
<span class="text-sm font-medium">Карта</span></a>
<a href="#statuses" class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-sm shadow-primary/5 transition-all">
<span class="material-symbols-outlined fill-current">donut_small</span>
<span class="text-sm font-bold">Статистика</span></a>
</nav>
</aside>
<main class="flex-1 flex flex-col min-w-0 bg-[#0b0f1a] relative overflow-hidden">
<div id="statuses-dashboard-root" class="flex-1 overflow-y-auto custom-scrollbar p-6 space-y-6">
<div class="flex flex-col items-center justify-center py-20 text-slate-500">
<div class="w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
<p class="text-sm">Загрузка аналитики...</p>
</div>
</div>
</main>
</div>
</section>
<section class="app-page" id="page-map">
<div class="flex w-full h-full bg-[#0b0f1a] overflow-hidden">
<aside class="w-64 bg-[#161b2c] border-r border-slate-800 flex flex-col shrink-0 z-30">
<div class="p-6 flex items-center gap-3">
<div class="w-10 h-10 bg-primary/20 border border-primary/40 rounded-xl flex items-center justify-center text-primary shadow-lg shadow-primary/10">
<span class="material-symbols-outlined text-[24px]">analytics</span>
</div>
<div class="flex flex-col"><h1 class="text-sm font-bold leading-tight text-white uppercase tracking-wider">ФКР Ленинградской области</h1></div>
</div>
<div class="px-6 pb-2">
<label class="theme-toggle" onclick="toggleTheme()">
<span class="material-symbols-outlined toggle-icon icon-moon">dark_mode</span>
<div class="toggle-track"></div>
<span class="material-symbols-outlined toggle-icon icon-sun">light_mode</span>
</label>
</div>
<nav class="flex-1 px-4 space-y-2 mt-4">
<a href="#results" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">grid_view</span>
<span class="text-sm font-medium">Главная</span></a>
<a href="#registry" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">table_rows</span>
<span class="text-sm font-medium">Реестр МКД</span></a>
<a href="#map" class="flex items-center gap-3 px-4 py-3 rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-sm shadow-primary/5 transition-all">
<span class="material-symbols-outlined fill-current">map</span>
<span class="text-sm font-bold">Карта</span></a>
<a href="#statuses" class="flex items-center gap-3 px-4 py-3 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/50 transition-all group">
<span class="material-symbols-outlined text-slate-500 group-hover:text-primary transition-colors">donut_small</span>
<span class="text-sm font-medium">Статистика</span></a>
</nav>
<!-- Map Filters -->
<div class="px-4 pb-6">
<div class="text-[10px] font-bold text-slate-600 uppercase tracking-widest px-4 mb-4">Фильтры карты</div>
<div class="mb-4 px-4">
<label class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2 block">Износ</label>
<select id="map-filter-wear" class="styled-select w-full bg-slate-900/50 border border-slate-800 rounded-xl py-2.5 px-4 text-sm text-slate-200 outline-none">
<option value="">Все</option>
<option value="cat1">0–30%</option>
<option value="cat2">30–50%</option>
<option value="cat3">50–65%</option>
<option value="cat4">65%+</option>
</select>
</div>
<div class="mb-4 px-4">
<label class="text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2 block">Статус</label>
<select id="map-filter-status" class="styled-select w-full bg-slate-900/50 border border-slate-800 rounded-xl py-2.5 px-4 text-sm text-slate-200 outline-none">
<option value="">Все</option>
<option value="Не обследовано">Не обследовано</option>
<option value="Полевые работы">Полевые работы</option>
<option value="Камеральная обработка">Камеральная обработка</option>
<option value="Передано заказчику">Передано заказчику</option>
<option value="Согласовано">Согласовано</option>
</select>
</div>
<div class="px-4">
<button id="map-reset-filters" class="w-full py-2.5 px-4 bg-primary text-white text-xs font-bold uppercase tracking-wider rounded-xl hover:bg-primary/90 transition-colors shadow-lg shadow-primary/20">
Сбросить фильтры
</button>
</div>
</div>
</aside>
<main class="flex-1 min-w-0 relative" style="position:relative;">
<div id="map-container" style="position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%;"></div>
<div class="absolute bottom-6 left-6 z-[500] bg-[#161b2c]/90 backdrop-blur-md border border-slate-800 rounded-2xl p-4 shadow-2xl">
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-3">Износ МКД</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2"><span class="w-3 h-3 rounded-full bg-emerald-500 shrink-0"></span><span class="text-xs text-slate-300">0–30%</span></div>
<div class="flex items-center gap-2"><span class="w-3 h-3 rounded-full bg-yellow-500 shrink-0"></span><span class="text-xs text-slate-300">30–50%</span></div>
<div class="flex items-center gap-2"><span class="w-3 h-3 rounded-full bg-orange-500 shrink-0"></span><span class="text-xs text-slate-300">50–65%</span></div>
<div class="flex items-center gap-2"><span class="w-3 h-3 rounded-full bg-red-500 shrink-0"></span><span class="text-xs text-slate-300">65%+</span></div>
</div>
</div>
</main>
</div>
</section>
<!-- ══════════════════════════════════════════════ СТРАНИЦА ИИ ══ -->
<section class="app-page" id="page-ai" style="display:none">
<style>
#ai-canvas{position:fixed;inset:0;z-index:0;pointer-events:none;}
#ai-page-wrap{position:relative;z-index:1;display:flex;flex-direction:column;height:100vh;overflow:hidden;width:100%;flex:1;}
.ai-glass{background:rgba(255,255,255,0.08);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,0.18);border-radius:20px;}
.ai-glass-dark{background:rgba(8,18,38,0.75);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,0.10);border-radius:20px;}
.ai-input{width:100%;background:rgba(15,30,65,0.9);border:1px solid rgba(100,160,255,0.35);border-radius:10px;padding:10px 14px;color:#fff;font-size:13px;outline:none;transition:border-color .2s;font-family:inherit;-webkit-appearance:none;appearance:none;}
/* Custom multiselect */
.ms-box{height:40px;min-height:40px;max-height:40px;background:rgba(15,30,65,0.9);border:1px solid rgba(100,160,255,0.35);border-radius:10px;padding:0 32px 0 8px;color:#fff;font-size:13px;cursor:pointer;display:flex;align-items:center;flex-wrap:nowrap;gap:5px;position:relative;transition:border-color .2s;user-select:none;overflow:hidden;}
.ms-box:hover,.ms-box.open{border-color:#4a9eff;box-shadow:0 0 0 3px rgba(74,158,255,0.15);}
.ms-arrow{position:absolute;right:8px;top:50%;transform:translateY(-50%);font-size:18px;color:rgba(180,210,255,0.5);transition:transform .2s;pointer-events:none;}
.ms-box.open .ms-arrow{transform:translateY(-50%) rotate(180deg);}
.ms-placeholder{color:rgba(180,210,255,0.35);font-size:12px;padding:2px 4px;}
.ms-chips{display:flex;align-items:center;gap:5px;flex-wrap:nowrap;overflow:hidden;min-width:0;flex:1;}
.ms-chip{display:inline-flex;align-items:center;gap:4px;background:rgba(0,90,200,0.55);border:1px solid rgba(74,158,255,0.5);border-radius:6px;padding:2px 6px 2px 8px;font-size:11px;color:#d0e8ff;font-weight:600;max-width:120px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:0;}
.ms-chip-x{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:rgba(74,158,255,0.3);border:none;color:#90c8ff;font-size:13px;cursor:pointer;line-height:1;padding:0;flex-shrink:0;}
.ms-chip-x:hover{background:rgba(255,80,80,0.5);color:#fff;}
.ms-dropdown{display:none;position:absolute;z-index:999999;background:rgba(8,20,50,0.97);border:1px solid rgba(74,158,255,0.4);border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,0.7);overflow-y:auto;max-height:220px;min-width:200px;scrollbar-width:thin;scrollbar-color:rgba(74,158,255,0.4) rgba(15,30,65,0.5);}
.ms-dropdown.open{display:block;}
.ms-opt{padding:9px 14px;font-size:12px;color:#c8dcff;cursor:pointer;border-bottom:1px solid rgba(100,160,255,0.07);display:flex;align-items:center;gap:8px;transition:background .15s;}
.ms-opt:hover{background:rgba(0,80,180,0.4);}
.ms-opt.selected{background:rgba(0,100,200,0.4);color:#fff;font-weight:700;}
.ms-opt-check{width:14px;height:14px;border-radius:4px;border:1px solid rgba(74,158,255,0.5);background:transparent;display:inline-block;flex-shrink:0;}
.ms-opt.selected .ms-opt-check{background:#4a9eff;border-color:#4a9eff;}
.ai-input option{background:#0a1a3a;color:#fff;}
.ai-input:focus{border-color:#4a9eff;box-shadow:0 0 0 3px rgba(74,158,255,0.15);}
.ai-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(180,210,255,0.7);margin-bottom:7px;display:block;}
.ai-tip-icon{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;background:rgba(74,158,255,0.25);border:1px solid rgba(74,158,255,0.5);font-size:9px;color:#4a9eff;font-weight:900;cursor:help;margin-left:4px;vertical-align:middle;line-height:1;flex-shrink:0;}
#ai-global-tip{position:fixed;z-index:9999;background:#0a1628;border:1px solid rgba(74,158,255,0.45);border-radius:10px;padding:12px 15px;font-size:11px;font-weight:400;color:rgba(220,235,255,0.95);line-height:1.6;width:270px;box-shadow:0 8px 32px rgba(0,0,0,0.7);pointer-events:none;display:none;}
#ai-table thead th{background:rgba(255,255,255,0.05);color:rgba(180,210,255,0.6);font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;padding:12px 16px;text-align:left;border-bottom:1px solid rgba(255,255,255,0.08);}
#ai-table tbody tr{border-bottom:1px solid rgba(255,255,255,0.05);transition:background .15s;}
#ai-table tbody tr:hover{background:rgba(74,158,255,0.07);}
#ai-table tbody td{padding:10px 16px;font-size:12px;color:rgba(255,255,255,0.88);}
.ai-mkd-cell{display:flex;align-items:flex-start;gap:8px;flex-wrap:wrap;max-width:520px;}
.ai-mkd-addr{flex:1;min-width:100px;line-height:1.35;}
.ai-mkd-actions{display:inline-flex;gap:2px;flex-shrink:0;align-items:flex-start;padding-top:1px;}
.ai-mkd-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border-radius:8px;border:1px solid rgba(74,158,255,0.35);background:rgba(74,158,255,0.12);color:#7dd3fc;cursor:pointer;transition:background .15s,border-color .15s;}
.ai-mkd-btn:hover{background:rgba(74,158,255,0.22);border-color:rgba(120,190,255,0.55);}
.ai-mkd-btn .material-symbols-outlined{font-size:17px;line-height:1;}
.ai-mkd-btn-x{border-color:rgba(248,113,113,0.4);background:rgba(248,113,113,0.1);color:#fca5a5;}
.ai-mkd-btn-x:hover{background:rgba(248,113,113,0.2);border-color:rgba(252,165,165,0.55);}
.ai-mkd-btn-restore{border-color:rgba(74,222,128,0.4);background:rgba(74,222,128,0.1);color:#4ade80;}
.ai-mkd-btn-restore:hover{background:rgba(74,222,128,0.22);border-color:rgba(74,222,128,0.6);}
.ai-mkd-btn-swap{border-color:rgba(251,191,36,0.4);background:rgba(251,191,36,0.1);color:#fbbf24;}
.ai-mkd-btn-swap:hover{background:rgba(251,191,36,0.22);border-color:rgba(251,191,36,0.6);}
.ai-swap-dropdown{position:absolute;z-index:9999;top:calc(100% + 4px);left:0;min-width:320px;max-width:420px;background:rgba(8,18,45,0.98);border:1px solid rgba(100,160,255,0.4);border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,0.7);overflow:hidden;}
.ai-swap-dropdown-header{padding:10px 14px;font-size:11px;font-weight:800;color:rgba(180,210,255,0.6);text-transform:uppercase;letter-spacing:.08em;border-bottom:1px solid rgba(255,255,255,0.07);}
.ai-swap-item{display:flex;align-items:center;gap:10px;padding:8px 14px;cursor:pointer;transition:background .15s;border-bottom:1px solid rgba(255,255,255,0.04);}
.ai-swap-item:last-child{border-bottom:none;}
.ai-swap-item:hover{background:rgba(74,158,255,0.15);}
.ai-swap-item-addr{font-size:12px;color:#d0e8ff;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.ai-swap-item-dist{font-size:11px;color:rgba(180,210,255,0.45);flex-shrink:0;white-space:nowrap;}
.ai-swap-item-id{font-size:10px;font-weight:800;color:#a78bfa;background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.35);border-radius:4px;padding:1px 6px;flex-shrink:0;}
</style>
<canvas id="ai-canvas"></canvas>
<div id="ai-page-wrap">
<main style="flex:1;overflow-y:auto;padding:32px 40px;display:flex;flex-direction:column;gap:22px;align-items:center;justify-content:flex-start;min-height:0;width:100%;box-sizing:border-box;">
<!-- Заголовок -->
<div style="text-align:center;padding:10px 0 6px;width:100%;max-width:960px;">
<div style="width:56px;height:56px;border-radius:16px;background:linear-gradient(135deg,#0056b2,#00aaff);display:flex;align-items:center;justify-content:center;margin:0 auto 14px;box-shadow:0 0 32px rgba(0,170,255,0.5);">
<span class="material-symbols-outlined" style="color:#fff;font-size:28px;">psychology</span>
</div>
<div style="display:flex;align-items:center;justify-content:center;gap:12px;margin:0 0 6px;">
<h1 style="font-size:26px;font-weight:900;color:#fff;margin:0;letter-spacing:-.03em;">Искусственный интеллект</h1>
</div>
<p style="font-size:13px;color:rgba(180,210,255,0.6);margin:0 0 24px;">Анализ данных МКД</p>
<!-- 3 кнопки-таба -->
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
<button onclick="aiSwitchTab('defects')" id="ai-tab-defects"
style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-radius:16px;border:1px solid rgba(74,158,255,0.5);background:rgba(74,158,255,0.15);color:#fff;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .2s;min-width:200px;">
<span class="material-symbols-outlined" style="font-size:22px;color:#4a9eff;">bug_report</span>
<div style="text-align:left;">
<div>Формирование базы</div>
<div style="font-size:10px;font-weight:400;color:rgba(180,210,255,0.6);margin-top:2px;">дефектов по категориям</div>
</div>
</button>
<button onclick="aiSwitchTab('staff')" id="ai-tab-staff"
style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-radius:16px;border:1px solid rgba(74,158,255,0.5);background:rgba(74,158,255,0.15);color:#fff;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .2s;min-width:200px;">
<span class="material-symbols-outlined" style="font-size:22px;color:#4a9eff;">people</span>
<div style="text-align:left;">
<div>Анализ эффективности</div>
<div style="font-size:10px;font-weight:400;color:rgba(180,210,255,0.6);margin-top:2px;">сотрудников и задач</div>
</div>
</button>
<button onclick="aiSwitchTab('schedule')" id="ai-tab-schedule"
style="display:flex;align-items:center;gap:10px;padding:14px 24px;border-radius:16px;border:1px solid rgba(74,158,255,0.5);background:rgba(74,158,255,0.15);color:#fff;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;transition:all .2s;min-width:200px;">
<span class="material-symbols-outlined" style="font-size:22px;color:#4a9eff;">calendar_month</span>
<div style="text-align:left;">
<div>График обследований</div>
<div style="font-size:10px;font-weight:400;color:rgba(180,210,255,0.6);margin-top:2px;">составление плана МКД</div>
</div>
</button>
</div>
</div>
<!-- ══════════ РАЗДЕЛ: БАЗА ДЕФЕКТОВ ══════════ -->
<div id="ai-section-defects" style="display:none;width:100%;flex-direction:column;align-items:center;gap:18px;">
<!-- Фильтры -->
<div class="ai-glass" style="padding:22px 26px;width:100%;max-width:960px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px;">
<span class="material-symbols-outlined" style="color:#4a9eff;font-size:18px;">tune</span>
<span style="font-size:11px;font-weight:800;color:rgba(180,210,255,0.8);text-transform:uppercase;letter-spacing:.1em;">Параметры анализа дефектов</span>
</div>
<div style="display:grid;grid-template-columns:1fr auto;gap:16px;align-items:end;">
<div>
<label class="ai-label">Муниципалитет</label>
<select id="def-mo-filter" class="ai-input"><option value="">Все муниципалитеты</option></select>
</div>
<button onclick="loadDefectsDB()" id="def-load-btn"
style="display:flex;align-items:center;gap:8px;padding:11px 26px;background:linear-gradient(135deg,#0056b2,#0099ff);border:none;border-radius:12px;color:#fff;font-weight:800;font-size:13px;cursor:pointer;box-shadow:0 4px 20px rgba(0,120,255,0.4);font-family:inherit;white-space:nowrap;">
<span class="material-symbols-outlined" style="font-size:18px;">analytics</span>Сформировать базу
</button>
</div>
</div>
<!-- Загрузка -->
<div id="def-loading" style="display:none;align-items:center;justify-content:center;gap:14px;padding:30px;">
<div style="width:36px;height:36px;border:3px solid rgba(0,150,255,0.2);border-top-color:#0099ff;border-radius:50%;animation:spin 1s linear infinite;"></div>
<span style="color:rgba(180,210,255,0.7);font-size:13px;">Анализ данных о дефектах...</span>
</div>
<!-- Сводные карточки -->
<div id="def-stats" style="display:none;grid-template-columns:1fr 1fr;gap:14px;width:100%;">
<div class="ai-glass" style="padding:20px;text-align:center;">
<p style="font-size:10px;font-weight:700;color:rgba(180,210,255,0.5);text-transform:uppercase;letter-spacing:.08em;margin:0 0 6px;">Всего дефектов</p>
<p id="def-stat-total" style="font-size:32px;font-weight:900;color:#fff;margin:0;">—</p>
</div>
<div class="ai-glass" style="padding:20px;text-align:center;">
<p style="font-size:10px;font-weight:700;color:rgba(180,210,255,0.5);text-transform:uppercase;letter-spacing:.08em;margin:0 0 6px;">МКД с дефектами</p>
<p id="def-stat-blds" style="font-size:32px;font-weight:900;color:#4a9eff;margin:0;">—</p>
</div>
</div>
<!-- Дефекты по системам МКД (индивидуальные виды) -->
<div id="def-matrix-block" style="display:none;width:100%;" class="ai-glass-dark">
<div style="padding:14px 18px;border-bottom:1px solid rgba(255,255,255,0.07);">
<span style="font-size:11px;font-weight:800;color:rgba(180,210,255,0.8);text-transform:uppercase;letter-spacing:.1em;">Дефекты по системам МКД</span>
</div>
<div id="def-matrix-body" style="padding:4px 0;"></div>
</div>
<!-- Таблица износа по системам -->
<div id="def-systems-block" style="display:none;width:100%;" class="ai-glass-dark">
<div style="padding:14px 18px;border-bottom:1px solid rgba(255,255,255,0.07);display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:11px;font-weight:800;color:rgba(180,210,255,0.8);text-transform:uppercase;letter-spacing:.1em;">Износ по системам МКД</span>
</div>
<div style="overflow-x:auto;">
<table style="width:100%;min-width:700px;border-collapse:collapse;" id="def-systems-table">
<thead>
<tr>
<th style="padding:10px 16px;text-align:left;font-size:10px;font-weight:700;color:rgba(180,210,255,0.6);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">Система</th>
<th style="padding:10px 12px;text-align:center;font-size:10px;font-weight:700;color:rgba(180,210,255,0.6);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">МКД</th>
<th style="padding:10px 12px;text-align:center;font-size:10px;font-weight:700;color:rgba(56,239,125,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">Кат. 1<br><span style="font-size:9px;opacity:.7;">0–30%</span></th>
<th style="padding:10px 12px;text-align:center;font-size:10px;font-weight:700;color:rgba(251,191,36,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">Кат. 2<br><span style="font-size:9px;opacity:.7;">30–50%</span></th>
<th style="padding:10px 12px;text-align:center;font-size:10px;font-weight:700;color:rgba(251,146,60,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">Кат. 3<br><span style="font-size:9px;opacity:.7;">50–65%</span></th>
<th style="padding:10px 12px;text-align:center;font-size:10px;font-weight:700;color:rgba(248,113,113,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">Кат. 4<br><span style="font-size:9px;opacity:.7;">>65%</span></th>
</tr>
</thead>
<tbody id="def-systems-body"></tbody>
</table>
</div>
</div>
<!-- Топ дефектов -->
<div id="def-top-block" style="display:none;width:100%;" class="ai-glass-dark">
<div style="padding:14px 18px;border-bottom:1px solid rgba(255,255,255,0.07);">
<span style="font-size:11px;font-weight:800;color:rgba(180,210,255,0.8);text-transform:uppercase;letter-spacing:.1em;">Наиболее распространённые дефекты</span>
</div>
<div style="padding:8px 0;" id="def-top-list"></div>
</div>
<!-- Экспорт -->
<div id="def-export" style="display:none;gap:12px;">
<button onclick="exportDefectsExcel()"
style="display:inline-flex;align-items:center;gap:8px;padding:9px 22px;background:rgba(34,197,94,0.15);border:1px solid rgba(34,197,94,0.4);border-radius:10px;color:#4ade80;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit;transition:all .2s;"
onmouseover="this.style.background='rgba(34,197,94,0.25)'" onmouseout="this.style.background='rgba(34,197,94,0.15)'">
<span class="material-symbols-outlined" style="font-size:16px;">table_view</span>Скачать Excel
</button>
</div>
</div>
<!-- ══════════ РАЗДЕЛ: ЭФФЕКТИВНОСТЬ ══════════ -->
<div id="ai-section-staff" style="display:none;width:100%;flex-direction:column;align-items:center;gap:18px;">
<!-- Запуск анализа — единая кнопка -->
<button onclick="loadStaffAnalysis()" id="staff-load-btn"
style="width:100%;max-width:960px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:22px 32px;background:linear-gradient(135deg,#0a2a6e,#0056b2,#0099ff);border:1px solid rgba(74,158,255,0.5);border-radius:16px;color:#fff;cursor:pointer;box-shadow:0 4px 28px rgba(0,120,255,0.35);font-family:inherit;transition:all .2s;"
onmouseover="this.style.boxShadow='0 8px 36px rgba(0,150,255,0.55)';this.style.transform='translateY(-2px)'"
onmouseout="this.style.boxShadow='0 4px 28px rgba(0,120,255,0.35)';this.style.transform=''">
<div style="display:flex;align-items:center;gap:10px;">
<span class="material-symbols-outlined" style="font-size:22px;">insights</span>
<span style="font-size:16px;font-weight:800;letter-spacing:-.01em;">Глобальный анализ эффективности</span>
</div>
<div style="font-size:12px;color:rgba(200,225,255,0.75);font-weight:400;">По всем исполнителям · все муниципалитеты · износ из всех систем МКД · Запустить анализ</div>
</button>
<!-- Загрузка -->
<div id="staff-loading" style="display:none;align-items:center;justify-content:center;gap:14px;padding:30px;">
<div style="width:36px;height:36px;border:3px solid rgba(0,150,255,0.2);border-top-color:#0099ff;border-radius:50%;animation:spin 1s linear infinite;"></div>
<span style="color:rgba(180,210,255,0.7);font-size:13px;">Анализ данных по сотрудникам...</span>
</div>
<!-- Сводные карточки -->
<div id="staff-stats" style="display:none;grid-template-columns:1fr 1fr;gap:16px;width:100%;">
<div class="ai-glass" style="padding:20px;text-align:center;">
<p style="font-size:10px;font-weight:700;color:rgba(180,210,255,0.5);text-transform:uppercase;letter-spacing:.08em;margin:0 0 6px;">Исполнителей</p>
<p id="staff-stat-count" style="font-size:32px;font-weight:900;color:#fff;margin:0;">—</p>
</div>
<div class="ai-glass" style="padding:20px;text-align:center;">
<p style="font-size:10px;font-weight:700;color:rgba(180,210,255,0.5);text-transform:uppercase;letter-spacing:.08em;margin:0 0 6px;">Всего объектов</p>
<p id="staff-stat-blds" style="font-size:32px;font-weight:900;color:#4a9eff;margin:0;">—</p>
</div>
</div>
<!-- Таблица по сотрудникам -->
<div id="staff-table-block" style="display:none;width:100%;" class="ai-glass-dark">
<div style="padding:16px 22px;border-bottom:1px solid rgba(255,255,255,0.07);">
<span style="font-size:13px;font-weight:800;color:rgba(180,210,255,0.9);text-transform:uppercase;letter-spacing:.1em;">Эффективность исполнителей</span>
</div>
<div style="overflow-x:auto;">
<table style="width:100%;" id="staff-table">
<thead>
<tr>
<th style="padding:14px 20px;text-align:left;font-size:12px;font-weight:700;color:rgba(180,210,255,0.7);text-transform:uppercase;letter-spacing:.08em;border-bottom:1px solid rgba(255,255,255,0.08);">Исполнитель</th>
<th style="padding:14px 20px;text-align:center;font-size:12px;font-weight:700;color:rgba(180,210,255,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">Объектов</th>
<th style="padding:14px 20px;text-align:center;font-size:12px;font-weight:700;color:rgba(180,210,255,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">Дефектов</th>
<th style="padding:14px 20px;text-align:left;font-size:12px;font-weight:700;color:rgba(180,210,255,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">Системы МКД</th>
<th style="padding:14px 20px;text-align:center;font-size:12px;font-weight:700;color:rgba(180,210,255,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">
Сложность
<span class="ai-tip-icon" data-tip="<b style='color:#4a9eff;'>Сложность задач</b> определяется по трём критериям:<br>• <b>Износ объектов:</b> >50% — +1 балл, >65% — +2 балла<br>• <b>Дефекты:</b> >20 — +1 балл, >50 — +2 балла<br>• <b>Этажность:</b> >5 этажей — +1 балл<br><br>Итого ≥4 → <b style='color:#f87171;'>Высокая</b>, ≥2 → <b style='color:#fbbf24;'>Средняя</b>, иначе → <b style='color:#4ade80;'>Низкая</b>">?</span>
</th>
<th style="padding:14px 20px;text-align:center;font-size:12px;font-weight:700;color:rgba(180,210,255,0.7);text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.08);">
Рейтинг
<span class="ai-tip-icon" data-tip="<b style='color:#4a9eff;'>Рейтинг эффективности</b> (0–100 баллов):<br>• <b>Качество (до 40):</b> дефектов на объект × 4<br>• <b>Производительность (до 40):</b> объектов ÷ 30 × 40<br>• <b>Сложность (до 20):</b> ср. износ ÷ 80 × 20<br><br>Чем выше балл — тем выше нагрузка и качество обследования.">?</span>
</th>
</tr>
</thead>
<tbody id="staff-tbody"></tbody>
</table>
</div>
</div>
<!-- Экспорт -->
<div id="staff-export" style="display:none;gap:12px;width:100%;justify-content:flex-start;">
<button onclick="exportStaffExcel()"
style="display:inline-flex;align-items:center;gap:8px;padding:9px 22px;background:rgba(34,197,94,0.15);border:1px solid rgba(34,197,94,0.4);border-radius:10px;color:#4ade80;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit;transition:all .2s;" onmouseover="this.style.background='rgba(34,197,94,0.25)'" onmouseout="this.style.background='rgba(34,197,94,0.15)'">
<span class="material-symbols-outlined" style="font-size:16px;">table_view</span>Скачать Excel
</button>
<button onclick="exportStaffPDF()"
style="display:inline-flex;align-items:center;gap:8px;padding:9px 22px;background:rgba(239,68,68,0.15);border:1px solid rgba(239,68,68,0.4);border-radius:10px;color:#f87171;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit;transition:all .2s;" onmouseover="this.style.background='rgba(239,68,68,0.25)'" onmouseout="this.style.background='rgba(239,68,68,0.15)'">
<span class="material-symbols-outlined" style="font-size:16px;">picture_as_pdf</span>Скачать PDF
</button>
</div>
</div>
<!-- ══════════ РАЗДЕЛ: ГРАФИК ══════════ -->
<div id="ai-section-schedule" style="display:none;width:100%;flex-direction:column;align-items:center;gap:18px;">
<!-- Параметры -->
<div class="ai-glass" style="padding:26px 28px;width:100%;max-width:960px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:18px;">
<span class="material-symbols-outlined" style="color:#4a9eff;font-size:18px;">tune</span>
<span style="font-size:11px;font-weight:800;color:rgba(180,210,255,0.8);text-transform:uppercase;letter-spacing:.1em;">Параметры графика</span>
</div>
<!-- Быстрые пресеты -->
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;">
<span style="font-size:10px;font-weight:700;color:rgba(180,210,255,0.5);text-transform:uppercase;letter-spacing:.08em;align-self:center;margin-right:4px;">Быстрый выбор:</span>
<button onclick="aiSetPreset('week')" class="ai-preset-btn" data-preset="week"
style="padding:5px 14px;border-radius:20px;border:1px solid rgba(74,158,255,0.4);background:rgba(74,158,255,0.1);color:rgba(180,210,255,0.9);font-size:11px;font-weight:700;cursor:pointer;transition:all .2s;font-family:inherit;">
1 неделя
</button>
<button onclick="aiSetPreset('2weeks')" class="ai-preset-btn" data-preset="2weeks"
style="padding:5px 14px;border-radius:20px;border:1px solid rgba(74,158,255,0.4);background:rgba(74,158,255,0.1);color:rgba(180,210,255,0.9);font-size:11px;font-weight:700;cursor:pointer;transition:all .2s;font-family:inherit;">
2 недели
</button>
<button onclick="aiSetPreset('month')" class="ai-preset-btn" data-preset="month"
style="padding:5px 14px;border-radius:20px;border:1px solid rgba(0,170,255,0.7);background:rgba(0,120,255,0.25);color:#fff;font-size:11px;font-weight:700;cursor:pointer;transition:all .2s;font-family:inherit;">
1 месяц
</button>
<button onclick="aiSetPreset('quarter')" class="ai-preset-btn" data-preset="quarter"
style="padding:5px 14px;border-radius:20px;border:1px solid rgba(74,158,255,0.4);background:rgba(74,158,255,0.1);color:rgba(180,210,255,0.9);font-size:11px;font-weight:700;cursor:pointer;transition:all .2s;font-family:inherit;">
3 месяца
</button>
<span id="ai-workdays-badge" style="display:none;margin-left:6px;padding:5px 12px;border-radius:20px;background:rgba(0,220,130,0.15);border:1px solid rgba(0,220,130,0.35);color:#4ade80;font-size:11px;font-weight:700;align-self:center;"></span>
</div>
<!-- Поля ввода — строка 1: даты + муниципалитет -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;align-items:end;margin-bottom:14px;">
<div>
<label class="ai-label">Дата начала</label>
<input type="date" id="ai-start-date" class="ai-input" style="color-scheme:dark;" oninput="aiUpdateWorkdays()"/>
</div>
<div>
<label class="ai-label">Дата окончания</label>
<input type="date" id="ai-end-date" class="ai-input" style="color-scheme:dark;" oninput="aiUpdateWorkdays()"/>
</div>
<div>
<label class="ai-label">Муниципалитет</label>
<select id="ai-mo-filter" class="ai-input">
<option value="">Все муниципалитеты</option>
</select>
</div>
</div>
<!-- Строка 2: инженер + камеральщик + кнопка -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr auto;gap:16px;align-items:end;flex-wrap:nowrap;">
<div style="position:relative;">
<label class="ai-label">Инженер</label>
<div class="ms-box" id="ms-executor" onclick="msToggle('ms-executor',event)">
<div class="ms-chips" id="ms-executor-chips"><span class="ms-placeholder">Все инженеры...</span></div>
<span class="material-symbols-outlined ms-arrow">expand_more</span>
</div>
<div class="ms-dropdown" id="ms-executor-drop"></div>
<input type="hidden" id="ai-executor-filter"/>
</div>
<div style="position:relative;">
<label class="ai-label">Камеральщик</label>
<div class="ms-box" id="ms-kamer" onclick="msToggle('ms-kamer',event)">
<div class="ms-chips" id="ms-kamer-chips"><span class="ms-placeholder">Все камеральщики...</span></div>
<span class="material-symbols-outlined ms-arrow">expand_more</span>
</div>
<div class="ms-dropdown" id="ms-kamer-drop"></div>
<input type="hidden" id="ai-kamer-filter"/>
</div>
<div style="position:relative;">
<label class="ai-label">Координатор</label>
<div class="ms-box" id="ms-koord" onclick="msToggle('ms-koord',event)">
<div class="ms-chips" id="ms-koord-chips"><span class="ms-placeholder">Все координаторы...</span></div>
<span class="material-symbols-outlined ms-arrow">expand_more</span>
</div>
<div class="ms-dropdown" id="ms-koord-drop"></div>
<input type="hidden" id="ai-koord-filter"/>
</div>
<div style="position:relative;">
<label class="ai-label">Эксперт</label>
<div class="ms-box" id="ms-expert" onclick="msToggle('ms-expert',event)">
<div class="ms-chips" id="ms-expert-chips"><span class="ms-placeholder">Все эксперты...</span></div>
<span class="material-symbols-outlined ms-arrow">expand_more</span>
</div>
<div class="ms-dropdown" id="ms-expert-drop"></div>
<input type="hidden" id="ai-expert-filter"/>
</div>
<button onclick="generateAISchedule()" id="ai-generate-btn"
style="display:flex;align-items:center;justify-content:center;gap:8px;padding:11px 26px;background:linear-gradient(135deg,#0056b2,#0099ff);border:none;border-radius:12px;color:#fff;font-weight:800;font-size:13px;cursor:pointer;box-shadow:0 4px 24px rgba(0,120,255,0.45);transition:all .2s;white-space:nowrap;font-family:inherit;"
onmouseover="this.style.transform='translateY(-2px)';this.style.boxShadow='0 8px 28px rgba(0,120,255,0.6)'"
onmouseout="this.style.transform='';this.style.boxShadow='0 4px 24px rgba(0,120,255,0.45)'">
<span class="material-symbols-outlined" style="font-size:18px;">auto_awesome</span>Сгенерировать
</button>
</div>
</div>
<!-- Загрузка -->
<div id="ai-loading" style="display:none;flex-direction:column;align-items:center;justify-content:center;padding:50px 0;gap:16px;">
<div style="width:52px;height:52px;border:3px solid rgba(0,150,255,0.25);border-top-color:#0099ff;border-radius:50%;animation:spin 1s linear infinite;"></div>
<p style="color:rgba(180,210,255,0.7);font-size:13px;">ИИ анализирует данные и строит оптимальный маршрут...</p>
</div>
<!-- Резюме -->
<div id="ai-summary-block" style="display:none;width:100%;" class="ai-glass">
<div style="padding:20px 24px;display:flex;align-items:flex-start;gap:14px;">
<div style="width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,#0056b2,#00aaff);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 0 16px rgba(0,170,255,0.35);">
<span class="material-symbols-outlined" style="color:#fff;font-size:20px;">smart_toy</span>
</div>
<div>
<p style="font-size:10px;font-weight:800;color:#4a9eff;text-transform:uppercase;letter-spacing:.1em;margin:0 0 6px;">Анализ ИИ</p>
<p id="ai-summary-text" style="color:rgba(220,235,255,0.9);font-size:13px;line-height:1.7;margin:0;"></p>
</div>
</div>
</div>
<!-- Статистика -->
<div id="ai-stats-block" style="display:none;grid-template-columns:1fr 1fr 1fr;gap:16px;width:100%;">
<div class="ai-glass" style="padding:22px;text-align:center;">
<p style="font-size:10px;font-weight:700;color:rgba(180,210,255,0.5);text-transform:uppercase;letter-spacing:.08em;margin:0 0 8px;">Всего МКД</p>
<p id="ai-stat-total" style="font-size:36px;font-weight:900;color:#fff;margin:0;line-height:1;">—</p>
</div>
<div class="ai-glass" style="padding:22px;text-align:center;">
<p style="font-size:10px;font-weight:700;color:rgba(180,210,255,0.5);text-transform:uppercase;letter-spacing:.08em;margin:0 0 8px;">Рабочих дней</p>
<p id="ai-stat-days" style="font-size:36px;font-weight:900;color:#4a9eff;margin:0;line-height:1;">—</p>
</div>
<div class="ai-glass" style="padding:22px;text-align:center;">
<p style="font-size:10px;font-weight:700;color:rgba(180,210,255,0.5);text-transform:uppercase;letter-spacing:.08em;margin:0 0 8px;">Исполнителей</p>
<p id="ai-stat-exec" style="font-size:36px;font-weight:900;color:#00ddaa;margin:0;line-height:1;">—</p>
</div>
</div>
<!-- Экспорт -->
<div id="ai-export-block" style="display:none;gap:12px;width:100%;flex-wrap:wrap;align-items:center;">
<button onclick="exportAIExcel()" style="display:inline-flex;align-items:center;gap:8px;padding:9px 22px;background:rgba(34,197,94,0.15);border:1px solid rgba(34,197,94,0.4);border-radius:10px;color:#4ade80;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit;transition:all .2s;" onmouseover="this.style.background='rgba(34,197,94,0.25)'" onmouseout="this.style.background='rgba(34,197,94,0.15)'">
<span class="material-symbols-outlined" style="font-size:16px;">table_view</span>Скачать Excel
</button>
<button onclick="exportAIPDF()" style="display:inline-flex;align-items:center;gap:8px;padding:9px 22px;background:rgba(239,68,68,0.15);border:1px solid rgba(239,68,68,0.4);border-radius:10px;color:#f87171;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit;transition:all .2s;" onmouseover="this.style.background='rgba(239,68,68,0.25)'" onmouseout="this.style.background='rgba(239,68,68,0.15)'">
<span class="material-symbols-outlined" style="font-size:16px;">picture_as_pdf</span>Скачать PDF
</button>
<button id="planfix-btn" onclick="sendToPlanfix()" style="display:inline-flex;align-items:center;gap:8px;padding:9px 22px;background:linear-gradient(135deg,rgba(99,60,180,0.25),rgba(139,92,246,0.25));border:1px solid rgba(139,92,246,0.6);border-radius:10px;color:#a78bfa;font-weight:700;font-size:13px;cursor:pointer;font-family:inherit;transition:all .2s;" onmouseover="this.style.background='linear-gradient(135deg,rgba(99,60,180,0.4),rgba(139,92,246,0.4))'" onmouseout="this.style.background='linear-gradient(135deg,rgba(99,60,180,0.25),rgba(139,92,246,0.25))'">
<span class="material-symbols-outlined" style="font-size:16px;">send</span>Запись в ИнжинирингПРО
</button>
</div>
<!-- Таблица -->
<div id="ai-schedule-block" style="display:none;width:100%;" class="ai-glass-dark">
<div style="padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.07);display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:11px;font-weight:800;color:rgba(180,210,255,0.8);text-transform:uppercase;letter-spacing:.1em;">График обследований</span>
<input id="ai-search" oninput="filterAITable()" placeholder="Поиск по адресу или исполнителю..."
style="background:rgba(15,30,65,0.9);border:1px solid rgba(100,160,255,0.3);border-radius:8px;padding:7px 14px;font-size:12px;color:#fff;outline:none;width:240px;font-family:inherit;" />
</div>
<div style="overflow-x:auto;">
<table style="width:100%;" id="ai-table">
<thead><tr>
<th>День</th><th>Дата</th><th>Исполнитель</th><th>Камеральщик</th><th>Координатор</th><th>Эксперт</th><th>МКД 1 <span style="color:rgba(100,180,255,0.6);font-weight:400;">9:00</span></th><th>МКД 2 <span style="color:rgba(100,180,255,0.6);font-weight:400;">13:00</span></th><th>Расст. км</th>
</tr></thead>
<tbody id="ai-table-body"></tbody>
</table>
</div>
</div>
</div><!-- /ai-section-schedule -->
</main>
</div>
<script>
(function(){
const canvas = document.getElementById('ai-canvas');
const ctx = canvas.getContext('2d');
let W, H, particles = [], animId;
function resize(){W=canvas.width=window.innerWidth;H=canvas.height=window.innerHeight;}
function rand(a,b){return a+Math.random()*(b-a);}
function spawn(){return{x:W/2,y:H/2,angle:Math.random()*Math.PI*2,speed:rand(0.5,3.5),size:rand(1,3.5),alpha:rand(0.4,1),type:Math.random()<0.5?'line':(Math.random()<0.5?'dot':'tri'),len:rand(10,60),life:0,maxLife:rand(120,280)};}
function drawTri(x,y,size,alpha){ctx.save();ctx.globalAlpha=alpha;ctx.strokeStyle='#fff';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(x,y-size);ctx.lineTo(x+size*0.87,y+size*0.5);ctx.lineTo(x-size*0.87,y+size*0.5);ctx.closePath();ctx.stroke();ctx.restore();}
function tick(){
ctx.clearRect(0,0,W,H);
const grad=ctx.createRadialGradient(W/2,H/2,0,W/2,H/2,Math.max(W,H)*0.7);
grad.addColorStop(0,'rgba(0,30,80,1)');grad.addColorStop(0.5,'rgba(0,10,40,1)');grad.addColorStop(1,'rgba(0,0,15,1)');
ctx.fillStyle=grad;ctx.fillRect(0,0,W,H);
if(particles.length<180)particles.push(spawn());
particles=particles.filter(p=>p.life<p.maxLife);
particles.forEach(p=>{
p.life++;const progress=p.life/p.maxLife;
const ease=progress<0.1?progress/0.1:1-(progress-0.1)/0.9;
const dist=p.speed*p.life*(1+progress*2);
const px=p.x+Math.cos(p.angle)*dist,py=p.y+Math.sin(p.angle)*dist;
const alpha=p.alpha*ease;
ctx.save();ctx.globalAlpha=alpha;
if(p.type==='line'){
const ex=px+Math.cos(p.angle)*p.len*progress,ey=py+Math.sin(p.angle)*p.len*progress;
const g=ctx.createLinearGradient(px,py,ex,ey);g.addColorStop(0,'rgba(255,255,255,0)');g.addColorStop(1,'rgba(255,255,255,1)');
ctx.strokeStyle=g;ctx.lineWidth=p.size*0.6;ctx.beginPath();ctx.moveTo(px,py);ctx.lineTo(ex,ey);ctx.stroke();
}else if(p.type==='dot'){
ctx.fillStyle='#fff';ctx.beginPath();ctx.arc(px,py,p.size,0,Math.PI*2);ctx.fill();
const glow=ctx.createRadialGradient(px,py,0,px,py,p.size*5);glow.addColorStop(0,`rgba(150,200,255,${alpha*0.4})`);glow.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=glow;ctx.beginPath();ctx.arc(px,py,p.size*5,0,Math.PI*2);ctx.fill();
}else{ctx.restore();drawTri(px,py,p.size*3,alpha*0.7);ctx.save();}
ctx.restore();
});
animId=requestAnimationFrame(tick);
}
const section=document.getElementById('page-ai');
const observer=new MutationObserver(()=>{
const visible=section.classList.contains('is-active')||section.style.display!=='none';
if(visible&&!animId){resize();tick();}
if(!visible&&animId){cancelAnimationFrame(animId);animId=null;}
});
observer.observe(section,{attributes:true,attributeFilter:['class','style']});
window.addEventListener('resize',()=>{resize();});
})();
</script>
</section>
<!-- ══════════════════════════════════════════════════════════════ -->
<section class="app-page" id="page-results">
<style>
.glass {
background: rgba(18, 25, 33, 0.65);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.hero-gradient { background: linear-gradient(to right, rgba(18, 24, 32, 0.4) 0%, rgba(18, 24, 32, 0.1) 50%, rgba(18, 24, 32, 0) 100%); }
.hero-overlay-bottom { background: linear-gradient(to top, rgba(18, 24, 32, 0.6) 0%, rgba(18, 24, 32, 0) 100%); }
</style>
<div class="relative h-screen w-full overflow-hidden bg-background-dark text-white">
<div class="absolute inset-0 z-0">
<img id="hero-bg-dark" alt="Ленинградская область — городской пейзаж" class="h-full w-full object-cover" src="/background.jpg" style="transition: opacity 0.4s ease; object-position: center 42%;" onerror="this.onerror=null;this.src='/background-light.jpg'"/>
<img id="hero-bg-light" class="h-full w-full object-cover absolute inset-0" src="/background-light.jpg" style="opacity:0;transition:opacity 0.7s ease;object-position:center 42%;" />
<div class="absolute inset-0 hero-gradient"></div>
<div class="absolute inset-0 hero-overlay-bottom h-1/3 bottom-0 top-auto"></div>
</div>
<div class="relative z-10 flex flex-col h-full px-8 lg:px-16 py-8">
<header class="flex items-center justify-between w-full">
<div class="flex items-center gap-3">
<div class="size-10 bg-primary flex items-center justify-center rounded-xl shadow-lg shadow-primary/20">
<span class="material-symbols-outlined text-white text-2xl">domain</span>
</div>
<div class="flex flex-col">
<span class="text-xs uppercase tracking-widest text-primary font-bold">Ленинградская область</span>
<span class="text-sm font-medium opacity-80">Капитальный ремонт</span>
</div>
<button onclick="toggleTheme()" class="theme-btn-main ml-4" title="Переключить тему">
<span class="material-symbols-outlined !text-lg theme-icon-main">dark_mode</span>
</button>
</div>
<nav class="hidden md:flex items-center gap-2 glass p-1.5 rounded-full">
<a class="px-6 py-2 rounded-full text-sm font-medium text-white hover:bg-white/20 transition-colors" href="#results">Главная</a>
<a class="px-6 py-2 rounded-full text-sm font-medium text-white hover:bg-white/20 transition-colors" href="#registry">Реестр МКД</a>
<a class="px-6 py-2 rounded-full text-sm font-medium text-white hover:bg-white/20 transition-colors" href="#map">Карта</a>
<a class="px-6 py-2 rounded-full text-sm font-medium text-white hover:bg-white/20 transition-colors" href="#statuses">Статистика</a>
</nav>
</header>
<main class="flex-1 flex flex-col justify-center max-w-4xl">
<div class="space-y-6">
<div class="inline-flex items-center gap-2 px-4 py-1.5 glass rounded-full border border-primary/20">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
<span class="text-xs font-semibold tracking-wide uppercase opacity-90">Данные обновляются в режиме реального времени</span>
</div>
<h1 class="text-2xl lg:text-4xl font-bold leading-[1.2] tracking-tight text-white drop-shadow-2xl uppercase">
Некоммерческая организация<br/>
Фонд капитального ремонта<br/>
многоквартирных домов<br/>
<span class="text-primary">Ленинградской области</span>
</h1>
</div>
</main>
<footer class="mt-auto">
<div class="glass p-4 lg:p-6 rounded-3xl flex flex-col lg:flex-row items-center gap-8 border border-white/10">
<div class="flex flex-wrap items-center gap-8 lg:gap-12 shrink-0">
<div class="flex flex-col">
<span class="text-white/50 text-xs font-medium uppercase tracking-wider mb-1">Объектов на 2026г</span>
<div class="flex items-baseline gap-2">
<span class="text-3xl font-bold text-white tracking-tight" id="main-total-objects">—</span>
</div>
</div>
</div>
<div class="hidden lg:block h-12 w-px bg-white/10"></div>
<div class="flex-1 w-full relative group">
<div class="absolute inset-y-0 left-5 flex items-center pointer-events-none">
<span class="material-symbols-outlined text-primary group-focus-within:text-white transition-colors">search</span>
</div>
<input id="main-search-input-new" class="w-full bg-white/5 border border-white/10 rounded-full py-4 pl-14 pr-24 text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:bg-white/10 transition-all text-sm lg:text-base" placeholder="Поиск по адресу, ID, статусу, исполнителю..." type="text" autocomplete="off"/>
<div id="smart-search-drop" style="display:none;position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:9999;background:rgba(8,16,40,0.98);border:1px solid rgba(74,158,255,0.3);border-radius:16px;overflow:hidden;box-shadow:0 16px 48px rgba(0,0,0,0.7);backdrop-filter:blur(20px);"></div>
<button id="main-search-clear" class="absolute inset-y-0 right-28 flex items-center opacity-0 pointer-events-none transition-opacity" type="button">
<span class="material-symbols-outlined text-white/40 hover:text-white !text-xl cursor-pointer">close</span>
</button>
<div class="absolute inset-y-2 right-2 hidden md:block">
<button type="button" id="main-search-btn-new" class="h-full px-6 bg-white/10 hover:bg-white/20 text-white text-xs font-bold rounded-full transition-colors uppercase tracking-widest border border-white/5" onclick="if(window.runHeroSearch){window.runHeroSearch();} return false;">
Найти
</button>
</div>
</div>
<button type="button" id="main-search-btn-mobile" class="md:hidden w-full shrink-0 py-3.5 rounded-2xl bg-primary hover:brightness-110 text-white text-xs font-bold uppercase tracking-widest border border-primary/40 shadow-lg shadow-primary/20 mt-3" onclick="if(window.runHeroSearch){window.runHeroSearch();} return false;">Найти</button>
</div>
<div class="mt-4 text-center text-[10px] uppercase tracking-widest text-white/30 font-bold">
<p>© 2026 Ленинградская область. Фонд капитального ремонта МКД.</p>
</div>
</footer>
</div>
</div>
</section>
<script>
// ===== Theme Toggle =====
function toggleTheme() { /* тема отключена */ }
function updateThemeIcons(isLight) {
document.querySelectorAll('.theme-icon-main').forEach(icon => {
icon.textContent = isLight ? 'light_mode' : 'dark_mode';
});
}
function updateHeroBg(isLight) {
const dark = document.getElementById("hero-bg-dark");
const light = document.getElementById("hero-bg-light");
if (dark) dark.style.opacity = isLight ? "0" : "1";
if (light) light.style.opacity = isLight ? "1" : "0";
}
(function initTheme() {
// Белая тема отключена — всегда тёмная
localStorage.removeItem('theme');
document.documentElement.classList.remove('light-theme');
})();
// Global state
let addressesData = [];
let defectsSummary = {};
let systemPhotoLinks = {};
let appendixIndex = [];
let currentAppendixData = null;
let map = null;
let filteredData = [];
let currentPage = 1;
let rowsPerPage = 25;
let selectedIds = new Set();
let currentSortCol = 'ID';
let currentSortDir = 'asc';
let currentFilters = { search: '', status: '' };
let _buildingDocs = {}; // { schema_url, defect_url }
const STATUS_FILTER_OPTIONS = [
'Не обследовано',
'Полевые работы',
'Камеральная обработка',
'Передано заказчику',
'Согласовано',
];
const STATUS_COLORS = {
'Не обследовано': { bg: 'rgba(100,116,139,0.18)', color: '#94a3b8', border: 'rgba(100,116,139,0.4)' },
'Полевые работы': { bg: 'rgba(14,165,233,0.18)', color: '#38bdf8', border: 'rgba(14,165,233,0.4)' },
'Камеральная обработка': { bg: 'rgba(139,92,246,0.18)', color: '#a78bfa', border: 'rgba(139,92,246,0.4)' },
'Передано заказчику': { bg: 'rgba(245,158,11,0.18)', color: '#fbbf24', border: 'rgba(245,158,11,0.4)' },
'Согласовано': { bg: 'rgba(34,197,94,0.18)', color: '#4ade80', border: 'rgba(34,197,94,0.4)' },
};
function normalizeBuildingStatus(status) {
const s = (status || '').trim();
return s || 'Не обследовано';
}
function statusBadgeHtml(status) {
const label = normalizeBuildingStatus(status);
const c = STATUS_COLORS[label] || STATUS_COLORS['Не обследовано'];
return `<span class="inline-flex items-center justify-center px-2.5 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wide whitespace-nowrap border" style="background:${c.bg};color:${c.color};border-color:${c.border}">${label}</span>`;
}
function applyStatusBadgeEl(el, status) {
if (!el) return;
const label = normalizeBuildingStatus(status);
const c = STATUS_COLORS[label] || STATUS_COLORS['Не обследовано'];
el.textContent = label;
el.classList.remove('hidden');
el.classList.add('inline-flex');
el.style.background = c.bg;
el.style.color = c.color;
el.style.borderColor = c.border;
}
// Открытие PDF документов через прокси бэкенда (обходит ограничения YD)
window.showBuildingOnMap = function() {
const building = window._currentBuilding || App?.currentBuilding;
if (!building) return;
const id = String(building.ID || building.nomer || '');
// Сбрасываем фильтр статуса чтобы точно показать здание на карте
mapFilters.status = '';
const mapStatusSel = document.getElementById('map-filter-status');
if (mapStatusSel) mapStatusSel.value = '';
// Переходим через hash — активирует IIFE навигацию и инициализирует карту
location.hash = '#map';
const tryOpen = (attempts) => {
if (!map) { if (attempts > 0) setTimeout(() => tryOpen(attempts - 1), 400); return; }
_loadMapCoords().then(coords => {
const info = coords[id];
if (!info) return;
const address = info.address || `МКД #${id}`;
const mo = info.mo || '';
const wearInfo = _getWearPreset(info.wear);
// Центрируем с zoom 16 — маркер точно не будет в кластере
map.setCenter([info.lat, info.lng], 16, { duration: 400 });
// Открываем балун напрямую по координатам — не зависит от кластера
setTimeout(() => {
map.balloon.open([info.lat, info.lng], {
contentHeader: `<div style="font-weight:700;font-size:13px;line-height:1.3;margin-bottom:4px;">${address}</div>`,
contentBody: `
<div style="font-size:11px;color:#64748b;margin-bottom:6px;">${mo}</div>
${_buildSystemsBalloonHtml(info.systems)}`,
contentFooter: `<a href="#card/${id}" onclick="openCard('${id}')" style="display:inline-block;margin-top:8px;padding:8px 20px;background:#0ea5e9;color:#fff;border-radius:8px;text-decoration:none;font-size:12px;font-weight:700;width:100%;text-align:center;box-sizing:border-box;">Открыть карточку</a>`,
}, { closeButton: true, autoPan: true });
}, 700);
});
};
setTimeout(() => tryOpen(20), 800);
};
// Загрузка и отображение схем МКД
let _currentSchemasBuilding = null;
function loadBuildingSchemas(building) {
_currentSchemasBuilding = building;
if (window._schemaViewerState) window._schemaViewerState.markersCache = null;
const section = document.getElementById('card-schemas-section');
const gallery = document.getElementById('card-schemas-gallery');
const countEl = document.getElementById('schemas-gallery-count');
if (!section || !gallery) return;
section.style.display = 'none';
gallery.innerHTML = '';
if (!building['Адрес']) return;
const ydBase = `disk:/${building.yadisk_root || 'Ленинградская область'}/${building['Адрес']}/Дефектная ведомость`;
fetch(`/api/yadisk/list?path=${encodeURIComponent(ydBase)}&limit=100&all_files=true`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const items = data._embedded?.items || [];
// Только файлы вида "Схема N.pdf"
const schemas = items.filter(i =>
i.type === 'file' && /\.pdf$/i.test(i.name || '') && /^Схема\s*\d+/i.test(i.name || '')
);
if (!schemas.length) return;
section.style.display = '';
if (countEl) {
const n = schemas.length;
countEl.textContent = n + (n === 1 ? ' схема' : n < 5 ? ' схемы' : ' схем');
}
// Предзагружаем маркеры в фоне пока пользователь смотрит на карточку
if (building.ID && window._schemaViewerState) {
const _st = window._schemaViewerState;
if (!_st.markersCache || _st.markersCache._bid !== String(building.ID)) {
fetch('/api/buildings/' + building.ID + '/schema-markers')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) {
_st.markersCache = data.markers || [];
_st.markersCache._bid = String(building.ID);
}
}).catch(() => {});
}
}
gallery.innerHTML = '';
schemas.forEach(f => {
const ydPath = ydBase + '/' + f.name;
const schemeNum = parseInt(f.name.match(/\d+/)?.[0] || '1');
const label = f.name.replace(/\.pdf$/i, '');
const card = document.createElement('div');
card.className = 'schema-card';
card.title = label;
card.onclick = () => openSchemaViewer(ydPath, schemeNum, building.ID, building['Адрес'], building.yadisk_root);
// Thumbnail area — placeholder icon while loading
const thumbWrap = document.createElement('div');
thumbWrap.className = 'schema-thumb-wrap';
const placeholder = document.createElement('div');
placeholder.className = 'schema-thumb-placeholder';
placeholder.innerHTML = '<span class="material-symbols-outlined schema-card-icon">map</span>';
thumbWrap.appendChild(placeholder);
const nameEl = document.createElement('div');
nameEl.className = 'schema-card-name';
nameEl.textContent = label;
card.appendChild(thumbWrap);
card.appendChild(nameEl);
gallery.appendChild(card);
// Render PDF thumbnail async
_renderSchemaThumb(thumbWrap, '/api/yadisk/serve?path=' + encodeURIComponent(ydPath));
});
})
.catch(() => {});
}
window.toggleSoglPanel = async function() {
const panel = document.getElementById('sogl-panel');
const btn = document.getElementById('open-sogl-files');
if (!panel) return;
if (panel.style.display !== 'none') {
panel.style.display = 'none';
return;
}
panel.style.display = '';
const basePath = btn?._soglBase;
if (!basePath || btn?._soglLoaded) return;
const content = document.getElementById('sogl-panel-content');
content.innerHTML = '<div style="color:rgba(180,210,255,0.5);font-size:12px;padding:8px;">Загрузка...</div>';
const folders = [
{ name: 'Запросить исходные данные', icon: 'send' },
{ name: 'Подготовить графическую часть', icon: 'draw' },
{ name: 'Уведомить УК о дате обследования', icon: 'notifications' },
];
const cols = await Promise.all(folders.map(async (f) => {
const folderPath = basePath + '/' + f.name;
try {
const resp = await fetch('/api/yadisk/list?path=' + encodeURIComponent(folderPath) + '&limit=50&all_files=true');
if (!resp.ok) throw new Error();
const data = await resp.json();
const files = (data._embedded?.items || []).filter(i => i.type === 'file');
const filesHtml = files.length === 0
? '<div style="color:rgba(180,210,255,0.3);font-size:11px;padding:4px 0;">Файлов нет</div>'
: files.map(fi => {
const fp = fi.path || '';
const isPdf = /\.pdf$/i.test(fi.name);
const isImg = /\.(jpg|jpeg|png|gif|webp)$/i.test(fi.name);
let onclick = '';
const fpE = fp.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
const fnE = fi.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
if (isPdf || /\.docx?$/i.test(fi.name) || /\.xlsx?$/i.test(fi.name)) {
onclick = `onclick="window.openDocModal('${fpE}','${fnE}')"`;
} else if (isImg) {
onclick = `onclick="window.open('/api/yadisk/serve?path=${encodeURIComponent(fp)}','_blank')"`;
} else {
onclick = `onclick="window.open('/api/yadisk/serve?path=${encodeURIComponent(fp)}','_blank')"`;
}
const isDocx = /\.docx?$/i.test(fi.name);
const isXlsx2 = /\.xlsx?$/i.test(fi.name);
const icon = isPdf ? 'picture_as_pdf' : isImg ? 'image' : isDocx ? 'description' : isXlsx2 ? 'table_view' : 'insert_drive_file';
const iconColor = isPdf ? '#f87171' : isImg ? '#7dd3fc' : isDocx ? '#60a5fa' : isXlsx2 ? '#4ade80' : 'rgba(148,163,184,0.7)';
return `<button type="button" ${onclick} style="display:flex;align-items:center;gap:6px;width:100%;padding:5px 6px;background:transparent;border:none;border-radius:6px;color:#cbd5e1;font-size:11px;cursor:pointer;text-align:left;transition:background .15s;" onmouseover="this.style.background='rgba(255,255,255,0.07)'" onmouseout="this.style.background='transparent'">
<span class="material-symbols-outlined" style="font-size:15px;color:${iconColor};flex-shrink:0;">${icon}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${fi.name}</span>
</button>`;
}).join('');
return `<div style="flex:1;min-width:0;background:rgba(251,191,36,0.06);border:1px solid rgba(251,191,36,0.2);border-radius:12px;padding:12px;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<span class="material-symbols-outlined" style="font-size:15px;color:#fbbf24;">${f.icon}</span>
<span style="font-size:11px;font-weight:700;color:#fbbf24;">${f.name}</span>
<span style="font-size:10px;color:rgba(180,210,255,0.4);margin-left:auto;">${files.length} файл(ов)</span>
</div>
${filesHtml}
</div>`;
} catch(e) {
return `<div style="flex:1;min-width:0;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:12px;">
<div style="display:flex;align-items:center;gap:6px;">
<span class="material-symbols-outlined" style="font-size:15px;color:rgba(148,163,184,0.35);">${f.icon}</span>
<span style="font-size:11px;color:rgba(180,210,255,0.35);">${f.name}</span>
<span style="font-size:10px;color:rgba(248,113,113,0.6);margin-left:auto;">Не найдено</span>
</div>
</div>`;
}
}));
content.innerHTML = cols.join('');
if (btn) btn._soglLoaded = true;
};
// ── Универсальный просмотр документов (PDF / Word / Excel) ──────
let _docWb = null;
let _docFilePath = null;
function _docShowOnly(id) {
['docpreview-loading','docpreview-pdf','docpreview-word','docpreview-excel'].forEach(function(sid) {
const el = document.getElementById(sid);
if (el) el.style.display = sid === id ? (sid === 'docpreview-loading' ? 'flex' : '') : 'none';
});
}
window.openDocModal = async function(filePath, fileName) {
_docFilePath = filePath;
_docWb = null;
const modal = document.getElementById('docpreview-modal');
const title = document.getElementById('docpreview-title');
const icon = document.getElementById('docpreview-icon');
const tabs = document.getElementById('docpreview-tabs');
const dlBtn = document.getElementById('docpreview-download');
modal.style.display = '';
_docShowOnly('docpreview-loading');
tabs.innerHTML = '';
title.textContent = fileName;
dlBtn.onclick = function() {
window.open('/api/yadisk/serve?path=' + encodeURIComponent(filePath), '_blank');
};
const isPdf = /\.pdf$/i.test(fileName);
const isDocx = /\.docx?$/i.test(fileName);
const isXlsx = /\.xlsx?$/i.test(fileName);
// Иконка и цвет
if (isPdf) { icon.textContent = 'picture_as_pdf'; icon.style.color = '#f87171'; }
else if (isDocx) { icon.textContent = 'description'; icon.style.color = '#60a5fa'; }
else if (isXlsx) { icon.textContent = 'table_view'; icon.style.color = '#4ade80'; }
else { icon.textContent = 'insert_drive_file'; icon.style.color = '#94a3b8'; }
try {
if (isPdf) {
// PDF — показываем в iframe через наш прокси
const iframe = document.getElementById('docpreview-pdf');
iframe.src = '/api/yadisk/serve?path=' + encodeURIComponent(filePath);
_docShowOnly('docpreview-pdf');
} else if (isDocx) {
// Word — конвертируем через mammoth.js
const resp = await fetch('/api/yadisk/serve?path=' + encodeURIComponent(filePath));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const buf = await resp.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer: buf });
const wordDiv = document.getElementById('docpreview-word');
wordDiv.innerHTML = result.value || '<p style="color:#999;">Документ пуст или не удалось конвертировать</p>';
_docShowOnly('docpreview-word');
} else if (isXlsx) {
// Excel — SheetJS
const resp = await fetch('/api/yadisk/serve?path=' + encodeURIComponent(filePath));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const buf = await resp.arrayBuffer();
_docWb = XLSX.read(buf, { type: 'array' });
_docShowOnly('docpreview-excel');
// Табы листов
_docWb.SheetNames.forEach(function(name, idx) {
const tab = document.createElement('button');
tab.className = 'doc-tab' + (idx === 0 ? ' active' : '');
tab.textContent = name;
tab.onclick = function() {
document.querySelectorAll('.doc-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
_docRenderSheet(name);
};
tabs.appendChild(tab);
});
_docRenderSheet(_docWb.SheetNames[0]);
} else {
// Неизвестный формат — просто скачиваем
window.open('/api/yadisk/serve?path=' + encodeURIComponent(filePath), '_blank');
closeDocModal();
}
} catch(e) {
document.getElementById('docpreview-loading').innerHTML =
'<span class="material-symbols-outlined" style="font-size:44px;color:#f87171;">error</span>' +
'<span style="font-size:13px;color:#f87171;">Ошибка загрузки: ' + e.message + '</span>';
_docShowOnly('docpreview-loading');
}
};
function _docRenderSheet(sheetName) {
if (!_docWb) return;
const ws = _docWb.Sheets[sheetName];
const html = XLSX.utils.sheet_to_html(ws, { editable: false });
const wrap = document.getElementById('docpreview-excel-wrap');
wrap.innerHTML = html;
// Первую строку делаем заголовком
const firstRow = wrap.querySelector('tr');
if (firstRow) {
firstRow.querySelectorAll('td').forEach(function(td) {
const th = document.createElement('th');
th.innerHTML = td.innerHTML;
firstRow.replaceChild(th, td);
});
}
}
window.closeDocModal = function() {
const modal = document.getElementById('docpreview-modal');
if (modal) modal.style.display = 'none';
const iframe = document.getElementById('docpreview-pdf');
if (iframe) iframe.src = '';
_docWb = null;
_docFilePath = null;
};
// Обратная совместимость со старыми вызовами openXlsxModal
window.openXlsxModal = window.openDocModal;
window.closeXlsxModal = window.closeDocModal;
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDocModal();
});
window.openBuildingDoc = function(type) {
const path = type === 'schema' ? _buildingDocs.schema_path : _buildingDocs.defect_path;
if (!path) return;
const address = window._currentBuilding?.['Адрес'] || document.getElementById('card-address-header')?.textContent || '';
const title = type === 'schema'
? 'Схема по адресу: ' + address
: 'Дефектная ведомость по адресу: ' + address;
const rotate = type === 'defect' ? 90 : 0;
const url = '/api/yadisk/preview?path=' + encodeURIComponent(path)
+ '&title=' + encodeURIComponent(title)
+ (rotate ? '&rotate=' + rotate : '');
window.open(url, '_blank');
};
// Load data from FastAPI backend
async function loadData() {
const [buildingsResp, indexData] = await Promise.all([
fetch('/api/buildings?limit=5000').then(r => {
if (!r.ok) throw new Error(`Сервер вернул ошибку ${r.status}`);
return r.json();
}),
fetch('/api/summary/index').then(r => r.json()).catch(() => [])
]);
const buildingsArr = buildingsResp;
addressesData = (Array.isArray(buildingsArr) ? buildingsArr : []).map(item => ({
...item,
latitude: item.lat,
longitude: item.lng,
}));
appendixIndex = Array.isArray(indexData) ? indexData : [];
systemPhotoLinks = {};
addressesData.forEach(item => {
if (item.systems && item.systems.length > 0) {
const links = {};
item.systems.forEach(sys => {
if (sys.yadisk_path) links[sys.key] = sys.yadisk_path;
});
systemPhotoLinks[String(item.ID)] = links;
}
});
fetch('/api/defects').then(r => r.json()).then(d => {
defectsSummary = d.bySystem || {};
}).catch(() => { defectsSummary = {}; });
initializeRegistry();
initializeMOFilter();
initializeAppendixSelector();
initializeMap();
initializeEventListeners();
updateMainPageStats();
if (typeof _isStatisticsPageActive === 'function' && _isStatisticsPageActive()) {
initStatusesDashboard();
}
}
// Wrapper with auto-retry and user-visible error
async function loadDataWithRetry(attempt) {
attempt = attempt || 1;
const maxAttempts = 3;
// Show loading state
const tbody = document.getElementById('registry-tbody');
const paginationInfo = document.getElementById('pagination-info');
if (tbody && attempt > 1) {
tbody.innerHTML = `<tr><td colspan="9" style="padding:48px;text-align:center;">
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;">
<div style="width:36px;height:36px;border:3px solid #1e3a5f;border-top-color:#0ea5e9;border-radius:50%;animation:spin 0.8s linear infinite;"></div>
<span style="color:#475569;font-size:12px;text-transform:uppercase;letter-spacing:.08em;font-weight:600;">Попытка ${attempt} из ${maxAttempts}...</span>
</div>
</td></tr>`;
}
try {
await loadData();
} catch (error) {
console.error(`Ошибка загрузки (попытка ${attempt}):`, error);
if (attempt < maxAttempts) {
const delay = attempt * 2000; // 2s, 4s
if (tbody) {
tbody.innerHTML = `<tr><td colspan="9" style="padding:32px;text-align:center;">
<div style="display:flex;flex-direction:column;align-items:center;gap:10px;">
<span style="color:#f59e0b;font-size:13px;">⚠ ${error.message}</span>
<span style="color:#64748b;font-size:11px;">Повтор через ${delay / 1000} сек... (попытка ${attempt + 1} из ${maxAttempts})</span>
</div>
</td></tr>`;
}
setTimeout(() => loadDataWithRetry(attempt + 1), delay);
} else {
// All attempts failed — show error with retry button
if (tbody) {
tbody.innerHTML = `<tr><td colspan="9" style="padding:40px;text-align:center;">
<div style="display:flex;flex-direction:column;align-items:center;gap:16px;">
<span style="font-size:36px;">⚠️</span>
<div style="color:#f87171;font-size:14px;font-weight:600;">Не удалось загрузить данные</div>
<div style="color:#64748b;font-size:12px;max-width:400px;">${error.message}</div>
<button onclick="loadDataWithRetry(1)"
style="margin-top:8px;padding:10px 28px;background:#0056b2;color:#fff;border:none;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;letter-spacing:.04em;">
Попробовать снова
</button>
</div>
</td></tr>`;
}
if (paginationInfo) paginationInfo.textContent = 'Ошибка загрузки';
}
}
}
window.loadDataWithRetry = loadDataWithRetry;
function populateStatusFilterOptions() {
const statusesFromData = [...new Set(addressesData.map(item => item['Статус']))].filter(Boolean);
const statuses = [
...STATUS_FILTER_OPTIONS,
...statusesFromData.filter(s => !STATUS_FILTER_OPTIONS.includes(s)).sort(),
];
['reg-filter-status', 'map-filter-status'].forEach(id => {
const sel = document.getElementById(id);
if (!sel) return;
const current = sel.value;
while (sel.options.length > 1) sel.remove(1);
statuses.forEach(s => {
const opt = document.createElement('option');
opt.value = s;
opt.textContent = s;
sel.appendChild(opt);
});
sel.value = current || '';
});
}
function initializeMOFilter() {
populateStatusFilterOptions();
const regStatus = document.getElementById('reg-filter-status');
const mapStatus = document.getElementById('map-filter-status');
if (regStatus) regStatus.value = '';
if (mapStatus) mapStatus.value = '';
currentFilters.status = '';
if (typeof mapFilters !== 'undefined') mapFilters.status = '';
filterAndDisplayRegistry();
}
function initializeEventListeners() {
const searchInput = document.getElementById('search-input');
const searchClearBtn = document.getElementById('search-clear');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
currentFilters.search = e.target.value.toLowerCase();
currentPage = 1; filterAndDisplayRegistry();
if (searchClearBtn) {
searchClearBtn.classList.toggle('opacity-0', !e.target.value);
searchClearBtn.classList.toggle('pointer-events-none', !e.target.value);
}
});
}
if (searchClearBtn) {
searchClearBtn.addEventListener('click', () => {
searchInput.value = ''; currentFilters.search = '';
currentPage = 1; filterAndDisplayRegistry();
searchClearBtn.classList.add('opacity-0', 'pointer-events-none');
searchInput.focus();
});
}
const exportBtn = document.getElementById('export-csv');
if (exportBtn) exportBtn.addEventListener('click', exportToCSV);
const exportExcelBtn = document.getElementById('export-excel');
if (exportExcelBtn) exportExcelBtn.addEventListener('click', exportToExcel);
const selectAllCb = document.getElementById('select-all-checkbox');
if (selectAllCb) {
selectAllCb.addEventListener('change', (e) => {
const start = (currentPage - 1) * rowsPerPage;
const pageData = filteredData.slice(start, start + rowsPerPage);
pageData.forEach(item => { if (e.target.checked) selectedIds.add(item.ID); else selectedIds.delete(item.ID); });
displayRegistry();
});
}
const clearSelectionBtn = document.getElementById('clear-selection');
if (clearSelectionBtn) clearSelectionBtn.addEventListener('click', () => { selectedIds.clear(); displayRegistry(); });
const exportDashboardBtn = document.getElementById('export-dashboard-csv');
if (exportDashboardBtn) exportDashboardBtn.addEventListener('click', exportDashboardCSV);
const exportDashboardExcelBtn = document.getElementById('export-dashboard-excel');
if (exportDashboardExcelBtn) exportDashboardExcelBtn.addEventListener('click', exportDashboardExcel);
document.querySelectorAll('.wear-report-btn').forEach(btn => {
btn.addEventListener('click', () => {
const fmt = btn.getAttribute('data-wear-report');
if (fmt) exportWearReport(fmt);
});
});
const regStatusFilter = document.getElementById('reg-filter-status');
if (regStatusFilter) {
regStatusFilter.addEventListener('change', (e) => {
currentFilters.status = e.target.value; currentPage = 1; filterAndDisplayRegistry();
});
}
const regResetBtn = document.getElementById('reg-reset-filters');
if (regResetBtn) {
regResetBtn.addEventListener('click', () => {
currentFilters = { search: '', status: '' };
const si = document.getElementById('search-input'); if (si) si.value = '';
const rsf = document.getElementById('reg-filter-status'); if (rsf) rsf.value = '';
currentPage = 1; filterAndDisplayRegistry();
});
}
const rowsSelect = document.getElementById('rows-per-page');
if (rowsSelect) {
rowsSelect.addEventListener('change', (e) => {
rowsPerPage = parseInt(e.target.value); currentPage = 1; filterAndDisplayRegistry();
});
}
}
function _searchAbbrRules() {
return [
[/\bул\.(?=\s|$)/gi, 'улица'],
[/\bпр-кт\.(?=\s|$)/gi, 'проспект'],
[/\bпр\.(?=\s|$)/gi, 'проспект'],
[/\bпер\.(?=\s|$)/gi, 'переулок'],
[/\bпл\.(?=\s|$)/gi, 'площадь'],
[/\bб-р\.(?=\s|$)/gi, 'бульвар'],
[/\bнаб\.(?=\s|$)/gi, 'набережная'],
[/\bш\.(?=\s|$)/gi, 'шоссе'],
[/\bпр-д\.(?=\s|$)/gi, 'проезд'],
[/\bтуп\.(?=\s|$)/gi, 'тупик'],
[/\bд\.(?=\s|$)/gi, 'дом'],
[/\bг\.(?=\s|$)/gi, 'город'],
[/\bс\.(?=\s|$)/gi, 'село'],
[/\bкв\.(?=\s|$)/gi, 'квартал'],
];
}
function _searchNormalize(str) {
if (!str) return '';
let s = String(str).toLowerCase();
for (const [re, rep] of _searchAbbrRules()) s = s.replace(re, rep);
return s.replace(/[,.\-]/g, ' ').replace(/\s+/g, ' ').trim();
}
function _searchTokens(q) {
return _searchNormalize(q).split(/\s+/).filter(t => t.length >= 2 || /^\d+$/.test(t));
}
function _smartNorm(str) {
return _searchNormalize(str);
}
// ── Fuzzy search ──────────────────────────────────────────────────────────
function _lev(a, b) {
const m = a.length, n = b.length;
if (!m) return n; if (!n) return m;
const prev = Array.from({length: n+1}, (_, i) => i);
const curr = Array(n+1).fill(0);
for (let i = 1; i <= m; i++) {
curr[0] = i;
for (let j = 1; j <= n; j++)
curr[j] = a[i-1] === b[j-1] ? prev[j-1]
: 1 + Math.min(prev[j], curr[j-1], prev[j-1]);
prev.splice(0, n+1, ...curr);
}
return curr[n];
}
function _tokScore(tok, fieldStr) {
if (/^\d+$/.test(tok)) {
const houseRe = new RegExp('(?:^|\\s)(?:дом\\s+)?' + tok + '(?:\\s|$)');
if (houseRe.test(fieldStr)) return 10;
if (fieldStr.includes(' ' + tok) || fieldStr.endsWith(' ' + tok)) return 8;
return 0;
}
// 1. Точное вхождение
if (fieldStr.includes(tok)) return 10;
// 2. Совпадение без учёта пробелов (слитно написанные слова)
const fs = fieldStr.replace(/\s+/g, '');
const ts = tok.replace(/\s+/g, '');
if (fs.includes(ts)) return 8;
// 3. Fuzzy по словам (опечатки) — только для слов длиной ≥ 4
if (tok.length < 4) return 0;
const maxDist = tok.length <= 5 ? 1 : tok.length <= 8 ? 2 : 3;
let best = 0;
for (const w of fieldStr.split(' ')) {
if (w.length < 2 || Math.abs(w.length - tok.length) > maxDist + 1) continue;
const d = _lev(tok, w.length > tok.length ? w.slice(0, tok.length + maxDist) : w);
if (d <= maxDist) { best = Math.max(best, 6 - d); }
}
return best;
}
function _buildFields(item) {
return [
_smartNorm(item['Адрес'] || ''),
_smartNorm(item['Управляющая организация'] || ''),
_smartNorm(item['Статус'] || ''),
_smartNorm(item['Исполнитель'] || ''),
String(item.ID || '')
].join(' ');
}
function filterData() {
const hasSearch = !!currentFilters.search;
const tokens = hasSearch ? _searchTokens(currentFilters.search) : [];
const scored = [];
for (const item of addressesData) {
if (currentFilters.status && normalizeBuildingStatus(item['Статус']) !== currentFilters.status) continue;
if (hasSearch) {
const fields = _buildFields(item);
let total = 0;
let skip = false;
for (const tok of tokens) {
const s = _tokScore(tok, fields);
if (s === 0) { skip = true; break; }
total += s;
}
if (skip) continue;
scored.push({ item, score: total });
} else {
scored.push({ item, score: 0 });
}
}
if (hasSearch) {
// Сортируем по релевантности
scored.sort((a, b) => b.score - a.score);
filteredData = scored.map(s => s.item);
} else {
filteredData = scored.map(s => s.item);
const col = currentSortCol;
const dir = currentSortDir === 'asc' ? 1 : -1;
filteredData.sort((a, b) => {
let va = col === 'Статус' ? normalizeBuildingStatus(a[col]) : a[col];
let vb = col === 'Статус' ? normalizeBuildingStatus(b[col]) : b[col];
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
if (va == null) return 1; if (vb == null) return -1;
return String(va).localeCompare(String(vb), 'ru') * dir;
});
}
return filteredData;
}
window.sortBy = function(col) {
if (currentSortCol === col) {
currentSortDir = currentSortDir === 'asc' ? 'desc' : 'asc';
} else {
currentSortCol = col; currentSortDir = 'asc';
}
// Update sort indicators
document.querySelectorAll('th span[id^="sort-"]').forEach(s => s.textContent = '↕');
const ind = document.getElementById('sort-' + col);
if (ind) ind.textContent = currentSortDir === 'asc' ? '↑' : '↓';
currentPage = 1; filterAndDisplayRegistry();
};
function filterAndDisplayRegistry() { filterData(); displayRegistry(); }
function initializeRegistry() { filterAndDisplayRegistry(); }
/** Главная → реестр: синхронизировать поле реестра, фильтры и таблицу (в т.ч. до срабатывания input-listeners) */
function applyMainQueryToRegistry(q) {
const v = (q || '').trim();
const si = document.getElementById('search-input');
if (si) si.value = v;
currentFilters.search = v.toLowerCase();
currentPage = 1;
filterAndDisplayRegistry();
try {
if (si) si.dispatchEvent(new Event('input', { bubbles: true }));
} catch (e) {}
}
function displayRegistry() {
const tbody = document.getElementById('registry-tbody');
const totalCount = document.getElementById('total-count');
const paginationInfo = document.getElementById('pagination-info');
const paginationControls = document.getElementById('pagination-controls');
if (!tbody) return;
const totalItems = filteredData.length;
if (totalCount) totalCount.textContent = totalItems;
const startIndex = (currentPage - 1) * rowsPerPage;
const endIndex = Math.min(startIndex + rowsPerPage, totalItems);
const pageData = filteredData.slice(startIndex, endIndex);
tbody.innerHTML = '';
pageData.forEach((item, idx) => {
const row = document.createElement('tr');
const isSelected = selectedIds.has(item.ID);
row.className = `hover:bg-primary/5 transition-all group cursor-pointer ${idx % 2 === 1 ? 'bg-slate-800/10' : ''} ${isSelected ? '!bg-primary/10' : ''}`;
row.onclick = () => openCard(item.ID);
const fullAddress = item['Адрес'] || '';
row.innerHTML = `
<td class="px-3 py-4" onclick="event.stopPropagation();">
<input type="checkbox" class="row-checkbox w-4 h-4 rounded bg-slate-800 border-slate-600 text-primary focus:ring-primary/30 cursor-pointer accent-[#0056b2]" data-id="${item.ID}" ${isSelected ? 'checked' : ''}/>
</td>
<td class="px-6 py-4 font-mono text-primary font-bold">#${item.ID}</td>
<td class="px-6 py-4">
<div class="flex flex-col">
<span class="text-white font-semibold">${fullAddress}</span>
</div>
</td>
<td class="px-4 py-4 text-center">${statusBadgeHtml(item['Статус'])}</td>
<td class="px-6 py-4 text-center font-medium text-white">${item['Год постройки'] || '—'}</td>
<td class="px-6 py-4 text-center font-medium text-white">${item['Число этажей'] || '—'}</td>
<td class="px-6 py-4 font-mono text-right font-bold text-slate-300">${item['Общая площадь, м²'] ? Number(item['Общая площадь, м²']).toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2}) : '—'}</td>
<td class="px-6 py-4 font-medium italic text-slate-400">${item['Управляющая организация'] || '—'}</td>
<td class="px-6 py-4">
<button onclick="event.stopPropagation();" class="p-1 rounded-lg hover:bg-primary/20 text-slate-500 hover:text-primary transition-all">
<span class="material-symbols-outlined !text-xl">arrow_forward_ios</span>
</button>
</td>`;
tbody.appendChild(row);
});
tbody.querySelectorAll('.row-checkbox').forEach(cb => {
cb.addEventListener('change', (e) => {
const id = e.target.dataset.id;
if (e.target.checked) selectedIds.add(id); else selectedIds.delete(id);
e.target.closest('tr').classList.toggle('!bg-primary/10', e.target.checked);
updateSelectionUI();
});
});
const selectAllCb = document.getElementById('select-all-checkbox');
if (selectAllCb) {
const pageIds = pageData.map(i => String(i.ID));
selectAllCb.checked = pageIds.length > 0 && pageIds.every(id => selectedIds.has(id));
selectAllCb.indeterminate = !selectAllCb.checked && pageIds.some(id => selectedIds.has(id));
}
updateSelectionUI();
if (paginationInfo) {
paginationInfo.textContent = totalItems === 0 ? 'Показано 0 из 0' :
`Показано ${startIndex + 1}–${endIndex} из ${totalItems}`;
}
if (paginationControls) {
const totalPages = Math.ceil(totalItems / rowsPerPage);
let html = `<button onclick="goToPage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''} class="w-10 h-10 flex items-center justify-center rounded-xl bg-slate-800 text-slate-400 hover:bg-primary hover:text-white transition-all disabled:opacity-30 disabled:hover:bg-slate-800">
<span class="material-symbols-outlined">chevron_left</span></button>`;
for (let i = 1; i <= Math.min(totalPages, 3); i++) {
const pn = currentPage <= 2 ? i : (currentPage >= totalPages - 1 ? totalPages - 3 + i : currentPage - 2 + i);
if (pn > 0 && pn <= totalPages) {
html += `<button onclick="goToPage(${pn})" class="w-10 h-10 flex items-center justify-center rounded-xl ${pn === currentPage ? 'bg-primary text-white font-bold shadow-lg shadow-primary/20' : 'bg-slate-800 text-slate-400 hover:bg-slate-700 transition-all font-bold'}">${pn}</button>`;
}
}
html += `<button onclick="goToPage(${currentPage + 1})" ${currentPage === totalPages || totalPages === 0 ? 'disabled' : ''} class="w-10 h-10 flex items-center justify-center rounded-xl bg-slate-800 text-slate-400 hover:bg-primary hover:text-white transition-all disabled:opacity-30 disabled:hover:bg-slate-800">
<span class="material-symbols-outlined">chevron_right</span></button>`;
paginationControls.innerHTML = html;
}
}
function getRegistryExportData() {
if (selectedIds.size > 0) {
return addressesData.filter(item =>
selectedIds.has(String(item.ID)) || selectedIds.has(item.ID)
);
}
return filteredData;
}
function updateSelectionUI() {
const count = selectedIds.size;
const countEl = document.getElementById('selected-count');
const numEl = document.getElementById('selected-num');
const clearBtn = document.getElementById('clear-selection');
if (countEl) countEl.classList.toggle('hidden', count === 0);
if (numEl) numEl.textContent = count;
if (clearBtn) { clearBtn.classList.toggle('hidden', count === 0); clearBtn.classList.toggle('flex', count > 0); }
}
let _wearReportBusy = false;
async function exportWearReport(format) {
if (_wearReportBusy) return;
const ids = getRegistryExportData().map(b => String(b.ID));
if (!ids.length) {
alert('Нет данных для отчёта');
return;
}
_wearReportBusy = true;
const activeBtns = document.querySelectorAll('.wear-report-btn[data-wear-report="' + format + '"]');
activeBtns.forEach(b => { b.disabled = true; });
try {
const resp = await fetch('/api/reports/mkd-wear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, format })
});
if (!resp.ok) {
let msg = 'Ошибка ' + resp.status;
try {
const err = await resp.json();
if (err.detail) msg = typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail);
} catch (_) {}
throw new Error(msg);
}
const blob = await resp.blob();
const cd = resp.headers.get('Content-Disposition') || '';
let filename = 'otchet_mkd_iznos.' + format;
const star = cd.match(/filename\*=UTF-8''([^;]+)/i);
const plain = cd.match(/filename="([^"]+)"/i);
if (star) filename = decodeURIComponent(star[1]);
else if (plain) filename = plain[1];
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
alert('Не удалось сформировать отчёт: ' + (e.message || e));
} finally {
_wearReportBusy = false;
activeBtns.forEach(b => { b.disabled = false; });
}
}
window.exportWearReport = exportWearReport;
window.goToPage = function(page) { currentPage = page; displayRegistry(); };
function exportToCSV() {
const data = getRegistryExportData();
let csv = 'ID,Адрес,Муниципалитет,Год постройки,Этажность,Площадь (м²),Управляющая организация\n';
data.forEach(item => {
csv += `${item.ID},"${(item['Адрес']||'').replace(/"/g,'""')}","${(item['Муниципалитет']||'').replace(/"/g,'""')}",${item['Год постройки']||''},${item['Число этажей']||''},${item['Общая площадь, м²']||''},"${(item['Управляющая организация']||'').replace(/"/g,'""')}"\n`;
});
const suffix = selectedIds.size > 0 ? `_выбранные_${data.length}шт` : '';
const blob = new Blob(['\uFEFF' + csv], {type: 'text/csv;charset=utf-8;'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `реестр_мкд${suffix}_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
}
function exportToExcel() {
const data = getRegistryExportData();
const rows = data.map(item => ({
'ID': item.ID, 'Адрес': item['Адрес']||'',
'Муниципалитет': item['Муниципалитет']||'',
'Год постройки': item['Год постройки']||'',
'Этажи': item['Число этажей']||'',
'Площадь (м²)': item['Общая площадь, м²']||'',
'Управляющая организация': item['Управляющая организация']||''
}));
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, selectedIds.size > 0 ? 'Выбранные МКД' : 'Реестр МКД');
const suffix = selectedIds.size > 0 ? `_выбранные_${data.length}шт` : '';
XLSX.writeFile(wb, `реестр_мкд${suffix}_${new Date().toISOString().split('T')[0]}.xlsx`);
}
function exportDashboardCSV() {
if (!currentAppendixData || !currentAppendixData.rows) { alert('Сначала выберите приложение'); return; }
const columns = currentAppendixData.columns || [];
let csv = columns.join(',') + '\n';
currentAppendixData.rows.forEach(row => {
csv += columns.map(col => `"${row[col]||''}"`).join(',') + '\n';
});
const blob = new Blob(['\uFEFF' + csv], {type: 'text/csv;charset=utf-8;'});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `сводные_данные_${new Date().toISOString().split('T')[0]}.csv`;
link.click();
}
function exportDashboardExcel() {
if (!currentAppendixData || !currentAppendixData.rows) { alert('Сначала выберите приложение'); return; }
const columns = currentAppendixData.columns || [];
const rows = currentAppendixData.rows.map(row => {
const obj = {}; columns.forEach(col => { obj[col] = row[col] != null ? row[col] : ''; }); return obj;
});
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Сводные данные');
XLSX.writeFile(wb, `сводные_данные_${new Date().toISOString().split('T')[0]}.xlsx`);
}
function initializeAppendixSelector() {
const selector = document.getElementById('appendix-selector');
if (!selector || !appendixIndex.length) return;
appendixIndex.forEach(item => {
const option = document.createElement('option');
option.value = item.file;
option.textContent = `${item.title || item.sheet} (${item.nrows})`;
selector.appendChild(option);
});
selector.addEventListener('change', (e) => {
if (e.target.value) loadAppendixData(e.target.value);
else showEmptyAppendixState();
});
}
async function loadAppendixData(filename) {
try {
// filename = "summary/sheet_key" — map to /api/summary/sheet_key
const data = await fetch(`/api/${filename}`).then(r => r.json());
currentAppendixData = data;
displayAppendixAddresses(data);
} catch (error) {
console.error('Ошибка загрузки приложения:', error);
}
}
function displayAppendixAddresses(data) {
const container = document.getElementById('addresses-container');
if (!container || !data.rows || !data.columns) return;
const totalItems = data.rows.length;
const itemsPerPage = 50;
let curAppPage = 1;
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
function renderPage(page) {
const start = (page - 1) * itemsPerPage;
const end = Math.min(start + itemsPerPage, totalItems);
const pageRows = data.rows.slice(start, end);
const cols = data.columns;
container.innerHTML = `
<div class="h-full overflow-auto dashboard-scrollbar">
<table class="w-full border-collapse text-sm text-left">
<thead class="dashboard-table-sticky">
<tr class="text-slate-400 uppercase tracking-wider font-bold text-xs">
${cols.map(c => `<th class="px-5 py-4 whitespace-nowrap">${esc(c)}</th>`).join('')}
<th class="px-4 py-4 w-12"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/50">
${pageRows.map((row, idx) => {
const address = row['Адрес'] || '';
const matchingBuilding = addressesData.find(b => b['Адрес'] && address && b['Адрес'] === address);
const rowClass = matchingBuilding ? 'hover:bg-primary/5 cursor-pointer' : 'hover:bg-slate-800/30';
const rowClick = matchingBuilding ? `onclick="openCard('${matchingBuilding.ID}')"` : '';
return `<tr class="${rowClass} ${idx % 2 === 1 ? 'bg-slate-800/10' : ''} transition-all group" ${rowClick}>
${cols.map(c => `<td class="px-5 py-3.5 text-slate-200 ${c.toLowerCase().includes('адрес') ? 'font-medium' : ''}" title="${esc(row[c])}">${esc(row[c] || '—')}</td>`).join('')}
<td class="px-4 py-3.5">${matchingBuilding ? '<button class="p-1 rounded-lg hover:bg-primary/20 text-slate-500 hover:text-primary transition-all"><span class="material-symbols-outlined !text-xl">arrow_forward_ios</span></button>' : ''}</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>
${totalItems > itemsPerPage ? `
<div class="bg-[#161b2c] border-t border-slate-800 px-8 py-4 flex items-center justify-between shrink-0">
<span class="text-[11px] text-slate-500 font-bold uppercase tracking-widest">Показано ${start+1}–${end} из ${totalItems}</span>
<div class="flex items-center gap-1">
<button type="button" onclick="changeAppendixPage(${page-1})" ${page<=1?'disabled':''} class="w-10 h-10 flex items-center justify-center rounded-xl bg-slate-800 text-slate-400 hover:bg-primary hover:text-white transition-all disabled:opacity-30">
<span class="material-symbols-outlined">chevron_left</span></button>
<button class="w-10 h-10 flex items-center justify-center rounded-xl bg-primary text-white font-bold">${page}</button>
<button type="button" onclick="changeAppendixPage(${page+1})" ${page>=Math.ceil(totalItems/itemsPerPage)?'disabled':''} class="w-10 h-10 flex items-center justify-center rounded-xl bg-slate-800 text-slate-400 hover:bg-primary hover:text-white transition-all disabled:opacity-30">
<span class="material-symbols-outlined">chevron_right</span></button>
</div>
</div>` : ''}`;
curAppPage = page;
}
window.changeAppendixPage = function(page) {
const max = Math.ceil(totalItems / itemsPerPage);
if (page < 1 || page > max) return;
renderPage(page);
const tbl = container.querySelector('.dashboard-scrollbar');
if (tbl) tbl.scrollTop = 0;
};
renderPage(1);
}
function showEmptyAppendixState() {
const container = document.getElementById('addresses-container');
if (!container) return;
container.className = 'overflow-auto dashboard-scrollbar flex-1 flex flex-col bg-[#0b0f1a]';
container.innerHTML = `<div class="flex-1 flex flex-col items-center justify-center text-slate-500">
<span class="material-symbols-outlined text-7xl mb-4 opacity-30">database</span>
<p class="text-sm font-bold uppercase tracking-widest text-slate-400">Нет данных</p>
<p class="text-xs mt-2 text-slate-600">Выберите приложение в списке выше</p></div>`;
}
// ===== Card Page =====
function openCard(id) {
window.currentBuildingId = id;
location.hash = 'card/' + id;
// Сбрасываем плейсхолдер на спиннер загрузки
const _sp = document.getElementById('system-placeholder');
const _ss = document.getElementById('system-placeholder-spinner');
const _si = document.getElementById('system-placeholder-icon');
const _st = document.getElementById('system-placeholder-text');
if (_sp) _sp.style.display = '';
if (_ss) _ss.style.display = '';
if (_si) _si.classList.add('hidden');
if (_st) _st.textContent = 'Загрузка данных...';
setTimeout(() => loadCardData(id), 100);
}
window.closeCardSmooth = function() {
const cardPage = document.getElementById('page-card');
if (!cardPage) { window.history.back(); return; }
cardPage.classList.add('is-closing');
setTimeout(() => {
window.history.back();
setTimeout(() => cardPage.classList.remove('is-closing'), 50);
}, 300);
};
// SIZE_PRIORITY для выбора лучшего превью
const SIZE_PRIORITY_IMG = ['XXXL','XXL','XL','L','DEFAULT','M','S'];
function pickBestSize(sizes) {
if (!sizes || !sizes.length) return null;
for (const name of SIZE_PRIORITY_IMG) {
const s = sizes.find(s => s.name === name);
if (s && s.url) return s.url;
}
return sizes.find(s => s.name !== 'ORIGINAL' && s.url)?.url || null;
}
window.updateGallery = function(photosArray) {
const gallery = document.getElementById('photo-gallery-new');
if (!gallery) return;
gallery.innerHTML = '';
// «Общий вид» — строго первые 4 фото
if (Array.isArray(photosArray)) photosArray = photosArray.slice(0, 4);
if (!photosArray || photosArray.length === 0) {
gallery.className = 'flex-1 flex flex-col items-center justify-center min-h-0 text-slate-500';
gallery.innerHTML = `<div class="flex flex-col items-center opacity-50">
<span class="material-symbols-outlined text-4xl mb-2">no_photography</span>
<p class="text-xs font-bold tracking-widest uppercase">Фото отсутствуют</p></div>`;
} else {
gallery.className = 'flex-1 overflow-y-auto custom-scroll grid grid-cols-4 gap-4 content-start';
gallery.innerHTML = photosArray.map((url, i) => `
<div onclick="openOverviewGallery(${i})" data-gallery-idx="${i}" class="w-full aspect-video rounded-xl overflow-hidden cursor-pointer group relative border border-white/10 hover:border-primary/50 transition-all bg-black/20 shadow-md">
<img class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" src="${url}" alt="Фото ${i+1}" onerror="this.parentElement.style.display='none'" referrerpolicy="no-referrer"/>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
<span class="material-symbols-outlined text-white drop-shadow-lg text-4xl transform scale-75 group-hover:scale-100 transition-transform">zoom_in</span>
</div>
<div class="absolute bottom-2 right-2 bg-black/60 text-white text-[9px] px-2 py-0.5 rounded-full backdrop-blur-sm pointer-events-none border border-white/10">${i+1}</div>
</div>`).join('');
}
};
function loadCardData(id) {
const building = addressesData.find(b => String(b.ID) === String(id));
if (building) window._currentBuilding = building;
if (!building) {
// Try fetching directly from API
fetch(`/api/buildings/${id}`).then(r => r.json()).then(b => {
addressesData.push(b);
loadCardData(id);
}).catch(() => {});
return;
}
const cardAddressHeader = document.getElementById('card-address-header');
if (cardAddressHeader) {
cardAddressHeader.textContent = building['Адрес'] || 'Адрес не указан';
cardAddressHeader.title = building['Адрес'] || '';
}
const metricYear = document.getElementById('metric-year');
const metricFloors = document.getElementById('metric-floors');
const metricArea = document.getElementById('metric-area');
const metricUk = document.getElementById('metric-uk');
if (metricYear) metricYear.textContent = building['Год постройки'] || '—';
if (metricFloors) metricFloors.textContent = building['Число этажей'] || '—';
if (metricArea) {
const area = building['Общая площадь, м²'];
if (area !== null && area !== undefined && area !== '') {
const areaNum = parseFloat(area);
metricArea.textContent = isNaN(areaNum) ? area + ' м²'
: areaNum.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + ' м²';
} else {
metricArea.textContent = '—';
}
}
if (metricUk) metricUk.textContent = building['Управляющая организация'] || '—';
const cardHeaderStatus = document.getElementById('card-header-status');
if (cardHeaderStatus) {
applyStatusBadgeEl(cardHeaderStatus, building['Статус']);
}
const cardMkdWear = document.getElementById('card-mkd-wear');
if (cardMkdWear) {
const totalWear = building['Общий износ'];
cardMkdWear.textContent = totalWear != null ? Math.round(totalWear) : '—';
}
// Load overview photos from backend Yandex Disk proxy
const overviewPath = building['yadisk_overview_path'];
if (overviewPath) {
window.updateGallery(null); // Show loading state
// Берём пачку с диска и оставляем только первые 4 изображения (по имени)
fetch(`/api/yadisk/list?path=${encodeURIComponent(overviewPath)}&limit=50`)
.then(r => r.json())
.then(data => {
const files = (data._embedded?.items || [])
.filter(i => i.type === 'file' && i.media_type === 'image')
.sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''), 'ru', { numeric: true }))
.slice(0, 4);
const urls = files.map(item => pickBestSize(item.sizes)).filter(Boolean).slice(0, 4);
window.updateGallery(urls);
})
.catch(() => window.updateGallery([]));
} else {
window.updateGallery([]);
}
// Загружаем схемы МКД
loadBuildingSchemas(building);
// System links — all clickable by default, photo-highlight set async from YD
document.querySelectorAll('.system-link').forEach(link => {
const systemKey = link.dataset.system;
const newLink = link.cloneNode(true);
link.parentNode.replaceChild(newLink, link);
newLink.dataset.hasPhotos = '0';
newLink.classList.remove('text-primary', 'hover:bg-primary/20');
newLink.classList.add('text-slate-400');
newLink.style.background = '';
newLink.style.border = '';
newLink.style.opacity = '0.65';
newLink.style.cursor = 'pointer';
newLink.style.pointerEvents = 'auto';
newLink.addEventListener('click', (e) => {
e.preventDefault();
if (typeof triggerBtnAnim === 'function') triggerBtnAnim(newLink, false);
window.selectSystemInCard(systemKey, building.ID);
});
});
// TZK button — временно включена только для 2 пилотных домов (Загорская 32, Блюхера 10 А)
const openTzkBtn = document.getElementById('open-tzk');
if (openTzkBtn) {
const _tzkAddr = (building['Адрес'] || '').toLowerCase();
const _isTzkPilotBuilding =
(_tzkAddr.includes('загорская') && _tzkAddr.includes('32')) ||
(_tzkAddr.includes('блюхера') && (_tzkAddr.includes('10 а') || _tzkAddr.includes('10а')));
if (!_isTzkPilotBuilding) {
openTzkBtn.classList.add('hidden');
openTzkBtn.onclick = null;
} else {
openTzkBtn.classList.remove('hidden');
if (building['ТЗК']) {
openTzkBtn.classList.remove('bg-slate-300','cursor-not-allowed','opacity-50');
openTzkBtn.classList.add('bg-primary','hover:brightness-110','cursor-pointer');
openTzkBtn.disabled = false;
openTzkBtn.onclick = () => window.open(building['ТЗК'], '_blank');
} else {
openTzkBtn.classList.remove('bg-primary','hover:brightness-110');
openTzkBtn.classList.add('bg-slate-300','cursor-not-allowed','opacity-50');
openTzkBtn.disabled = true;
openTzkBtn.onclick = null;
}
}
}
// Схема, Дефектная ведомость и флаги кнопок систем
_buildingDocs = {};
const schemaBtn = document.getElementById('open-schema');
const defectBtn = document.getElementById('open-defect-report');
if (schemaBtn) schemaBtn.style.display = 'none';
if (defectBtn) defectBtn.style.display = 'none';
if (building['Адрес']) {
const addrRoot = `disk:/${building.yadisk_root || 'Ленинградская область'}/${building['Адрес']}`;
const ydBase = `${addrRoot}/Дефектная ведомость`;
function _isPdfSchema(name) {
return /^Schemas?_/i.test(name) || /схем/i.test(name) || /schema/i.test(name);
}
function _isPdfDefect(name) {
return /Дефектная[\s_]?ведомость/i.test(name)
|| /defect/i.test(name)
|| /^Report/i.test(name)
|| /ведомость/i.test(name);
}
function _processPdfItems(items, basePath) {
items.filter(i => i.type === 'file' && /\.pdf$/i.test(i.name || '')).forEach(f => {
const name = f.name || '';
const fullPath = basePath + '/' + name;
if (!_buildingDocs.schema_path && _isPdfSchema(name)) {
_buildingDocs.schema_path = fullPath;
if (schemaBtn) schemaBtn.style.display = '';
}
if (!_buildingDocs.defect_path && _isPdfDefect(name)) {
_buildingDocs.defect_path = fullPath;
if (defectBtn) defectBtn.style.display = '';
}
});
}
// Кнопка «Файлы согласования» — скрываем до проверки наличия файлов
const soglBtn = document.getElementById('open-sogl-files');
if (soglBtn) {
soglBtn.style.display = 'none';
soglBtn._soglBase = addrRoot;
soglBtn._soglLoaded = false;
}
const soglPanel = document.getElementById('sogl-panel');
if (soglPanel) {
soglPanel.style.display = 'none';
const c = document.getElementById('sogl-panel-content');
if (c) c.innerHTML = '';
}
// Проверяем наличие файлов хотя бы в одной из папок согласования
const _soglFolders = [
'Запросить исходные данные',
'Подготовить графическую часть',
'Уведомить УК о дате обследования',
];
Promise.any(_soglFolders.map(function(folder) {
return fetch('/api/yadisk/list?path=' + encodeURIComponent(addrRoot + '/' + folder) + '&limit=5&all_files=true')
.then(function(r) { if (!r.ok) throw new Error(); return r.json(); })
.then(function(d) {
const files = (d._embedded?.items || []).filter(function(i) { return i.type === 'file'; });
if (files.length === 0) throw new Error();
return true;
});
})).then(function() {
// Хотя бы одна папка содержит файлы — показываем кнопку
if (soglBtn) soglBtn.style.display = '';
}).catch(function() {
// Все папки пусты или не найдены — кнопка остаётся скрытой
});
// Запрос к «Дефектная ведомость»: папки систем + PDF (all_files=true чтобы получить PDF)
fetch(`/api/yadisk/list?path=${encodeURIComponent(ydBase)}&limit=200&all_files=true`)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const items = data._embedded?.items || [];
// PDF из «Дефектная ведомость»
_processPdfItems(items, ydBase);
// Если кнопок ещё нет — ищем PDF в корне адреса
if (!_buildingDocs.schema_path || !_buildingDocs.defect_path) {
fetch(`/api/yadisk/list?path=${encodeURIComponent(addrRoot)}&limit=200&all_files=true`)
.then(r => r.ok ? r.json() : null)
.then(d2 => {
if (!d2) return;
_processPdfItems(d2._embedded?.items || [], addrRoot);
}).catch(() => {});
}
}).catch(() => {});
}
// Systems list
const systems = (building.systems || []).filter(s => s.wear !== null && s.wear !== undefined);
const systemsHorizontal = document.getElementById('systems-horizontal');
if (systemsHorizontal) {
const allSystems = building.systems || [];
systemsHorizontal.innerHTML = allSystems.map(system => {
const noAccess = !!system.no_access;
const wVal = system.wear != null ? system.wear : 0;
const catColor = wVal > 65 ? 'red' : (wVal > 50 ? 'orange' : (wVal > 30 ? 'amber' : 'green'));
const catNum = wVal > 65 ? '4' : (wVal > 50 ? '3' : (wVal > 30 ? '2' : '1'));
const wearLabel = noAccess ? 'Износ: —' : `Износ: ${wVal.toFixed(0)}%`;
const badge = noAccess
? `<span class="ml-2 px-2 py-1 bg-slate-500/20 text-slate-400 text-[10px] font-bold rounded border border-slate-500/30 whitespace-nowrap">—</span>`
: `<span class="ml-2 px-2 py-1 bg-${catColor}-500/20 text-${catColor}-400 text-[10px] font-bold rounded border border-${catColor}-500/30 whitespace-nowrap">КАТ. ${catNum}</span>`;
return `<div id="syscard-${system.key}" class="system-card flex items-center justify-between p-3 glass-panel rounded-xl border border-transparent bg-white/5 hover:bg-white/10 hover:border-primary/30 cursor-pointer transition-all shrink-0" data-system="${system.key}" onclick="selectSystemInCard('${system.key}', '${building.ID}')">
<div class="flex flex-col overflow-hidden">
<span class="text-xs font-bold text-white truncate">${system.label}</span>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[10px] text-slate-400">${wearLabel}</span>
</div>
</div>
${badge}
</div>`;
}).join('');
}
// Repairs button
const repairsBtnWrapper = document.getElementById('repairs-btn-wrapper');
if (repairsBtnWrapper) {
const repairs = building['Проведённые ремонты'];
repairsBtnWrapper.style.display = (repairs && repairs.trim()) ? '' : 'none';
}
// Auto-select first system with data (только если пользователь ещё не выбрал сам)
const _loadedId = building.ID;
if (systems.length > 0) {
setTimeout(() => {
// Пропускаем авто-выбор если пользователь уже переключился на другое здание
// или уже выбрал систему вручную (контент уже заполнен)
if (String(window.currentBuildingId) !== String(_loadedId)) return;
const placeholder = document.getElementById('system-placeholder');
if (placeholder && placeholder.style.display === 'none') return;
window.selectSystemInCard(systems[0].key, building.ID);
}, 50);
} else {
// Нет систем — показываем подсказку вместо спиннера
const spinner = document.getElementById('system-placeholder-spinner');
const icon = document.getElementById('system-placeholder-icon');
const text = document.getElementById('system-placeholder-text');
if (spinner) spinner.style.display = 'none';
if (icon) icon.classList.remove('hidden');
if (text) text.textContent = 'Выберите систему в меню слева';
}
}
// Load system defect photos, пробует список путей пока не найдёт фото
async function loadSystemPhotosGallery(yadiskPathOrPaths, systemLabel) {
const section = document.getElementById('system-photos-section');
const gallery = document.getElementById('system-photos-gallery');
const title = document.getElementById('system-photos-title');
const countEl = document.getElementById('system-photos-count');
if (!section || !gallery) return;
const paths = Array.isArray(yadiskPathOrPaths) ? yadiskPathOrPaths.filter(Boolean) : (yadiskPathOrPaths ? [yadiskPathOrPaths] : []);
if (!paths.length) { section.style.display = 'none'; return; }
section.style.display = '';
if (title) title.textContent = `Фото дефектов — ${systemLabel}`;
if (countEl) countEl.textContent = '';
gallery.innerHTML = `<div class="col-span-3 flex items-center justify-center h-16 text-slate-500 gap-2">
<span class="material-symbols-outlined text-xl" style="animation:spin 1s linear infinite">refresh</span>
<span class="text-sm">Загрузка фото...</span></div>`;
let files = [];
let usedPath = '';
try {
for (const yadiskPath of paths) {
const resp = await fetch(`/api/yadisk/list?path=${encodeURIComponent(yadiskPath)}`);
if (!resp.ok) continue;
const data = await resp.json();
const found = (data._embedded?.items || []).filter(i => i.type === 'file' && i.media_type === 'image');
if (found.length > 0) { files = found; usedPath = yadiskPath; break; }
}
const yadiskPath = usedPath;
if (files.length === 0) { section.style.display = 'none'; return; }
if (countEl) countEl.textContent = `${files.length} фото`;
section.dataset.yadiskPath = yadiskPath;
const validItems = files.map(item => {
const sz = item.sizes || [];
// S(150px) → M(300px) — маленькие, грузятся мгновенно
const thumbUrl = sz.find(s => s.name === 'S')?.url
|| sz.find(s => s.name === 'M')?.url
|| sz.find(s => s.name !== 'ORIGINAL' && s.name !== 'XXXL' && s.name !== 'XXL')?.url;
// XL(800px)/XXL(1024px)/XXXL — для полноэкранного просмотра
const fullUrl = sz.find(s => s.name === 'XL')?.url
|| sz.find(s => s.name === 'XXL')?.url
|| sz.find(s => s.name === 'XXXL')?.url
|| sz.find(s => s.name === 'L')?.url
|| thumbUrl;
if (!thumbUrl) return null;
return { thumb: thumbUrl, full: fullUrl, name: item.name };
}).filter(Boolean);
if (validItems.length === 0) { section.style.display = 'none'; return; }
gallery.innerHTML = validItems.map((item, idx) => `
<div data-filename="${encodeURIComponent(item.name)}" data-fullurl="${encodeURIComponent(item.full || item.thumb)}" onclick="openSystemPhotoFull(this)"
class="w-full aspect-square rounded-lg overflow-hidden cursor-pointer group relative border border-white/10 hover:border-primary/60 transition-all bg-black/20">
<img referrerpolicy="no-referrer" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" src="${item.thumb}" alt="Фото ${idx+1}" onerror="this.parentElement.style.display='none'"/>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center opacity-0 group-hover:opacity-100">
<span class="material-symbols-outlined text-white text-2xl drop-shadow-lg">zoom_in</span>
</div>
<div class="absolute bottom-1 right-1 bg-black/70 text-white text-[8px] w-4 h-4 rounded-full flex items-center justify-center font-bold pointer-events-none">${idx+1}</div>
</div>`).join('');
} catch(e) {
section.style.display = 'none';
}
}
window.openSystemPhotoFull = function(el) {
const container = el.closest('[id]');
const siblings = container ? Array.from(container.querySelectorAll('[data-fullurl]')) : [el];
const items = siblings.map(e => decodeURIComponent(e.dataset.fullurl || ''));
const idx = siblings.indexOf(el);
_openGallery(items, idx >= 0 ? idx : 0);
};
function triggerBtnAnim(el, isAmber) {
const cls = isAmber ? 'btn-click-anim-amber' : 'btn-click-anim';
el.classList.remove(cls);
void el.offsetWidth;
el.classList.add(cls);
el.addEventListener('animationend', () => el.classList.remove(cls), {once: true});
}
document.querySelectorAll('.system-link').forEach(link => {
link.addEventListener('click', function() { triggerBtnAnim(this, false); });
});
const repairsBtnEl = document.getElementById('btn-repairs');
if (repairsBtnEl) repairsBtnEl.addEventListener('click', function() { triggerBtnAnim(this, true); });
window.selectSystemInCard = async function(key, bid) {
window.currentBuildingId = bid;
const container = document.getElementById('system-content-new');
if (!container) return;
// Скрываем плейсхолдер — система выбрана
const placeholder = document.getElementById('system-placeholder');
if (placeholder) placeholder.style.display = 'none';
// Подсвечиваем активную карточку в правом блоке
document.querySelectorAll('.system-card').forEach(c => {
const isActive = c.dataset.system === key;
c.classList.toggle('border-primary/60', isActive);
c.classList.toggle('bg-primary/10', isActive);
c.classList.toggle('bg-white/5', !isActive);
c.classList.toggle('border-transparent', !isActive);
});
// Подгружаем дефекты для этого здания, если ещё не кешированы
if (!defectsSummary[String(bid)]) {
try {
const dr = await fetch(`/api/buildings/${bid}/defects`);
if (dr.ok) {
const dd = await dr.json();
defectsSummary[String(bid)] = dd.by_system || {};
console.log(`[Defects] bid=${bid}`, dd.by_system);
} else {
console.warn(`[Defects] HTTP ${dr.status} for bid=${bid}`);
defectsSummary[String(bid)] = {};
}
} catch(e) {
console.error(`[Defects] fetch error bid=${bid}`, e);
defectsSummary[String(bid)] = {};
}
}
const repairsBtn = document.getElementById('btn-repairs');
if (repairsBtn) repairsBtn.classList.remove('active-repairs');
document.querySelectorAll('.system-link').forEach(l => {
const isTarget = l.dataset.system === key;
const hasPhotos = l.dataset.hasPhotos === '1';
if (isTarget) {
l.classList.remove('text-primary','text-slate-500','text-slate-300','hover:bg-primary/20','hover:bg-white/10');
l.classList.add('shadow-lg');
l.style.background = '#0056b2'; l.style.border = '1px solid #0056b2';
l.style.opacity = '1'; l.style.color = '#fff';
} else {
l.classList.remove('shadow-lg'); l.style.color = '';
if (hasPhotos) {
l.classList.add('text-primary','hover:bg-primary/20');
l.classList.remove('text-slate-500');
l.style.background = 'rgba(0,86,178,0.15)'; l.style.border = '1px solid rgba(0,86,178,0.35)';
l.style.opacity = '1'; l.style.cursor = 'pointer'; l.style.pointerEvents = 'auto';
} else {
l.classList.add('text-slate-400'); l.classList.remove('text-primary','hover:bg-primary/20','text-slate-500');
l.style.background = ''; l.style.border = '';
l.style.opacity = '0.65'; l.style.cursor = 'pointer'; l.style.pointerEvents = 'auto';
}
}
});
const building = addressesData.find(b => String(b.ID) === String(bid));
const sysData = building?.systems?.find(s => s.key === key);
if (!sysData) {
const sysPhotos = document.getElementById('system-photos-section');
if (sysPhotos) sysPhotos.style.display = 'none';
container.innerHTML = '<div class="absolute inset-0 flex items-center justify-center"><p class="text-slate-500 text-lg">Нет данных по системе</p></div>';
return;
}
// Load defects from defectsSummary
const bDefects = defectsSummary[String(bid)] || defectsSummary[bid] || {};
const possibleKeys = sysData.defect_match || [sysData.label];
let defects = [];
for (const k of possibleKeys) {
if (bDefects[k] && Array.isArray(bDefects[k])) {
defects = bDefects[k].slice();
break;
}
}
// Строим список путей для попытки: основной + альтернативные имена папок
const ydPathBase = sysData.yadisk_path
? sysData.yadisk_path.substring(0, sysData.yadisk_path.lastIndexOf('/'))
: null;
const pathsToTry = ydPathBase && sysData.yd_folder_alts?.length
? sysData.yd_folder_alts.map(f => `${ydPathBase}/${f}`)
: (sysData.yadisk_path ? [sysData.yadisk_path] : []);
loadSystemPhotosGallery(pathsToTry, sysData.label);
const isNoAccess = !!sysData.no_access;
const wear = sysData.wear !== null && sysData.wear !== undefined ? sysData.wear : null;
const color = isNoAccess ? 'slate' : (wear !== null ? (wear > 65 ? 'red' : (wear > 50 ? 'orange' : (wear > 30 ? 'amber' : 'green'))) : 'slate');
const statusText = isNoAccess
? '—'
: (wear !== null
? (wear <= 30 ? 'Не требует ремонта'
: wear <= 50 ? 'Требует ремонта'
: wear <= 65 ? 'Требует ремонта в приоритетном порядке'
: 'Требует инструментального обследования')
: 'Нет данных');
const prichinas = [...new Set(defects.map(d => d.prichina).filter(v => v && v.trim()))];
const recomends = [...new Set(defects.map(d => d.recomend).filter(v => v && v.trim()))];
const rabotis = [...new Set(defects.map(d => d.raboti_po_172).filter(v => v && v.trim()))];
const _tabs = [
{ id: 'defects', label: 'Дефекты' },
...(prichinas.length ? [{ id: 'prichina', label: 'Причины' }] : []),
...(rabotis.length ? [{ id: 'raboti', label: 'Рекомендуемые работы' }] : []),
];
const _tabBtns = _tabs.map((t, i) =>
`<button onclick="switchSysTab('${t.id}')" id="systab-${t.id}" class="systab-btn flex-1 px-2 py-1.5 rounded-lg text-xs font-bold transition-all ${i === 0 ? 'bg-primary text-white shadow' : 'text-slate-400 hover:text-white hover:bg-white/10'}">${t.label}</button>`
).join('');
const _defectRows = defects.length > 0 ? defects.map(d =>
`<div class="flex items-center gap-3 p-3 bg-white/5 rounded-xl border border-white/10 hover:bg-white/10 transition-colors mb-2">
<span class="material-symbols-outlined text-red-400 text-xl shrink-0">error_outline</span>
<p class="text-white text-sm flex-1">${d.defect_name || d.defect || '—'}</p>
<div class="flex flex-col items-center bg-white/10 rounded-lg px-2.5 py-1.5 shrink-0 min-w-[42px]">
<span class="text-[9px] text-slate-400 font-bold uppercase">${d.edinica || 'шт.'}</span>
<span class="text-base font-bold text-white">${d.count}</span>
</div>
</div>`).join('')
: `<div class="flex items-center justify-center gap-3 h-20 text-slate-500 opacity-60 border-2 border-dashed border-white/5 rounded-2xl">
<span class="material-symbols-outlined text-3xl">check_circle</span>
<p class="text-base font-medium">Дефектов не обнаружено</p>
</div>`;
const _listRows = (items, emptyText = 'Нет данных') => items.length
? items.map(v => `<div class="p-3 bg-white/5 rounded-xl border border-white/10 mb-2 text-sm text-slate-200 leading-relaxed">${v}</div>`).join('')
: `<div class="flex items-center justify-center gap-3 h-20 text-slate-500 opacity-60 border-2 border-dashed border-white/5 rounded-2xl"><span class="material-symbols-outlined text-3xl">info</span><p class="text-base font-medium">${emptyText}</p></div>`;
container.innerHTML = `
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<h2 class="text-xl font-bold text-white mb-1.5">${sysData.label}</h2>
<span class="px-3 py-1 bg-${color}-500/20 text-${color}-400 text-xs font-bold uppercase rounded border border-${color}-500/30">${statusText}</span>
</div>
<div class="text-right">
<p class="text-[10px] text-slate-400 uppercase font-bold mb-0.5">Износ</p>
<p class="text-3xl font-black text-white tracking-tight">${isNoAccess ? '—' : (wear !== null ? wear.toFixed(0) + '%' : '—')}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-white/5 p-3 rounded-xl border border-white/10">
<p class="text-[10px] text-slate-400 uppercase font-bold mb-1">Материал</p>
<p class="text-white font-medium text-sm">${sysData.material || 'Не указано'}</p>
</div>
<div class="bg-white/5 p-3 rounded-xl border border-white/10">
<p class="text-[10px] text-slate-400 uppercase font-bold mb-1">Тип системы</p>
<p class="text-white font-medium text-sm">${sysData.type || 'Не указано'}</p>
</div>
</div>
<div>
<div class="flex gap-1 bg-white/5 p-1 rounded-xl mb-3">${_tabBtns}</div>
<div id="systabcontent-defects">${_defectRows}</div>
<div id="systabcontent-prichina" class="hidden">${_listRows(prichinas)}</div>
<div id="systabcontent-recomend" class="hidden">${_listRows(recomends, 'Рекомендации не указаны')}</div>
<div id="systabcontent-raboti" class="hidden">${_listRows(rabotis)}</div>
</div>
</div>`;
document.querySelectorAll('.system-card').forEach(c => {
c.classList.remove('border-primary','bg-primary/10');
c.classList.add('border-transparent','bg-white/5');
});
const activeCard = document.querySelector(`.system-card[data-system="${key}"]`);
if (activeCard) {
activeCard.classList.remove('border-transparent','bg-white/5');
activeCard.classList.add('border-primary','bg-primary/10');
}
};
window.showRepairsInCard = function() {
const bid = window.currentBuildingId;
const container = document.getElementById('system-content-new');
if (!container) return;
const sysPhotos = document.getElementById('system-photos-section');
if (sysPhotos) sysPhotos.style.display = 'none';
const building = addressesData.find(b => String(b.ID) === String(bid));
const repairsRaw = building ? (building['Проведённые ремонты'] || '') : '';
document.querySelectorAll('.system-link').forEach(l => {
l.classList.remove('bg-primary','text-white','shadow-lg');
l.style.background = '';
l.style.border = '';
l.style.opacity = l.dataset.hasPhotos === '1' ? '1' : '0.4';
l.style.color = '';
});
const repairsBtn = document.getElementById('btn-repairs');
if (repairsBtn) repairsBtn.classList.add('active-repairs');
document.querySelectorAll('.system-card').forEach(c => {
c.classList.remove('border-primary','bg-primary/10');
c.classList.add('border-transparent','bg-white/5');
});
const items = repairsRaw.split(/[;\n]/).map(s => s.trim()).filter(Boolean);
const useGrid = items.length > 3;
const cardHtml = items.map(item => {
const match = item.match(/^(.+?)\s*[—–-]\s*(\d{4})$/);
const name = match ? match[1].trim() : item;
const year = match ? match[2] : '';
return `<div class="flex items-center gap-3 p-3 bg-white/5 rounded-xl border border-white/10 hover:bg-white/10 transition-colors">
<span class="material-symbols-outlined text-primary text-lg shrink-0">check_circle</span>
<div class="flex-1 min-w-0"><p class="text-white text-sm leading-snug truncate" title="${name}">${name}</p></div>
${year ? `<div class="flex flex-col items-center justify-center bg-primary/10 rounded-lg px-2 py-1 shrink-0 border border-primary/20">
<span class="text-[9px] text-slate-400 font-bold uppercase leading-none mb-0.5">Год</span>
<span class="text-base font-bold text-primary leading-none">${year}</span></div>` : ''}
</div>`;
}).join('');
container.innerHTML = `
<div class="flex flex-col h-full">
<div class="flex items-center justify-between mb-5 border-b border-white/10 pb-4">
<div>
<h2 class="text-2xl font-bold text-white mb-1.5">Проведённые ремонты</h2>
<span class="px-2.5 py-0.5 bg-blue-500/20 text-blue-400 text-xs font-bold uppercase rounded border border-blue-500/30">${items.length} позиц${items.length===1?'ия':items.length<5?'ии':'ий'}</span>
</div>
<span class="material-symbols-outlined text-4xl text-primary/40">construction</span>
</div>
<div class="flex-1 overflow-y-auto no-scrollbar ${useGrid?'grid grid-cols-2 gap-3 content-start':'flex flex-col gap-2'}">${cardHtml}</div>
</div>`;
};
// ===== Map =====
let mapFilters = { wear: '', status: '' };
let mapInitialized = false;
function getWearInfo(item) {
let pct = item['Общий износ'] || null;
if (pct === null) {
const systems = (item.systems || []).filter(s => s.wear !== null && s.wear !== undefined);
if (systems.length > 0) pct = systems.reduce((sum, s) => sum + s.wear, 0) / systems.length;
}
if (pct === null) {
const year = item['Год постройки'];
if (year) {
const age = 2026 - year;
if (age <= 10) pct = 8; else if (age <= 20) pct = 22;
else if (age <= 30) pct = 38; else if (age <= 40) pct = 55;
else if (age <= 50) pct = 68; else pct = 82;
} else pct = 50;
}
let cat, color, preset;
if (pct <= 30) { cat = 'Категория 1'; color = '#10b981'; preset = 'islands#greenCircleDotIcon'; }
else if (pct <= 50) { cat = 'Категория 2'; color = '#eab308'; preset = 'islands#yellowCircleDotIcon'; }
else if (pct <= 65) { cat = 'Категория 3'; color = '#f59e0b'; preset = 'islands#orangeCircleDotIcon'; }
else { cat = 'Категория 4'; color = '#ef4444'; preset = 'islands#redCircleDotIcon'; }
return { pct: Math.round(pct), cat, color, preset, label: `Износ ${Math.round(pct)}%` };
}
let _mapFiltersInited = false;
function initializeMap() {
if (_mapFiltersInited) return;
_mapFiltersInited = true;
const wearFilter = document.getElementById('map-filter-wear');
const statusFilter = document.getElementById('map-filter-status');
const resetBtn = document.getElementById('map-reset-filters');
if (wearFilter) wearFilter.addEventListener('change', (e) => { mapFilters.wear = e.target.value; displayMapMarkers(); });
if (statusFilter) statusFilter.addEventListener('change', (e) => { mapFilters.status = e.target.value; displayMapMarkers(); });
if (resetBtn) {
resetBtn.addEventListener('click', () => {
mapFilters = { wear: '', status: '' };
if (wearFilter) wearFilter.value = '';
if (statusFilter) statusFilter.value = '';
displayMapMarkers();
});
}
}
function createMapWhenVisible() {
if (mapInitialized) return;
const mapContainer = document.getElementById('map-container');
if (!mapContainer || mapContainer.clientWidth < 10) { setTimeout(createMapWhenVisible, 300); return; }
mapInitialized = true;
ymaps.ready(function() {
try {
// Локальные XYZ-тайлы (как у OSM): public_html/tiles/{z}/{x}/{y}.png → URL /tiles/…
try {
ymaps.layer.storage.add('localTiles#layer', function () {
return new ymaps.Layer('/tiles/%z/%x/%y.png', { projection: ymaps.projection.sphericalMercator });
});
ymaps.mapType.storage.add('localTiles#map', new ymaps.MapType('Локальные тайлы', ['localTiles#layer']));
} catch (e) { console.warn('Локальный слой тайлов:', e); }
map = new ymaps.Map('map-container', {
center: [56.9965, 40.9739],
zoom: 7,
type: 'yandex#hybrid',
controls: ['zoomControl', 'fullscreenControl']
}, { suppressMapOpenInYandex: true });
map.controls.add(new ymaps.control.TypeSelector([
'yandex#map',
'yandex#satellite',
'yandex#hybrid',
'localTiles#map'
]), { float: 'right' });
displayMapMarkers();
} catch(e) { console.error('Map init error:', e); mapInitialized = false; }
});
}
(function initMapInfoToggle() {
const btn = document.getElementById('map-info-toggle');
const body = document.getElementById('map-info-body');
const chev = document.querySelector('#map-info-wrap .map-info-chevron');
if (!btn || !body) return;
btn.addEventListener('click', function() {
const open = body.classList.toggle('hidden');
if (chev) chev.style.transform = open ? '' : 'rotate(180deg)';
});
})();
let _mapCoordsCache = null;
let mapClusterer = null;
let _placemarkById = {};
async function _loadMapCoords() {
if (_mapCoordsCache) return _mapCoordsCache;
try {
const data = await fetch('/api/coords').then(r => r.json());
_mapCoordsCache = data;
} catch(e) {
console.error('Ошибка загрузки координат:', e);
_mapCoordsCache = {};
}
return _mapCoordsCache;
}
function _getWearPreset(pct) {
if (pct === null || pct === undefined) return { color: '#64748b', label: 'Нет данных', preset: 'islands#blueCircleDotIcon' };
if (pct <= 30) return { color: '#10b981', label: `Износ ${Math.round(pct)}%`, preset: 'islands#greenCircleDotIcon' };
if (pct <= 50) return { color: '#eab308', label: `Износ ${Math.round(pct)}%`, preset: 'islands#yellowCircleDotIcon' };
if (pct <= 65) return { color: '#f59e0b', label: `Износ ${Math.round(pct)}%`, preset: 'islands#orangeCircleDotIcon' };
return { color: '#ef4444', label: `Износ ${Math.round(pct)}%`, preset: 'islands#redCircleDotIcon' };
}
function _wearColor(v) {
if (v <= 30) return '#10b981';
if (v <= 50) return '#eab308';
if (v <= 65) return '#f59e0b';
return '#ef4444';
}
function _buildSystemsBalloonHtml(systems) {
if (!systems || Object.keys(systems).length === 0) return '';
let html = '<div style="margin-top:10px;border-top:1px solid #cbd5e1;padding-top:8px;">';
html += '<div style="font-size:11px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px;">Системы</div>';
html += '<table style="width:100%;border-collapse:collapse;">';
for (let [name, val] of Object.entries(systems)) {
name = name.replace('Фасад/Стены','Фасад').replace(/^Кровля$/,'Крыша').replace(/^Подвал$/,'Подвальное помещение').replace(/^Отопление$/,'Теплоснабжение');
const c = _wearColor(val);
html += `<tr>
<td style="font-size:13px;color:#334155;padding:3px 10px 3px 0;white-space:nowrap;">${name}</td>
<td style="font-size:13px;font-weight:800;color:${c};text-align:right;padding:3px 0;white-space:nowrap;">${val}%</td>
</tr>`;
}
html += '</table></div>';
return html;
}
async function displayMapMarkers() {
if (!map) return;
const coordsData = await _loadMapCoords();
if (mapClusterer) { map.geoObjects.remove(mapClusterer); mapClusterer = null; }
_placemarkById = {};
mapClusterer = new ymaps.Clusterer({
preset: 'islands#invertedBlueClusterIcons',
groupByCoordinates: false,
clusterDisableClickZoom: false,
clusterHideIconOnBalloonOpen: false,
});
const placemarks = [];
for (const [id, info] of Object.entries(coordsData)) {
if (!info.lat || !info.lng) continue;
if (mapFilters.status && normalizeBuildingStatus(info.status) !== mapFilters.status) continue;
let pct = info.wear;
if (mapFilters.wear) {
if (pct === null || pct === undefined) { if (mapFilters.wear !== '') continue; }
else {
const p = Math.round(pct);
if (mapFilters.wear === 'cat1' && p > 30) continue;
if (mapFilters.wear === 'cat2' && (p <= 30 || p > 50)) continue;
if (mapFilters.wear === 'cat3' && (p <= 50 || p > 65)) continue;
if (mapFilters.wear === 'cat4' && p <= 65) continue;
}
}
const wearInfo = _getWearPreset(pct);
const address = info.address || `МКД #${id}`;
const mo = info.mo || '';
const pm = new ymaps.Placemark([info.lat, info.lng], {
balloonContentHeader: `<div style="font-weight:700;font-size:13px;line-height:1.3;margin-bottom:4px;">${address}</div>`,
balloonContentBody: `
<div style="font-size:11px;color:#64748b;margin-bottom:6px;">${mo}</div>
${_buildSystemsBalloonHtml(info.systems)}`,
balloonContentFooter: `<a href="#card/${id}" onclick="openCard('${id}')" style="display:inline-block;margin-top:8px;padding:8px 20px;background:#0ea5e9;color:#fff;border-radius:8px;text-decoration:none;font-size:12px;font-weight:700;width:100%;text-align:center;box-sizing:border-box;">Открыть карточку</a>`,
hintContent: `<b style="font-size:12px;">${address}</b><br><span style="color:${wearInfo.color};font-size:11px;">${wearInfo.label}</span>`
}, { preset: wearInfo.preset });
_placemarkById[id] = pm;
placemarks.push(pm);
}
mapClusterer.add(placemarks);
map.geoObjects.add(mapClusterer);
if (placemarks.length > 0) {
map.setBounds(mapClusterer.getBounds(), { checkZoomRange: true, zoomMargin: 40 });
}
}
// ===== Statistics / Analytics Dashboards =====
let _statusesDashLoading = false;
function _isStatisticsPageActive() {
const hash = (location.hash || '').replace('#', '').trim().split('/')[0];
return hash === 'statuses';
}
function _dashPct(count, total) {
if (!total) return 0;
return Math.round((count / total) * 100);
}
function _dashBarRow(label, count, total, color) {
const pct = _dashPct(count, total);
return `<div class="dash-bar-row">
<span class="dash-bar-label">${label}</span>
<div class="dash-bar-track"><div class="dash-bar-fill" style="width:${pct}%;background:${color || '#0ea5e9'}"></div></div>
<span class="dash-bar-val">${count}</span>
</div>`;
}
function _dashStack(categories, total) {
if (!total) return '<div class="dash-stack"></div>';
return `<div class="dash-stack">${categories.map(c =>
c.count ? `<span style="width:${_dashPct(c.count, total)}%;background:${c.color}"></span>` : ''
).join('')}</div>`;
}
function renderStatusesDashboard(data) {
const root = document.getElementById('statuses-dashboard-root');
if (!root || !data) return;
const total = data.total || 0;
const topStatus = (data.by_status && data.by_status[0]) ? data.by_status[0].status : '—';
const kpis = `
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="dash-kpi"><div class="text-[10px] uppercase tracking-widest text-slate-500 mb-1">Всего МКД</div><div class="text-3xl font-black text-white">${total}</div></div>
<div class="dash-kpi"><div class="text-[10px] uppercase tracking-widest text-slate-500 mb-1">Средний износ МКД</div><div class="text-3xl font-black text-white">${data.mkd_avg_wear != null ? data.mkd_avg_wear + '%' : '—'}</div></div>
<div class="dash-kpi"><div class="text-[10px] uppercase tracking-widest text-slate-500 mb-1">С данными по износу</div><div class="text-3xl font-black text-emerald-400">${data.mkd_with_wear || 0}</div></div>
<div class="dash-kpi"><div class="text-[10px] uppercase tracking-widest text-slate-500 mb-1">Чаще всего статус</div><div class="text-sm font-bold text-white leading-snug mt-1">${topStatus}</div></div>
</div>`;
const statusBars = (data.by_status || []).map((s, i) =>
_dashBarRow(s.status, s.count, total, {
'Не обследовано': '#64748b',
'Полевые работы': '#0ea5e9',
'Камеральная обработка': '#8b5cf6',
'Передано заказчику': '#f59e0b',
'Согласовано': '#22c55e',
}[s.status] || ['#0ea5e9', '#8b5cf6', '#14b8a6', '#f59e0b', '#64748b'][i % 5])
).join('');
const mkdWearBars = (data.mkd_wear_distribution || []).map(b =>
_dashBarRow(b.label, b.count, total, b.color)
).join('');
const sysCards = (data.systems || []).map(sys => {
const withData = sys.with_data || (total - (sys.no_data || 0));
const cats = sys.categories || [];
return `<div class="dash-sys-card">
<div class="flex items-start justify-between gap-2 mb-1">
<span class="text-xs font-bold text-white">${sys.label}</span>
<span class="text-[10px] font-bold text-slate-400">ср. ${sys.avg_wear != null ? sys.avg_wear + '%' : '—'}</span>
</div>
${_dashStack(cats, withData)}
<div class="flex flex-wrap gap-2 mt-2">
${cats.map(c => `<span class="text-[9px] font-bold px-1.5 py-0.5 rounded" style="background:${c.color}22;color:${c.color}">К${c.cat}: ${c.count}</span>`).join('')}
${sys.no_data ? `<span class="text-[9px] text-slate-500">нет данных: ${sys.no_data}</span>` : ''}
</div>
</div>`;
}).join('');
root.innerHTML = kpis + `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="dash-panel">
<h3 class="text-xs font-bold text-white uppercase tracking-wider mb-4">Статусы обследования</h3>
${statusBars || '<p class="text-sm text-slate-500">Нет данных</p>'}
</div>
<div class="dash-panel">
<h3 class="text-xs font-bold text-white uppercase tracking-wider mb-4">Распределение износа МКД, %</h3>
${mkdWearBars || '<p class="text-sm text-slate-500">Нет данных</p>'}
<p class="text-[10px] text-slate-500 mt-3">Без заполненного износа: ${data.mkd_no_wear || 0} · С износом: ${data.mkd_with_wear || 0}</p>
</div>
</div>
<div class="dash-panel">
<h3 class="text-xs font-bold text-white uppercase tracking-wider mb-4">Износ по системам — категории</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">${sysCards}</div>
</div>`;
}
window.initStatusesDashboard = async function() {
const root = document.getElementById('statuses-dashboard-root');
if (!root || _statusesDashLoading) return;
_statusesDashLoading = true;
root.innerHTML = `<div class="flex flex-col items-center justify-center py-20 text-slate-500">
<div class="w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
<p class="text-sm">Загрузка аналитики...</p></div>`;
try {
const resp = await fetch('/api/analytics/dashboard', { cache: 'no-store' });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const data = await resp.json();
renderStatusesDashboard(data);
} catch (e) {
root.innerHTML = `<div class="text-center py-16 text-red-400 text-sm">Не удалось загрузить дашборды: ${e.message}</div>`;
} finally {
_statusesDashLoading = false;
}
};
// ===== Page Navigation =====
(function() {
const titleById = { 'registry': 'Реестр МКД', 'card': 'Карточка МКД', 'dashboard': 'Сводные данные', 'statuses': 'Статистика', 'map': 'Карта', 'results': 'Главная', 'ai': 'Искусственный интеллект' };
function activate(pageId) {
const pageList = document.querySelectorAll('.app-page');
const targetPage = document.getElementById('page-' + pageId);
if (!targetPage) { pageId = 'results'; location.hash = '#results'; activate('results'); return; }
pageList.forEach(p => {
p.classList.remove('is-active');
setTimeout(() => { if (!p.classList.contains('is-active')) p.style.display = 'none'; }, 400);
});
targetPage.style.display = 'flex';
setTimeout(() => targetPage.classList.add('is-active'), 10);
document.querySelectorAll('aside nav a[href^="#"]').forEach(link => {
const href = (link.getAttribute('href') || '').replace('#', '').split('/')[0];
const isActive = href === pageId;
if (isActive) {
link.classList.add('bg-primary/10', 'text-primary', 'border', 'border-primary/20');
link.classList.remove('text-slate-400', 'hover:bg-slate-800/50');
const icon = link.querySelector('.material-symbols-outlined');
if (icon) { icon.style.transform = 'scale(1.2)'; setTimeout(() => icon.style.transform = 'scale(1)', 200); }
} else {
link.classList.remove('bg-primary/10', 'text-primary', 'border', 'border-primary/20', 'shadow-sm', 'shadow-primary/5');
link.classList.add('text-slate-400', 'hover:bg-slate-800/50');
}
});
const t = titleById[pageId];
document.title = t ? ('Портал ФКР — ' + t) : 'Портал ФКР';
if (pageId === 'registry') {
currentFilters.status = '';
const rsf = document.getElementById('reg-filter-status');
if (rsf) rsf.value = '';
if (addressesData.length) {
currentPage = 1;
filterAndDisplayRegistry();
}
}
if (pageId === 'map') {
mapFilters.status = '';
const msf = document.getElementById('map-filter-status');
if (msf) msf.value = '';
setTimeout(() => {
if (!mapInitialized) createMapWhenVisible();
else if (map) {
try { map.container.fitToViewport(); } catch(e) {}
displayMapMarkers();
}
}, 600);
}
if (pageId === 'card') {
const parts = (location.hash || '').replace('#', '').trim().split('/');
if (parts[1]) setTimeout(() => loadCardData(parts[1]), 100);
}
if (pageId === 'statuses') {
setTimeout(() => initStatusesDashboard(), 100);
}
}
function getCurPage() {
const h = (location.hash || '').replace('#', '').trim();
if (!h) return 'results';
return h.split('?')[0].split('/')[0];
}
window.addEventListener('hashchange', () => activate(getCurPage()));
if (!location.hash) location.hash = '#results';
activate(getCurPage());
})();
// Load data on page load with auto-retry (после авторизации)
function bootPortalUI() {
populateStatusFilterOptions();
updateMainPageStats();
}
window.startPortalApp = function () {
if (window.__portalStarted) return;
window.__portalStarted = true;
bootPortalUI();
loadDataWithRetry(1);
};
// Main search
/* ============================================================
УМНЫЙ ПОИСК — нормализация, ранжирование, подсказки
============================================================ */
(function() {
function normalize(str) {
return typeof _searchNormalize === 'function' ? _searchNormalize(str) : (str || '').toLowerCase().trim();
}
function searchTokens(raw) {
return typeof _searchTokens === 'function' ? _searchTokens(raw) : normalize(raw).split(/\s+/).filter(t => t.length >= 2 || /^\d+$/.test(t));
}
// Оценка совпадения: чем выше — тем лучше
function score(item, tokens) {
const fields = [
normalize(item['Адрес'] || ''),
normalize(item['Управляющая организация'] || ''),
normalize(item['Статус'] || ''),
normalize(item['Исполнитель'] || ''),
String(item.ID || '')
];
const fullText = fields.join(' ');
let total = 0;
for (const tok of tokens) {
if (!tok) continue;
if (/^\d+$/.test(tok)) {
const houseRe = new RegExp('(?:^|\\s)(?:дом\\s+)?' + tok + '(?:\\s|$)');
if (houseRe.test(fullText)) { total += 3; continue; }
if (fullText.includes(' ' + tok)) { total += 2; continue; }
return -1;
}
let best = 0;
for (const f of fields) {
if (f.startsWith(tok)) best = Math.max(best, 3);
else if (f.includes(tok)) best = Math.max(best, 2);
else if (tok.length >= 4 && f.split(' ').some(w => w.startsWith(tok.slice(0,-1)))) best = Math.max(best, 1);
}
if (best === 0) return -1; // Токен не найден нигде — пропускаем
total += best;
}
return total;
}
function highlight(text, tokens) {
if (!text || !tokens.length) return escHtml ? escHtml(text) : text;
let result = escHtml ? escHtml(text) : text;
tokens.forEach(tok => {
if (!tok || tok.length < 2) return;
const re = new RegExp('(' + tok.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')', 'gi');
result = result.replace(re, '<mark style="background:rgba(74,158,255,0.35);color:#fff;border-radius:3px;padding:0 2px;">$1</mark>');
});
return result;
}
function getIcon(item) {
const status = (item['Статус'] || '').toLowerCase();
if (status.includes('включен')) return '<span class="material-symbols-outlined" style="font-size:14px;color:#4ade80;">check_circle</span>';
if (status.includes('заключение')) return '<span class="material-symbols-outlined" style="font-size:14px;color:#60a5fa;">assignment</span>';
return '<span class="material-symbols-outlined" style="font-size:14px;color:rgba(180,210,255,0.4);">apartment</span>';
}
let _dropIdx = -1;
let _dropItems = [];
function renderDrop(items, rawQuery) {
const drop = document.getElementById('smart-search-drop');
if (!drop) return;
_dropItems = items;
_dropIdx = -1;
if (!items.length) {
drop.innerHTML = '<div style="padding:18px 20px;color:rgba(180,210,255,0.4);font-size:13px;text-align:center;">Ничего не найдено</div>';
drop.style.display = 'block';
return;
}
const tokens = normalize(rawQuery).split(' ').filter(t => t.length >= 2);
let html = '';
// Группируем: сначала до 6 зданий, потом уникальные исполнители / муниципалитеты
const buildings = items.slice(0, 7);
const uniqExec = [...new Set(items.map(i => i['Исполнитель']).filter(Boolean))].slice(0, 3);
const uniqMo = [...new Set(items.map(i => i['Муниципалитет']).filter(Boolean))].slice(0, 2);
if (buildings.length) {
html += `<div style="padding:8px 16px 4px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(100,160,255,0.5);">МКД</div>`;
buildings.forEach((item, i) => {
const addr = item['Адрес'] || '—';
const mo = item['Муниципалитет'] || '';
const st = item['Статус'] || '';
html += `<div class="ssd-item" data-idx="${i}" onclick="window._smartSearchGo('${item.ID}')"
style="padding:10px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);transition:background .12s;"
onmouseover="this.style.background='rgba(74,158,255,0.12)'" onmouseout="this.style.background=''">
<div style="flex-shrink:0;">${getIcon(item)}</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;color:#d0e8ff;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${highlight(addr, tokens)}</div>
<div style="font-size:11px;color:rgba(180,210,255,0.4);margin-top:2px;">${mo}${st ? ' · '+st : ''}</div>
</div>
<div style="font-size:10px;color:rgba(100,160,255,0.5);flex-shrink:0;">ID ${item.ID}</div>
</div>`;
});
}
if (uniqExec.length && normalize(rawQuery).length >= 3) {
html += `<div style="padding:8px 16px 4px;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:rgba(100,160,255,0.5);margin-top:4px;">ИСПОЛНИТЕЛИ</div>`;
uniqExec.forEach(ex => {
const cnt = items.filter(i => i['Исполнитель'] === ex).length;
html += `<div onclick="window._smartSearchExec('${ex.replace(/'/g,"\\'")}', event)"
style="padding:8px 16px;display:flex;align-items:center;gap:10px;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.04);"
onmouseover="this.style.background='rgba(74,158,255,0.12)'" onmouseout="this.style.background=''">
<span class="material-symbols-outlined" style="font-size:14px;color:#a78bfa;">person</span>
<span style="font-size:12px;color:#c4b5fd;">${highlight(ex, tokens)}</span>
<span style="margin-left:auto;font-size:10px;color:rgba(180,210,255,0.3);">${cnt} МКД</span>
</div>`;
});
}
if (items.length > 7) {
html += `<div onclick="window._smartSearchAll()" style="padding:10px 16px;text-align:center;font-size:12px;color:#4a9eff;cursor:pointer;font-weight:700;"
onmouseover="this.style.background='rgba(74,158,255,0.08)'" onmouseout="this.style.background=''">
Показать все ${items.length} результатов →
</div>`;
}
drop.innerHTML = html;
drop.style.display = 'block';
}
window._smartSearchGo = function(id) {
const drop = document.getElementById('smart-search-drop');
if (drop) drop.style.display = 'none';
try { location.hash = '#registry'; } catch (e) { location.hash = 'registry'; }
const idStr = String(id);
setTimeout(function() { applyMainQueryToRegistry(idStr); }, 30);
setTimeout(function() { applyMainQueryToRegistry(idStr); }, 120);
setTimeout(function() { applyMainQueryToRegistry(idStr); }, 350);
};
window._smartSearchExec = function(exec, e) {
if (e) e.stopPropagation();
const drop = document.getElementById('smart-search-drop');
if (drop) drop.style.display = 'none';
const inp = document.getElementById('main-search-input-new');
if (inp) inp.value = exec;
try { location.hash = '#registry'; } catch (e2) { location.hash = 'registry'; }
const q = exec || '';
setTimeout(function() { applyMainQueryToRegistry(q); }, 30);
setTimeout(function() { applyMainQueryToRegistry(q); }, 120);
setTimeout(function() { applyMainQueryToRegistry(q); }, 350);
};
window._smartSearchAll = function() {
const inp = document.getElementById('main-search-input-new');
const drop = document.getElementById('smart-search-drop');
if (drop) drop.style.display = 'none';
const q = inp ? inp.value.trim() : '';
try { location.hash = '#registry'; } catch (e) { location.hash = 'registry'; }
setTimeout(function() { applyMainQueryToRegistry(q); }, 30);
setTimeout(function() { applyMainQueryToRegistry(q); }, 120);
setTimeout(function() { applyMainQueryToRegistry(q); }, 350);
};
window.runHeroSearch = function() {
const drop = document.getElementById('smart-search-drop');
if (drop) drop.style.display = 'none';
const inp = document.getElementById('main-search-input-new');
const q = inp ? inp.value.trim() : '';
if (!q) return;
window._smartSearchAll();
};
let _searchTimer = null;
const mainSearchInputNew = document.getElementById('main-search-input-new');
const mainClearBtn = document.getElementById('main-search-clear');
const drop = document.getElementById('smart-search-drop');
function doSmartSearch(raw) {
const trimmed = raw.trim();
if (!trimmed || trimmed.length < 2) { if (drop) drop.style.display = 'none'; return; }
if (!addressesData || !addressesData.length) { window._smartSearchAll(); return; }
const tokens = searchTokens(trimmed);
if (!tokens.length) { if (drop) drop.style.display = 'none'; return; }
const scored = addressesData.map(item => ({ item, s: score(item, tokens) }))
.filter(x => x.s > 0)
.sort((a, b) => b.s - a.s);
renderDrop(scored.map(x => x.item), trimmed);
}
if (mainSearchInputNew) {
mainSearchInputNew.addEventListener('input', (e) => {
const val = e.target.value;
if (mainClearBtn) {
mainClearBtn.classList.toggle('opacity-0', !val);
mainClearBtn.classList.toggle('pointer-events-none', !val);
}
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => doSmartSearch(val), 180);
});
mainSearchInputNew.addEventListener('keydown', (e) => {
const items = drop ? drop.querySelectorAll('.ssd-item') : [];
if (e.key === 'ArrowDown') {
e.preventDefault();
_dropIdx = Math.min(_dropIdx + 1, items.length - 1);
items.forEach((el, i) => el.style.background = i === _dropIdx ? 'rgba(74,158,255,0.2)' : '');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
_dropIdx = Math.max(_dropIdx - 1, -1);
items.forEach((el, i) => el.style.background = i === _dropIdx ? 'rgba(74,158,255,0.2)' : '');
} else if (e.key === 'Enter') {
if (_dropIdx >= 0 && _dropItems[_dropIdx]) {
window._smartSearchGo(_dropItems[_dropIdx].ID);
} else {
window._smartSearchAll();
}
} else if (e.key === 'Escape') {
if (drop) drop.style.display = 'none';
}
});
mainSearchInputNew.addEventListener('focus', (e) => {
if (e.target.value.trim().length >= 2) doSmartSearch(e.target.value);
});
}
if (mainClearBtn) {
mainClearBtn.addEventListener('click', () => {
if (mainSearchInputNew) mainSearchInputNew.value = '';
mainClearBtn.classList.add('opacity-0', 'pointer-events-none');
if (drop) drop.style.display = 'none';
if (mainSearchInputNew) mainSearchInputNew.focus();
});
}
document.addEventListener('click', (e) => {
if (drop && !drop.contains(e.target) && e.target !== mainSearchInputNew) {
drop.style.display = 'none';
}
});
})();
function updateMainPageStats() {
const el = document.getElementById('main-total-objects');
fetch('/api/stats').then(r => r.json()).then(data => {
if (el && data.total_buildings != null) {
el.textContent = Number(data.total_buildings).toLocaleString('ru-RU');
}
}).catch(() => {
if (el && addressesData.length) {
el.textContent = addressesData.length.toLocaleString('ru-RU');
}
});
}
// Галерейное состояние лайтбокса
const _photoGallery = { items: [], index: 0 };
function _openGallery(items, startIdx) {
_photoGallery.items = items.filter(Boolean);
_photoGallery.index = startIdx;
const hasMany = _photoGallery.items.length > 1;
document.getElementById('modal-prev')?.classList.toggle('hidden', !hasMany);
document.getElementById('modal-next')?.classList.toggle('hidden', !hasMany);
const counter = document.getElementById('modal-counter');
if (counter) { counter.classList.toggle('hidden', !hasMany); counter.textContent = (startIdx + 1) + ' / ' + _photoGallery.items.length; }
openPhotoModal(_photoGallery.items[startIdx]);
}
window.openOverviewGallery = function(idx) {
const gallery = document.getElementById('photo-gallery-new');
if (!gallery) return;
const items = Array.from(gallery.querySelectorAll('[data-gallery-idx]')).map(el => el.querySelector('img')?.src).filter(Boolean);
_openGallery(items, idx);
};
window.switchSysTab = function(tabId) {
document.querySelectorAll('.systab-btn').forEach(b => {
const active = b.id === 'systab-' + tabId;
b.classList.toggle('bg-primary', active);
b.classList.toggle('text-white', active);
b.classList.toggle('shadow', active);
b.classList.toggle('text-slate-400', !active);
b.classList.toggle('hover\\:text-white', !active);
b.classList.toggle('hover\\:bg-white\\/10', !active);
});
['defects','prichina','recomend','raboti'].forEach(id => {
const el = document.getElementById('systabcontent-' + id);
if (el) el.classList.toggle('hidden', id !== tabId);
});
};
window.navigatePhoto = function(dir) {
const next = _photoGallery.index + dir;
if (next < 0 || next >= _photoGallery.items.length) return;
_photoGallery.index = next;
const counter = document.getElementById('modal-counter');
if (counter) counter.textContent = (next + 1) + ' / ' + _photoGallery.items.length;
openPhotoModal(_photoGallery.items[next]);
};
window.downloadCurrentPhoto = function() {
const img = document.getElementById('modal-photo');
if (!img || !img.src || img.src === window.location.href) return;
const a = document.createElement('a');
a.href = img.src;
a.download = img.src.split('/').pop().split('?')[0] || 'photo.jpg';
a.target = '_blank';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
// Photo Modal with zoom/drag
const _zoom = { scale: 1, tx: 0, ty: 0, dragging: false, sx: 0, sy: 0, ox: 0, oy: 0 };
function _applyZoom(img) {
img.style.transform = `translate(${_zoom.tx}px,${_zoom.ty}px) scale(${_zoom.scale})`;
img.style.cursor = _zoom.scale > 1 ? 'grab' : 'zoom-in';
}
function _resetZoom(img) {
_zoom.scale = 1; _zoom.tx = 0; _zoom.ty = 0; _zoom.dragging = false; _zoom.rotate = 0;
if (img) { img.style.transform = ''; img.style.cursor = 'zoom-in'; }
}
function _applyZoomFull(img) {
if (!img) return;
const r = _zoom.rotate || 0;
img.style.transform = `translate(${_zoom.tx}px,${_zoom.ty}px) scale(${_zoom.scale}) rotate(${r}deg)`;
img.style.cursor = _zoom.scale > 1 ? 'grab' : 'default';
}
window.photoZoom = function(delta) {
const img = document.getElementById('modal-photo');
if (!img) return;
_zoom.scale = Math.min(8, Math.max(0.2, _zoom.scale + delta));
_applyZoomFull(img);
};
window.photoFit = function() {
const img = document.getElementById('modal-photo');
if (!img) return;
_zoom.scale = 1; _zoom.tx = 0; _zoom.ty = 0;
_applyZoomFull(img);
};
window.photoRotate = function(deg) {
const img = document.getElementById('modal-photo');
if (!img) return;
_zoom.rotate = ((_zoom.rotate || 0) + deg + 360) % 360;
_applyZoomFull(img);
};
window.openPhotoModal = function(url) {
const modal = document.getElementById('photo-modal');
const img = document.getElementById('modal-photo');
if (modal && img) {
_zoom.rotate = 0;
_resetZoom(img);
img.src = '';
img.setAttribute('referrerpolicy', 'no-referrer');
img.src = url;
modal.classList.remove('hidden');
modal.classList.add('flex');
document.body.style.overflow = 'hidden';
requestAnimationFrame(() => {
modal.classList.remove('opacity-0');
modal.classList.add('opacity-100');
img.classList.remove('scale-90');
img.classList.add('scale-100');
});
}
};
window.closePhotoModal = function() {
const modal = document.getElementById('photo-modal');
const img = document.getElementById('modal-photo');
if (modal && img) {
_resetZoom(img);
modal.classList.add('opacity-0');
modal.classList.remove('opacity-100');
img.classList.remove('scale-100');
img.classList.add('scale-90');
document.body.style.overflow = 'auto';
setTimeout(() => {
modal.classList.add('hidden');
modal.classList.remove('flex');
img.src = '';
img.style.opacity = '1';
const spinner = document.getElementById('modal-loading-spinner');
if (spinner) spinner.style.cssText = 'display:none!important';
document.getElementById('modal-prev')?.classList.add('hidden');
document.getElementById('modal-next')?.classList.add('hidden');
document.getElementById('modal-counter')?.classList.add('hidden');
}, 300);
}
};
document.addEventListener('keydown', (e) => {
const schemaModal = document.getElementById('schema-viewer-modal');
if (schemaModal && !schemaModal.classList.contains('hidden')) {
if (e.key === 'Escape') { closeSchemaViewer(); return; }
}
const modal = document.getElementById('photo-modal');
if (!modal || modal.classList.contains('hidden')) return;
if (e.key === 'Escape') closePhotoModal();
if (e.key === 'ArrowRight') { e.preventDefault(); window.navigatePhoto && navigatePhoto(1); }
if (e.key === 'ArrowLeft') { e.preventDefault(); window.navigatePhoto && navigatePhoto(-1); }
if (e.key === '0') { const img = document.getElementById('modal-photo'); _resetZoom(img); }
});
(function initPhotoZoom() {
const modal = document.getElementById('photo-modal');
const img = document.getElementById('modal-photo');
if (!modal || !img) return;
// Zoom on scroll
modal.addEventListener('wheel', (e) => {
if (modal.classList.contains('hidden')) return;
e.preventDefault();
const delta = e.deltaY > 0 ? 0.85 : 1.18;
_zoom.scale = Math.min(Math.max(_zoom.scale * delta, 1), 8);
if (_zoom.scale === 1) { _zoom.tx = 0; _zoom.ty = 0; }
_applyZoom(img);
}, { passive: false });
// Double-click to zoom in/reset
img.addEventListener('dblclick', (e) => {
if (_zoom.scale > 1) { _resetZoom(img); }
else {
_zoom.scale = 2.5;
const rect = img.getBoundingClientRect();
_zoom.tx = (rect.width / 2 - (e.clientX - rect.left)) * 1.5;
_zoom.ty = (rect.height / 2 - (e.clientY - rect.top)) * 1.5;
_applyZoom(img);
}
});
// Drag to pan
img.addEventListener('mousedown', (e) => {
if (_zoom.scale <= 1) return;
e.preventDefault();
_zoom.dragging = true;
_zoom.sx = e.clientX - _zoom.tx;
_zoom.sy = e.clientY - _zoom.ty;
img.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (!_zoom.dragging) return;
_zoom.tx = e.clientX - _zoom.sx;
_zoom.ty = e.clientY - _zoom.sy;
img.style.transform = `translate(${_zoom.tx}px,${_zoom.ty}px) scale(${_zoom.scale})`;
});
document.addEventListener('mouseup', () => {
if (_zoom.dragging) { _zoom.dragging = false; _applyZoom(img); }
});
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) closePhotoModal();
});
})();
// ════════════════════════════════════════════════════════
// SCHEMA VIEWER — интерактивный просмотр схем с маркерами
// ════════════════════════════════════════════════════════
window._schemaViewerState = {
buildingId: null, address: null, yadiskRoot: null,
schemeNumber: null, markersCache: null
};
window.openSchemaViewer = function(ydPath, schemeNumber, buildingId, address, yadiskRoot) {
const state = window._schemaViewerState;
state.buildingId = buildingId;
state.address = address;
state.yadiskRoot = yadiskRoot || 'Ленинградская область';
state.schemeNumber = schemeNumber;
state.zoom = 1;
const modal = document.getElementById('schema-viewer-modal');
const title = document.getElementById('schema-viewer-title');
const loading = document.getElementById('schema-loading');
const canvas = document.getElementById('schema-canvas');
const layer = document.getElementById('schema-markers-layer');
const countEl = document.getElementById('schema-marker-count');
if (title) title.textContent = 'Схема ' + schemeNumber;
if (countEl) countEl.textContent = '';
if (layer) layer.innerHTML = '';
if (loading) loading.style.display = '';
if (canvas) { canvas.width = 10; canvas.height = 10; }
if (modal) { modal.classList.remove('hidden'); modal.classList.add('flex'); }
// Закрываем панель дефекта при смене схемы
const defectPanel = document.getElementById('schema-defect-panel');
if (defectPanel) defectPanel.style.display = 'none';
const pdfUrl = '/api/yadisk/serve?path=' + encodeURIComponent(ydPath);
// Запускаем PDF и маркеры параллельно
const markersReady = (!state.markersCache || state.markersCache._bid !== String(buildingId))
? fetch('/api/buildings/' + buildingId + '/schema-markers')
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data) { state.markersCache = data.markers || []; state.markersCache._bid = String(buildingId); }
}).catch(() => {})
: Promise.resolve();
_renderSchemaPdf(canvas, pdfUrl).then(function(scale) {
state.renderScale = scale;
if (loading) loading.style.display = 'none';
// Маркеры уже загружены (или загружаются), ждём и расставляем
markersReady.then(() => _loadAndPlaceMarkers(buildingId, schemeNumber, scale));
}).catch(function(err) {
console.error('Ошибка рендеринга схемы:', err);
if (loading) loading.style.display = 'none';
if (canvas) {
const ctx = canvas.getContext('2d');
canvas.width = 600; canvas.height = 100;
ctx.fillStyle = '#1e293b';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ef4444';
ctx.font = '16px sans-serif';
ctx.fillText('Ошибка загрузки схемы', 20, 55);
}
});
};
window.closeSchemaViewer = function() {
const modal = document.getElementById('schema-viewer-modal');
if (modal) { modal.classList.add('hidden'); modal.classList.remove('flex'); }
};
window.zoomSchema = function(delta) {
const state = window._schemaViewerState;
const cur = state.zoom || 1;
const next = Math.min(Math.max(cur + delta, 0.25), 5);
state.zoom = next;
const wrap = document.getElementById('schema-canvas-wrap');
if (wrap) { wrap.style.transformOrigin = 'top center'; wrap.style.transform = 'scale(' + next + ')'; }
const lbl = document.getElementById('schema-zoom-label');
if (lbl) lbl.textContent = Math.round(next * 100) + '%';
};
window.resetSchemaZoom = function() {
window._schemaViewerState.zoom = 1;
const wrap = document.getElementById('schema-canvas-wrap');
if (wrap) { wrap.style.transform = ''; }
const lbl = document.getElementById('schema-zoom-label');
if (lbl) lbl.textContent = '100%';
};
// Зум колёсиком мыши (Ctrl+scroll или просто scroll в schema area)
(function() {
const area = document.getElementById('schema-scroll-area');
if (!area) return;
area.addEventListener('wheel', function(e) {
const schemaModal = document.getElementById('schema-viewer-modal');
if (!schemaModal || schemaModal.classList.contains('hidden')) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
zoomSchema(e.deltaY > 0 ? -0.15 : 0.15);
}
}, { passive: false });
})();
// Рендер миниатюры PDF в карточку галереи
async function _renderSchemaThumb(container, pdfUrl) {
try {
const pdfjsLib = window['pdfjs-dist/build/pdf'];
if (!pdfjsLib) return;
pdfjsLib.GlobalWorkerOptions.workerSrc =
'assets/vendor/pdf.worker.min.js';
const pdf = await pdfjsLib.getDocument(pdfUrl).promise;
const page = await pdf.getPage(1);
const viewport1 = page.getViewport({ scale: 1 });
// Масштабируем под ширину контейнера
const targetW = container.clientWidth || 160;
const scale = targetW / viewport1.width;
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport }).promise;
container.innerHTML = '';
container.appendChild(canvas);
} catch(e) {
// Оставляем placeholder-иконку при ошибке
}
}
async function _renderSchemaPdf(canvas, pdfUrl) {
const pdfjsLib = window['pdfjs-dist/build/pdf'];
if (!pdfjsLib) throw new Error('PDF.js не загружен');
pdfjsLib.GlobalWorkerOptions.workerSrc =
'assets/vendor/pdf.worker.min.js';
const pdf = await pdfjsLib.getDocument(pdfUrl).promise;
const page = await pdf.getPage(1);
const viewport1 = page.getViewport({ scale: 1 });
const scrollArea = document.getElementById('schema-scroll-area');
const availW = scrollArea ? scrollArea.clientWidth - 48 : window.innerWidth - 48;
const availH = scrollArea ? scrollArea.clientHeight - 48 : window.innerHeight - 100;
const scaleW = availW / viewport1.width;
const scaleH = availH / viewport1.height;
const scale = Math.min(scaleW, scaleH, 2.5);
const viewport = page.getViewport({ scale });
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport }).promise;
// Синхронизируем размер слоя маркеров
const layer = document.getElementById('schema-markers-layer');
if (layer) { layer.style.width = canvas.width + 'px'; layer.style.height = canvas.height + 'px'; }
return scale;
}
async function _loadAndPlaceMarkers(buildingId, schemeNumber, renderScale) {
const state = window._schemaViewerState;
// Если кэш ещё не готов — запрашиваем (fallback)
if (!state.markersCache || state.markersCache._bid !== String(buildingId)) {
try {
const resp = await fetch('/api/buildings/' + buildingId + '/schema-markers');
if (!resp.ok) return;
const data = await resp.json();
state.markersCache = data.markers || [];
state.markersCache._bid = String(buildingId);
} catch(e) {
console.warn('Ошибка маркеров:', e);
return;
}
}
const layer = document.getElementById('schema-markers-layer');
if (!layer) return;
layer.innerHTML = '';
layer.style.pointerEvents = 'none';
const filtered = state.markersCache.filter(function(m) {
return m.scheme_number === schemeNumber;
});
const countEl = document.getElementById('schema-marker-count');
if (countEl) {
const n = filtered.length;
countEl.textContent = n ? n + (n === 1 ? ' дефект' : n < 5 ? ' дефекта' : ' дефектов') : '';
}
filtered.forEach(function(m) {
const wrap = document.createElement('div');
wrap.className = 'schema-marker-wrap';
const PIN_H = 28; // высота иконки location_on (font-size в .s-pin-icon)
const PIN_Y_OFFSET = 3; // смещение меток вниз относительно координаты (подгонка под PDF)
wrap.style.left = (m.coord_x * renderScale) + 'px';
wrap.style.top = (m.coord_y * renderScale - PIN_H / 2 + PIN_Y_OFFSET) + 'px';
// Teal badge with photo number
const badge = document.createElement('div');
badge.className = 's-pin-badge';
badge.textContent = m.photo_numbers.join(', ');
// Red location pin below
const pinIcon = document.createElement('span');
pinIcon.className = 'material-symbols-outlined s-pin-icon';
pinIcon.textContent = 'location_on';
wrap.appendChild(badge);
wrap.appendChild(pinIcon);
const marker = m;
wrap.addEventListener('mouseenter', function(e) {
e.stopPropagation();
document.querySelectorAll('.schema-marker-wrap').forEach(w => {
w.classList.remove('hovered');
w.style.zIndex = 20;
});
wrap.classList.add('hovered');
wrap.style.zIndex = 40;
});
wrap.addEventListener('mouseleave', function() {
wrap.classList.remove('hovered');
wrap.style.zIndex = 20;
});
wrap.onclick = function(e) {
e.stopPropagation();
document.querySelectorAll('.schema-marker-wrap').forEach(w => w.classList.remove('active'));
wrap.classList.add('active');
openDefectPanel(marker);
};
layer.appendChild(wrap);
});
}
function openDefectPanel(marker) {
const state = window._schemaViewerState;
const root = state.yadiskRoot || 'Ленинградская область';
const address = state.address || '';
const sistema = marker.sistema || '';
const panel = document.getElementById('schema-defect-panel');
if (panel) panel.style.display = '';
const systemTitle = document.getElementById('defect-panel-system');
if (systemTitle) systemTitle.textContent = marker.sistema || 'Дефект';
// ─── Photos ───────────────────────────────────────────
const photosEl = document.getElementById('schema-defect-photos');
if (photosEl) {
photosEl.innerHTML = '';
const nums = marker.photo_numbers || [];
if (nums.length) {
const row = document.createElement('div');
row.className = 'flex gap-2';
nums.forEach(function(n) {
const fp = 'disk:/' + root + '/' + address
+ '/Дефектная ведомость/' + sistema + '/Фото №' + n + '.jpeg';
const thumbUrl = '/api/yadisk/thumb?path=' + encodeURIComponent(fp) + '&size=L';
const serveUrl = '/api/yadisk/serve?path=' + encodeURIComponent(fp);
const cell = document.createElement('div');
cell.className = 'flex flex-col items-center gap-1';
const img = document.createElement('img');
img.src = thumbUrl;
img.className = 'w-28 h-20 object-cover rounded-lg cursor-pointer border border-white/10 hover:border-primary/50 transition-all';
img.onclick = function() {
const allUrls = nums.map(function(nn) {
const ffp = 'disk:/' + root + '/' + address
+ '/Дефектная ведомость/' + sistema + '/Фото №' + nn + '.jpeg';
return '/api/yadisk/serve?path=' + encodeURIComponent(ffp);
});
_openGallery(allUrls, nums.indexOf(n));
};
img.onerror = function() { cell.style.display = 'none'; };
const lbl = document.createElement('div');
lbl.className = 'text-[9px] text-slate-400 font-bold';
lbl.textContent = 'Фото №' + n;
cell.appendChild(img);
cell.appendChild(lbl);
row.appendChild(cell);
});
photosEl.appendChild(row);
} else {
photosEl.innerHTML = '<p class="text-xs text-slate-500 py-1">Фото не прикреплены</p>';
}
}
// ─── Defect tab ────────────────────────────────────────
const defectEl = document.getElementById('dtab-content-defects');
if (defectEl) {
defectEl.innerHTML = `<div class="flex items-start gap-3 p-3 bg-white/5 rounded-xl border border-white/10">
<span class="material-symbols-outlined text-orange-400 text-xl shrink-0 mt-0.5">warning</span>
<div class="flex flex-col gap-1.5 min-w-0">
<span class="text-white text-sm font-medium leading-snug">${marker.defect || '—'}</span>
${marker.raspolag ? `<span class="text-slate-400 text-xs">${marker.raspolag}</span>` : ''}
${marker.category ? `<span class="text-xs px-2 py-0.5 rounded-full bg-orange-500/20 text-orange-400 border border-orange-500/30 self-start mt-0.5">${marker.category}</span>` : ''}
</div>
</div>`;
}
// ─── Causes tab ────────────────────────────────────────
const causesEl = document.getElementById('dtab-content-causes');
if (causesEl) {
causesEl.innerHTML = marker.prichina
? `<p class="text-slate-300 text-sm leading-relaxed">${marker.prichina}</p>`
: '<p class="text-slate-500 text-xs">Не указано</p>';
}
// ─── Works tab ─────────────────────────────────────────
const worksEl = document.getElementById('dtab-content-works');
if (worksEl) {
worksEl.innerHTML = marker.raboti_po_172
? `<p class="text-slate-300 text-sm leading-relaxed">${marker.raboti_po_172}</p>`
: '<p class="text-slate-500 text-xs">Не указано</p>';
}
switchDefectTab('defects');
}
window.closeDefectPanel = function() {
const panel = document.getElementById('schema-defect-panel');
if (panel) panel.style.display = 'none';
document.querySelectorAll('.schema-marker-wrap').forEach(w => w.classList.remove('active'));
};
window.switchDefectTab = function(tab) {
['defects', 'causes', 'works'].forEach(function(t) {
const btn = document.getElementById('dtab-' + t);
const cont = document.getElementById('dtab-content-' + t);
const active = t === tab;
if (btn) { btn.classList.toggle('dtab-active', active); btn.classList.toggle('text-primary', active); btn.classList.toggle('text-slate-400', !active); }
if (cont) cont.style.display = active ? '' : 'none';
});
};
</script>
<!-- ═══════════════ ИИ: логика страницы ═══════════════ -->
<script>
(function() {
let _aiData = null;
// Инициализация страницы ИИ
// Подсчёт рабочих дней между двумя датами
function countWorkdays(start, end) {
let count = 0, d = new Date(start);
const e = new Date(end);
while (d <= e) {
const dow = d.getDay();
if (dow !== 0 && dow !== 6) count++;
d.setDate(d.getDate() + 1);
}
return count;
}
// Добавить N рабочих дней к дате
function addWorkdays(startStr, n) {
const d = new Date(startStr);
let added = 0;
while (added < n) {
d.setDate(d.getDate() + 1);
if (d.getDay() !== 0 && d.getDay() !== 6) added++;
}
return d.toISOString().split('T')[0];
}
// Пресеты периодов (в рабочих днях)
const AI_PRESETS = { week: 5, '2weeks': 10, month: 22, quarter: 66 };
const AI_PRESET_LABELS = { week: '1 неделя', '2weeks': '2 недели', month: '1 месяц', quarter: '3 месяца' };
window.aiSetPreset = function(preset) {
const startInput = document.getElementById('ai-start-date');
const endInput = document.getElementById('ai-end-date');
const start = startInput.value || new Date().toISOString().split('T')[0];
if (!startInput.value) startInput.value = start;
const days = AI_PRESETS[preset] || 22;
endInput.value = addWorkdays(start, days - 1);
// Подсветить активный пресет
document.querySelectorAll('.ai-preset-btn').forEach(btn => {
const isActive = btn.dataset.preset === preset;
btn.style.background = isActive ? 'rgba(0,120,255,0.25)' : 'rgba(74,158,255,0.1)';
btn.style.borderColor = isActive ? 'rgba(0,170,255,0.7)' : 'rgba(74,158,255,0.4)';
btn.style.color = isActive ? '#fff' : 'rgba(180,210,255,0.9)';
});
aiUpdateWorkdays();
};
window.aiUpdateWorkdays = function() {
const s = document.getElementById('ai-start-date')?.value;
const e = document.getElementById('ai-end-date')?.value;
const badge = document.getElementById('ai-workdays-badge');
if (s && e && s <= e) {
const wd = countWorkdays(s, e);
badge.textContent = `${wd} раб. ${wd === 1 ? 'день' : wd < 5 ? 'дня' : 'дней'}`;
badge.style.display = 'inline-block';
} else {
badge.style.display = 'none';
}
};
// ── Переключение табов ──────────────────────────────────
let _aiCurrentTab = '';
window.aiSwitchTab = function(tab) {
const tabs = ['defects', 'staff', 'schedule'];
tabs.forEach(t => {
const btn = document.getElementById('ai-tab-' + t);
const sec = document.getElementById('ai-section-' + t);
const active = t === tab;
if (btn) {
btn.style.background = active ? 'linear-gradient(135deg,#0056b2,#0099ff)' : 'rgba(74,158,255,0.15)';
btn.style.borderColor = active ? 'rgba(0,150,255,0.8)' : 'rgba(74,158,255,0.5)';
btn.style.boxShadow = active ? '0 4px 20px rgba(0,120,255,0.4)' : 'none';
}
if (sec) { sec.style.display = active ? 'flex' : 'none'; if(active) { sec.style.flexDirection='column'; sec.style.alignItems='center'; } }
});
_aiCurrentTab = tab;
// Прокрутить к секции
const mainEl = document.querySelector('#ai-page-wrap main');
if (mainEl) mainEl.scrollTo({ top: 300, behavior: 'smooth' });
};
/* ---- Custom multiselect helpers (global) ---- */
window._msData = window._msData || {};
window.msInit = function(boxId, dropId, items) {
window._msData[boxId] = { options: items, selected: [] };
const drop = document.getElementById(dropId);
if (!drop) return;
drop.innerHTML = '';
items.forEach(val => {
const el = document.createElement('div');
el.className = 'ms-opt';
el.dataset.val = val;
el.innerHTML = '<span class="ms-opt-check"></span>' + val;
el.addEventListener('click', function(e) { e.stopPropagation(); window.msSelect(boxId, dropId, val); });
drop.appendChild(el);
});
};
window.msSelect = function(boxId, dropId, val) {
const d = window._msData[boxId];
if (!d) return;
const idx = d.selected.indexOf(val);
if (idx === -1) d.selected.push(val);
else d.selected.splice(idx, 1);
window.msRender(boxId, dropId);
};
window.msRemove = function(boxId, dropId, val, e) {
if (e) { e.stopPropagation(); e.preventDefault(); }
const d = window._msData[boxId];
if (!d) return;
d.selected = d.selected.filter(v => v !== val);
window.msRender(boxId, dropId);
};
window.msRender = function(boxId, dropId) {
const d = window._msData[boxId];
if (!d) return;
const box = document.getElementById(boxId);
const drop = document.getElementById(dropId);
if (!box || !drop) return;
const chipsDiv = box.querySelector('.ms-chips');
if (!chipsDiv) return;
if (d.selected.length === 0) {
chipsDiv.innerHTML = '<span class="ms-placeholder">' + (box.dataset.placeholder || 'Выберите...') + '</span>';
} else {
chipsDiv.innerHTML = d.selected.map(function(v) {
const safe = v.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
return '<span class="ms-chip" title="' + v + '">'
+ '<span style="overflow:hidden;text-overflow:ellipsis;">' + v + '</span>'
+ '<button class="ms-chip-x" onclick="window.msRemove(\'' + boxId + '\',\'' + dropId + '\',\'' + safe + '\',event)">\u00d7</button>'
+ '</span>';
}).join('');
}
drop.querySelectorAll('.ms-opt').forEach(function(el) {
el.classList.toggle('selected', d.selected.includes(el.dataset.val));
});
const inp = box.parentElement ? box.parentElement.querySelector('input[type=hidden]') : null;
if (inp) inp.value = d.selected.join(',');
};
function _msCloseAll() {
document.querySelectorAll('.ms-box.open').forEach(function(b) {
b.classList.remove('open');
});
document.querySelectorAll('.ms-dropdown-portal').forEach(function(d) {
d.style.display = 'none';
d.classList.remove('open');
});
}
window.msToggle = function(boxId, e) {
if (e) e.stopPropagation();
const box = document.getElementById(boxId);
if (!box) return;
const wasOpen = box.classList.contains('open');
_msCloseAll();
if (!wasOpen) {
box.classList.add('open');
const dropId = boxId + '-drop';
let dropEl = document.getElementById(dropId);
if (dropEl) {
// Move to body to escape transform stacking contexts
if (dropEl.parentElement !== document.body) {
dropEl.classList.add('ms-dropdown-portal');
document.body.appendChild(dropEl);
}
// Recalculate position after potential DOM move
requestAnimationFrame(function() {
const rect = box.getBoundingClientRect();
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
dropEl.style.position = 'absolute';
dropEl.style.top = (rect.bottom + scrollY + 4) + 'px';
dropEl.style.left = (rect.left + scrollX) + 'px';
dropEl.style.width = rect.width + 'px';
dropEl.style.display = 'block';
dropEl.classList.add('open');
});
}
}
};
document.addEventListener('click', function() { _msCloseAll(); });
// Close on scroll only outside of dropdown
document.addEventListener('scroll', function(e) {
if (e.target && e.target.closest && e.target.closest('.ms-dropdown')) return;
_msCloseAll();
}, true);
// Prevent wheel scroll from closing the dropdown
document.addEventListener('wheel', function(e) {
if (e.target && e.target.closest && e.target.closest('.ms-dropdown')) {
e.stopPropagation();
}
}, true);
/* ---- End custom multiselect ---- */
async function initAIPage() {
const today = new Date().toISOString().split('T')[0];
const startInput = document.getElementById('ai-start-date');
if (startInput && !startInput.value) {
startInput.value = today;
aiSetPreset('month');
}
// Загружаем муниципалитеты во все фильтры
const moSelectors = ['ai-mo-filter', 'def-mo-filter', 'staff-mo-filter'];
const mos = await fetch('/api/municipalities').then(r => r.json()).catch(() => []);
moSelectors.forEach(selId => {
const moSel = document.getElementById(selId);
if (moSel && moSel.options.length <= 1) {
mos.forEach(mo => {
const o = document.createElement('option');
o.value = mo; o.textContent = mo;
moSel.appendChild(o);
});
}
});
// Загружаем инженеров в кастомный мультиселект
if (!window._msData['ms-executor'] || window._msData['ms-executor'].options.length === 0) {
const execs = await fetch('/api/ai/executors').then(r => r.json()).catch(() => []);
const execList = (execs.executors || execs || []).filter(Boolean);
const execBox = document.getElementById('ms-executor');
if (execBox) execBox.dataset.placeholder = 'Все инженеры...';
window.msInit('ms-executor', 'ms-executor-drop', execList);
window.msRender('ms-executor', 'ms-executor-drop');
}
// Загружаем камеральщиков
if (!window._msData['ms-kamer'] || window._msData['ms-kamer'].options.length === 0) {
const kamers = await fetch('/api/ai/kameralshchiki').then(r => r.json()).catch(() => []);
if (kamers && typeof kamers === 'object' && kamers.error) console.warn('[kameralshchiki]', kamers);
const kamerList = (Array.isArray(kamers) ? kamers : []).filter(k => k && typeof k === 'string');
const kamerBox = document.getElementById('ms-kamer');
if (kamerBox) kamerBox.dataset.placeholder = 'Все камеральщики...';
window.msInit('ms-kamer', 'ms-kamer-drop', kamerList);
window.msRender('ms-kamer', 'ms-kamer-drop');
}
// Загружаем координаторов
if (!window._msData['ms-koord'] || window._msData['ms-koord'].options.length === 0) {
const koords = await fetch('/api/ai/koordinatory').then(r => r.json()).catch(() => []);
if (koords && typeof koords === 'object' && koords.error) console.warn('[koordinatory]', koords);
const koordList = (Array.isArray(koords) ? koords : []).filter(k => k && typeof k === 'string');
const koordBox = document.getElementById('ms-koord');
if (koordBox) koordBox.dataset.placeholder = 'Все координаторы...';
window.msInit('ms-koord', 'ms-koord-drop', koordList);
window.msRender('ms-koord', 'ms-koord-drop');
}
// Загружаем экспертов
if (!window._msData['ms-expert'] || window._msData['ms-expert'].options.length === 0) {
const experts = await fetch('/api/ai/eksperty').then(r => r.json()).catch(() => []);
if (experts && typeof experts === 'object' && experts.error) console.warn('[eksperty]', experts);
const expertList = (Array.isArray(experts) ? experts : []).filter(e => e && typeof e === 'string');
const expertBox = document.getElementById('ms-expert');
if (expertBox) expertBox.dataset.placeholder = 'Все эксперты...';
window.msInit('ms-expert', 'ms-expert-drop', expertList);
window.msRender('ms-expert', 'ms-expert-drop');
}
// Показываем первый таб по умолчанию
if (!_aiCurrentTab) aiSwitchTab('schedule');
}
window.generateAISchedule = async function() {
const btn = document.getElementById('ai-generate-btn');
const startDate = document.getElementById('ai-start-date')?.value;
const endDate = document.getElementById('ai-end-date')?.value;
const mo = document.getElementById('ai-mo-filter')?.value || '';
const selectedExecs = (window._msData['ms-executor']?.selected || []).filter(Boolean);
if (!startDate || !endDate) { alert('Укажите дату начала и окончания'); return; }
if (startDate > endDate) { alert('Дата начала не может быть позже даты окончания'); return; }
// Скрываем результаты, показываем загрузку
btn.disabled = true;
btn.innerHTML = '<span class="material-symbols-outlined text-base animate-spin">autorenew</span>Анализирую...';
document.getElementById('ai-loading').classList.remove('hidden');
document.getElementById('ai-loading').classList.add('flex');
['ai-summary-block','ai-stats-block','ai-schedule-block','ai-export-block'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
// Возвращаем центрирование пока нет результатов
const mainEl = document.querySelector('#ai-page-wrap main');
if (mainEl) mainEl.style.justifyContent = 'center';
try {
const selectedKamers = (window._msData['ms-kamer']?.selected || []).filter(Boolean);
const selectedKoords = (window._msData['ms-koord']?.selected || []).filter(Boolean);
const selectedExperts = (window._msData['ms-expert']?.selected || []).filter(Boolean);
const params = new URLSearchParams({ start_date: startDate, end_date: endDate });
if (mo) params.set('mo', mo);
if (selectedExecs.length) params.set('executor', selectedExecs.join(','));
if (selectedKamers.length) params.set('kameralshchiki', selectedKamers.join(','));
if (selectedKoords.length) params.set('koordinatory', selectedKoords.join(','));
if (selectedExperts.length) params.set('eksperty', selectedExperts.join(','));
const data = await fetch('/api/ai/schedule?' + params).then(r => r.json());
_aiData = data;
renderAIResults(data);
} catch(e) {
alert('Ошибка генерации: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<span class="material-symbols-outlined text-base">auto_awesome</span>Сгенерировать';
document.getElementById('ai-loading').classList.add('hidden');
document.getElementById('ai-loading').classList.remove('flex');
}
};
function showEl(id, displayVal) {
const el = document.getElementById(id);
if (el) el.style.display = displayVal || 'block';
}
function hideEl(id) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
function normalizeScheduleBuildings(schedule) {
(schedule || []).forEach(row => {
if (!Array.isArray(row.buildings)) row.buildings = [];
const b = row.buildings;
while (b.length < 2) b.push(null);
b.length = 2;
});
}
// Хранилище удалённых ячеек: ключ = "rowIdx:slot", значение = объект здания
const _aiDeleted = {};
window.aiRemoveBuildingSlot = function(rowIdx, slot) {
if (!_aiData?.schedule?.[rowIdx]) return;
const b = _aiData.schedule[rowIdx].buildings[slot];
if (b) _aiDeleted[rowIdx + ':' + slot] = b;
_aiData.schedule[rowIdx].buildings[slot] = null;
renderAIResults(_aiData);
filterAITable();
};
window.aiRestoreBuildingSlot = function(rowIdx, slot) {
const key = rowIdx + ':' + slot;
if (!_aiDeleted[key] || !_aiData?.schedule?.[rowIdx]) return;
_aiData.schedule[rowIdx].buildings[slot] = _aiDeleted[key];
delete _aiDeleted[key];
renderAIResults(_aiData);
filterAITable();
};
// Закрыть все открытые дропдауны замены
function closeAllSwapDropdowns() {
document.querySelectorAll('.ai-swap-dropdown').forEach(d => d.remove());
}
document.addEventListener('click', function(e) {
// Закрываем только если клик не по кнопке замены и не внутри дропдауна
if (!e.target.closest('.ai-mkd-btn-swap') && !e.target.closest('.ai-swap-dropdown')) {
closeAllSwapDropdowns();
}
}, true); // capture=true — срабатывает до onclick на кнопке
// Делегированный обработчик кнопок замены — stopPropagation чтобы не закрывал глобальный listener
document.addEventListener('click', function(e) {
const btn = e.target.closest('.ai-mkd-btn-swap');
if (!btn) return;
e.stopPropagation();
const rowIdx = parseInt(btn.dataset.row);
const slot = parseInt(btn.dataset.slot);
window.aiSwapBuilding(rowIdx, slot, btn);
}, true);
// Кэш всех МКД с сервера для замены
let _swapBuildingsCache = null;
async function _getSwapPool() {
if (_swapBuildingsCache) return _swapBuildingsCache;
try {
const mo = document.getElementById('ai-mo-filter')?.value || '';
const url = '/api/buildings?limit=5000' + (mo ? '&mo=' + encodeURIComponent(mo) : '');
const data = await fetch(url).then(r => r.json());
const list = Array.isArray(data) ? data : (data.buildings || data.items || []);
_swapBuildingsCache = list.filter(b => b.lat && b.lng).map(b => ({
id: String(b.ID || b.id || b.nomer || ''),
address: b['Адрес'] || b.address || b.nazvanie || '',
lat: parseFloat(b.lat),
lng: parseFloat(b.lng),
}));
} catch(e) {
_swapBuildingsCache = [];
}
return _swapBuildingsCache;
}
// Сбрасываем кэш при новой генерации
const _origGenerateAI = window.generateAISchedule;
window.generateAISchedule = async function() {
_swapBuildingsCache = null;
return _origGenerateAI && _origGenerateAI.apply(this, arguments);
};
window.aiSwapBuilding = async function(rowIdx, slot, btn) {
closeAllSwapDropdowns();
const row = _aiData?.schedule?.[rowIdx];
if (!row) return;
const current = row.buildings[slot];
// Показываем индикатор загрузки на кнопке
const origIcon = btn.innerHTML;
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:17px;animation:spin 0.8s linear infinite;">autorenew</span>';
btn.disabled = true;
let freeBuildings = [];
try {
// Собираем все занятые id из графика
const usedIds = new Set();
(_aiData.schedule || []).forEach((r, ri) => {
(r.buildings || []).forEach((b, si) => {
if (b && !(ri === rowIdx && si === slot)) usedIds.add(String(b.id));
});
});
const currentId = current ? String(current.id) : null;
// Загружаем полный пул МКД с сервера (с кэшем)
const serverPool = await _getSwapPool();
// Объединяем серверный пул с локальными удалёнными
const allBuildingsMap = new Map();
serverPool.forEach(b => allBuildingsMap.set(String(b.id), b));
Object.values(_aiDeleted).forEach(b => { if (b) allBuildingsMap.set(String(b.id), b); });
// Свободные = не занятые в других слотах И не являются текущим МКД
freeBuildings = [...allBuildingsMap.values()].filter(b => {
const bid = String(b.id);
if (usedIds.has(bid)) return false;
if (currentId && bid === currentId) return false;
return true;
});
} finally {
btn.innerHTML = origIcon;
btn.disabled = false;
}
// Сортируем по расстоянию от текущего МКД (или от соседнего если текущий удалён)
const anchor = current || row.buildings[slot === 0 ? 1 : 0];
if (anchor && anchor.lat && anchor.lng) {
freeBuildings.sort((a, b) => {
const da = haversineKm(anchor.lat, anchor.lng, a.lat, a.lng);
const db2 = haversineKm(anchor.lat, anchor.lng, b.lat, b.lng);
return da - db2;
});
}
const top = freeBuildings.slice(0, 10);
if (!top.length) {
alert('Нет свободных МКД для замены');
return;
}
// Строим дропдаун
const dropdown = document.createElement('div');
dropdown.className = 'ai-swap-dropdown';
dropdown.innerHTML = `<div class="ai-swap-dropdown-header">Выберите замену</div>`;
top.forEach(b => {
const dist = anchor ? haversineKm(anchor.lat, anchor.lng, b.lat, b.lng).toFixed(1) : '—';
const item = document.createElement('div');
item.className = 'ai-swap-item';
item.innerHTML = `
<span class="ai-swap-item-id">ID ${escHtml(b.id||'')}</span>
<span class="ai-swap-item-addr" title="${escHtml(b.address)}">${escHtml(b.address)}</span>
<span class="ai-swap-item-dist">${dist} км</span>`;
item.addEventListener('click', function() {
// Снимаем старый МКД — кладём в удалённые если был
if (current) _aiDeleted[rowIdx + ':' + slot] = current;
// Ставим новый
_aiData.schedule[rowIdx].buildings[slot] = b;
// Убираем новый из удалённых если он там был
Object.keys(_aiDeleted).forEach(k => {
if (_aiDeleted[k] && String(_aiDeleted[k].id) === String(b.id)) delete _aiDeleted[k];
});
closeAllSwapDropdowns();
renderAIResults(_aiData);
filterAITable();
});
dropdown.appendChild(item);
});
// Позиционируем относительно кнопки
const wrap = btn.closest('.ai-mkd-cell') || btn.parentElement;
wrap.style.position = 'relative';
wrap.appendChild(dropdown);
};
function aiFormatMkCell(rowIdx, slot, b, timeBadge) {
const idBadgeHtml = (id) => id ? `<span style="display:inline-block;padding:1px 7px;border-radius:4px;background:rgba(139,92,246,0.15);border:1px solid rgba(139,92,246,0.4);color:#a78bfa;font-size:10px;font-weight:800;margin-right:6px;letter-spacing:.02em;">ID ${escHtml(id)}</span>` : '';
const rmBtn = `<button type="button" class="ai-mkd-btn ai-mkd-btn-x" title="Убрать из графика" onclick="window.aiRemoveBuildingSlot(${rowIdx},${slot})"><span class="material-symbols-outlined">close</span></button>`;
const swapBtn = `<button type="button" class="ai-mkd-btn ai-mkd-btn-swap" title="Заменить адрес" data-row="${rowIdx}" data-slot="${slot}"><span class="material-symbols-outlined">sync_alt</span></button>`;
if (!b) {
const deleted = _aiDeleted[rowIdx + ':' + slot];
if (deleted) {
return `<div class="ai-mkd-cell" style="position:relative;">
<span style="color:rgba(180,210,255,0.35);font-size:11px;">удалено:</span>
<span style="color:rgba(180,210,255,0.45);font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(deleted.address)}">${escHtml(deleted.address)}</span>
<button type="button" class="ai-mkd-btn ai-mkd-btn-restore" title="Восстановить" onclick="window.aiRestoreBuildingSlot(${rowIdx},${slot})"><span class="material-symbols-outlined">undo</span></button>
${swapBtn}
</div>`;
}
return `<div class="ai-mkd-cell" style="position:relative;"><span style="color:rgba(180,210,255,0.35);">—</span>${swapBtn}</div>`;
}
return `<div class="ai-mkd-cell" style="position:relative;">${timeBadge}${idBadgeHtml(b.id)}<span class="ai-mkd-addr">${escHtml(b.address)}</span><span class="ai-mkd-actions">${swapBtn}${rmBtn}</span></div>`;
}
function renderAIResults(data) {
if (!data || data.error) {
alert(data?.error || 'Ошибка'); return;
}
normalizeScheduleBuildings(data.schedule);
// После генерации — выравниваем контент сверху для прокрутки
const mainEl = document.querySelector('#ai-page-wrap main');
if (mainEl) mainEl.style.justifyContent = 'flex-start';
const countMk = (data.schedule || []).reduce((n, row) =>
n + (row.buildings || []).filter(Boolean).length, 0);
document.getElementById('ai-stat-total').textContent = countMk;
document.getElementById('ai-stat-days').textContent = data.total_days || 0;
document.getElementById('ai-stat-exec').textContent = (data.executors || []).length || '—';
showEl('ai-stats-block', 'grid');
// Резюме
if (data.summary) {
document.getElementById('ai-summary-text').textContent = data.summary;
showEl('ai-summary-block', 'block');
}
// Таблица
const tbody = document.getElementById('ai-table-body');
tbody.innerHTML = '';
const timeBadge1 = `<span style="display:inline-block;padding:1px 6px;border-radius:4px;background:rgba(0,150,255,0.15);border:1px solid rgba(0,150,255,0.3);color:#4a9eff;font-size:10px;font-weight:700;margin-right:6px;">9:00</span>`;
const timeBadge2 = `<span style="display:inline-block;padding:1px 6px;border-radius:4px;background:rgba(0,200,120,0.12);border:1px solid rgba(0,200,120,0.3);color:#4ade80;font-size:10px;font-weight:700;margin-right:6px;">13:00</span>`;
(data.schedule || []).forEach((row, rowIdx) => {
const b0 = row.buildings[0];
const b1 = row.buildings[1];
// Если оба МКД удалены — показываем строку только если есть что восстановить
const hasDeleted0 = !!_aiDeleted[rowIdx + ':0'];
const hasDeleted1 = !!_aiDeleted[rowIdx + ':1'];
if (!b0 && !b1 && !hasDeleted0 && !hasDeleted1) return;
let dist = '—';
if (b0 && b1) {
const d = haversineKm(b0.lat, b0.lng, b1.lat, b1.lng);
dist = d.toFixed(1);
}
const tr = document.createElement('tr');
tr.className = 'ai-row';
tr.dataset.search = ((row.executor || '') + ' ' + (row.kameralshchik || '') + ' ' + (row.koordinator || '') + ' ' + (row.ekspert || '') + ' ' + (b0?.address || '') + ' ' + (b1?.address || '') + ' ' + (b0?.id || '') + ' ' + (b1?.id || '')).toLowerCase();
// Если оба МКД удалены — скрываем персонал (строка только для восстановления)
const bothRemoved = !b0 && !b1;
const emptyCell = `<span style="color:rgba(180,210,255,0.15);font-size:11px;">—</span>`;
const kamerCell = bothRemoved ? emptyCell : (row.kameralshchik
? `<span style="color:#f0abfc;font-size:12px;">${escHtml(row.kameralshchik)}</span>`
: emptyCell);
const koordCell = bothRemoved ? emptyCell : (row.koordinator
? `<span style="color:#86efac;font-size:12px;">${escHtml(row.koordinator)}</span>`
: emptyCell);
const expertCell = bothRemoved ? emptyCell : (row.ekspert
? `<span style="color:#fcd34d;font-size:12px;">${escHtml(row.ekspert)}</span>`
: emptyCell);
tr.innerHTML = `
<td>${row.day}</td>
<td>${row.date_fmt} <span style="color:rgba(180,210,255,0.4);font-size:11px;">${row.weekday}</span></td>
<td style="color:#4a9eff;">${escHtml(row.executor)}</td>
<td>${kamerCell}</td>
<td>${koordCell}</td>
<td>${expertCell}</td>
<td>${aiFormatMkCell(rowIdx, 0, b0, timeBadge1)}</td>
<td>${aiFormatMkCell(rowIdx, 1, b1, timeBadge2)}</td>
<td style="color:rgba(180,210,255,0.5);">${dist}</td>`;
tbody.appendChild(tr);
});
showEl('ai-schedule-block', 'block');
showEl('ai-export-block', 'flex');
}
window.filterAITable = function() {
const q = (document.getElementById('ai-search')?.value || '').toLowerCase();
document.querySelectorAll('.ai-row').forEach(r => {
r.style.display = (!q || r.dataset.search.includes(q)) ? '' : 'none';
});
};
function haversineKm(lat1, lng1, lat2, lng2) {
const R = 6371, toRad = x => x * Math.PI / 180;
const dlat = toRad(lat2 - lat1), dlng = toRad(lng2 - lng1);
const a = Math.sin(dlat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dlng/2)**2;
return R * 2 * Math.asin(Math.sqrt(a));
}
function escHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
}
// ── Экспорт Excel ──────────────────────────────────────────
function _xlsxDownload(wb, filename) {
try {
const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([wbout], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { document.body.removeChild(a); URL.revokeObjectURL(url); }, 1000);
} catch(e) {
alert('Ошибка экспорта: ' + e.message);
}
}
window.exportAIExcel = function() {
if (!_aiData?.schedule) { alert('Сначала сгенерируйте график'); return; }
const rows = [];
_aiData.schedule.forEach(row => {
const b0 = row.buildings[0], b1 = row.buildings[1];
if (!b0 && !b1) return;
let distKm = '';
if (b0 && b1) distKm = haversineKm(b0.lat, b0.lng, b1.lat, b1.lng).toFixed(1);
const rec = {
'День': row.day,
'Дата': row.date_fmt,
'День недели': row.weekday,
'Инженер': row.executor,
'Камеральщик': row.kameralshchik || '',
'Координатор': row.koordinator || '',
'Эксперт': row.ekspert || '',
'Расст. км': distKm,
};
if (b0) {
rec['ID МКД 1'] = b0.id || '';
rec['МКД 1 (9:00)'] = b0.address || '';
}
if (b1) {
rec['ID МКД 2'] = b1.id || '';
rec['МКД 2 (13:00)'] = b1.address || '';
}
rows.push(rec);
});
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'График обследований');
const fn = `график_обследований_${(_aiData.start_date||'').replace(/-/g,'') || 'план'}.xlsx`;
_xlsxDownload(wb, fn);
};
// ── Экспорт PDF ────────────────────────────────────────────
window.exportAIPDF = function() {
if (!_aiData?.schedule) return;
const win = window.open('', '_blank');
const rows = _aiData.schedule.map(row => {
const b0 = row.buildings[0], b1 = row.buildings[1];
if (!b0 && !b1) return '';
const c1 = b0 ? ('9:00 [ID ' + escHtml(b0.id||'') + '] ' + escHtml(b0.address)) : '';
const c2 = b1 ? ('13:00 [ID ' + escHtml(b1.id||'') + '] ' + escHtml(b1.address)) : '';
return `<tr>
<td>${row.day}</td><td>${row.date_fmt} ${row.weekday}</td>
<td>${escHtml(row.executor)}</td>
<td>${escHtml(row.kameralshchik || '—')}</td>
<td>${escHtml(row.koordinator || '—')}</td>
<td>${escHtml(row.ekspert || '—')}</td>
<td>${c1}</td>
<td>${c2}</td>
</tr>`;
}).filter(Boolean).join('');
win.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<title>График обследований МКД</title>
<style>body{font-family:Arial,sans-serif;font-size:11px;padding:20px}
h1{font-size:14px;margin-bottom:4px}p{color:#666;margin-bottom:12px}
table{width:100%;border-collapse:collapse}
th,td{border:1px solid #ccc;padding:4px 6px;text-align:left}
th{background:#eee;font-weight:bold;font-size:10px}
tr:nth-child(even){background:#f9f9f9}</style></head>
<body><h1>График обследований МКД</h1>
<p>${escHtml(_aiData.summary || '')}</p>
<table><thead><tr><th>День</th><th>Дата</th><th>Исполнитель</th><th>Камеральщик</th><th>Координатор</th><th>Эксперт</th><th>МКД 1 (9:00)</th><th>МКД 2 (13:00)</th></tr></thead>
<tbody>${rows}</tbody></table></body></html>`);
win.document.close();
setTimeout(() => win.print(), 500);
};
// ── Отправка графика в PlanFix через n8n webhook ────────────────────────────────────────
window.sendToPlanfix = async function() {
if (!_aiData?.schedule || !_aiData.schedule.length) {
alert('Сначала сформируйте график обследований.');
return;
}
const btn = document.getElementById('planfix-btn');
const origHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;animation:spin 1s linear infinite;">autorenew</span>Отправка...';
const N8N_WEBHOOK_URL = 'https://sracukloycup.beget.app/webhook/fkr-schedule';
const schedOut = _aiData.schedule.map(row => {
const b0 = row.buildings[0], b1 = row.buildings[1];
const item = {
day: row.day,
date: row.date,
date_fmt: row.date_fmt,
weekday: row.weekday,
executor: row.executor,
kameralshchik: row.kameralshchik || '',
kameralshchik_field_id: 241968,
koordinator: row.koordinator || '',
koordinator_field_id: 241916,
ekspert: row.ekspert || '',
ekspert_field_id: 241970,
};
if (b0) item.mkd_1 = { id: b0.id, address: b0.address, time: '9:00' };
if (b1) item.mkd_2 = { id: b1.id, address: b1.address, time: '13:00' };
return item;
}).filter(row => row.mkd_1 || row.mkd_2);
const totalSent = schedOut.reduce((n, r) => n + (r.mkd_1 ? 1 : 0) + (r.mkd_2 ? 1 : 0), 0);
const payload = {
schedule: schedOut,
meta: {
total_buildings: totalSent,
total_days: _aiData.total_days,
start_date: _aiData.start_date,
end_date: _aiData.end_date,
generated_at: new Date().toISOString(),
}
};
try {
const resp = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) {
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:16px;">check_circle</span>Отправлено!';
btn.style.background = 'linear-gradient(135deg,rgba(34,197,94,0.25),rgba(74,222,128,0.25))';
btn.style.borderColor = 'rgba(74,222,128,0.6)';
btn.style.color = '#4ade80';
setTimeout(() => {
btn.innerHTML = origHtml;
btn.style.background = 'linear-gradient(135deg,rgba(99,60,180,0.25),rgba(139,92,246,0.25))';
btn.style.borderColor = 'rgba(139,92,246,0.6)';
btn.style.color = '#a78bfa';
btn.disabled = false;
}, 3000);
} else {
throw new Error('HTTP ' + resp.status);
}
} catch(e) {
alert('Ошибка отправки в ИнжинирингПРО: ' + e.message + '\n\nПроверьте URL webhook в n8n.');
btn.innerHTML = origHtml;
btn.disabled = false;
}
};
// ── База дефектов ───────────────────────────────────────
let _defData = null;
window.loadDefectsDB = async function() {
const btn = document.getElementById('def-load-btn');
const mo = document.getElementById('def-mo-filter')?.value || '';
btn.disabled = true;
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;animation:spin 1s linear infinite;">autorenew</span>Анализирую...';
showEl('def-loading', 'flex');
['def-stats','def-matrix-block','def-systems-block','def-top-block','def-export'].forEach(id => hideEl(id));
try {
const params = new URLSearchParams();
if (mo) params.set('mo', mo);
const data = await fetch('/api/ai/defects-db?' + params).then(r => r.json());
_defData = data;
if (data.error) { alert(data.error); return; }
// Статистика
document.getElementById('def-stat-total').textContent = (data.total || 0).toLocaleString('ru');
document.getElementById('def-stat-blds').textContent = data.buildings || 0;
showEl('def-stats', 'grid');
// Дефекты по системам (индивидуальные виды)
const defBySys = data.defects_by_system || {};
const matrixBody = document.getElementById('def-matrix-body');
const sysOrder = ['Фундамент','Фасад','Крыша','Подвальное помещение','ХВС','ГВС','Теплоснабжение','Водоотведение','Электроснабжение'];
const sysIcons = {
'Фундамент':'foundation','Фасад':'home','Крыша':'roofing','Подвальное помещение':'warehouse',
'ХВС':'water_drop','ГВС':'local_fire_department','Теплоснабжение':'thermostat','Водоотведение':'plumbing','Электроснабжение':'bolt'
};
let matHtml = '';
for (const sys of sysOrder) {
const defects = defBySys[sys];
if (!defects || !defects.length) continue;
const total = defects.reduce((a,d)=>a+d.count,0);
const maxC = defects[0].count || 1;
matHtml += `<div style="border-bottom:1px solid rgba(255,255,255,0.07);">
<div style="display:flex;align-items:center;gap:10px;padding:10px 18px;background:rgba(0,80,180,0.08);">
<span class="material-symbols-outlined" style="font-size:18px;color:#4a9eff;">${sysIcons[sys]||'settings'}</span>
<span style="font-size:12px;font-weight:800;color:#fff;text-transform:uppercase;letter-spacing:.06em;">${escHtml(sys)}</span>
<span style="margin-left:auto;font-size:11px;font-weight:700;color:rgba(74,158,255,0.8);">${total.toLocaleString('ru')} дефектов</span>
</div>
<div style="padding:6px 18px 10px;">
${defects.map((d,i) => {
const pct = Math.round(d.count/maxC*100);
return `<div style="display:flex;align-items:center;gap:10px;padding:5px 0;${i>0?'border-top:1px solid rgba(255,255,255,0.04)':''}">
<span style="width:18px;text-align:right;font-size:10px;font-weight:700;color:rgba(180,210,255,0.35);">${i+1}</span>
<div style="flex:1;min-width:0;">
<div style="font-size:12px;color:rgba(220,235,255,0.9);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(d.name)}</div>
<div style="height:3px;border-radius:2px;background:rgba(255,255,255,0.06);margin-top:3px;">
<div style="width:${pct}%;height:100%;background:linear-gradient(90deg,#0056b2,#4a9eff);border-radius:2px;"></div>
</div>
</div>
<span style="font-size:12px;font-weight:700;color:#4a9eff;min-width:36px;text-align:right;">${d.count.toLocaleString('ru')}</span>
</div>`;
}).join('')}
</div>
</div>`;
}
matrixBody.innerHTML = matHtml || '<div style="padding:20px;color:rgba(180,210,255,0.4);text-align:center;">Нет данных</div>';
showEl('def-matrix-block', 'block');
// Таблица износа по системам
const sysBody = document.getElementById('def-systems-body');
const wearColors = ['#4ade80','#fbbf24','#fb923c','#f87171'];
sysBody.innerHTML = (data.systems_wear || []).map(s => {
const avgColor = s.avg_wear === null ? 'rgba(255,255,255,0.2)' :
s.avg_wear <= 30 ? '#4ade80' : s.avg_wear <= 50 ? '#fbbf24' : s.avg_wear <= 65 ? '#fb923c' : '#f87171';
const bldTotal = (s.cat1||0)+(s.cat2||0)+(s.cat3||0)+(s.cat4||0);
const bar = (v) => bldTotal > 0 ? `<div style="font-size:12px;font-weight:700;color:#fff;">${v}</div><div style="height:3px;border-radius:2px;background:rgba(255,255,255,0.08);margin-top:3px;"><div style="width:${Math.round(v/bldTotal*100)}%;height:100%;background:currentColor;border-radius:2px;"></div></div>` : `<span style="color:rgba(255,255,255,0.2);">—</span>`;
return `<tr style="border-bottom:1px solid rgba(255,255,255,0.05);">
<td style="padding:10px 16px;font-size:12px;color:#fff;font-weight:600;">${escHtml(s.system)}</td>
<td style="padding:10px 12px;text-align:center;font-size:12px;color:rgba(180,210,255,0.7);">${s.buildings||'—'}</td>
<td style="padding:10px 12px;text-align:center;color:#4ade80;">${bar(s.cat1||0)}</td>
<td style="padding:10px 12px;text-align:center;color:#fbbf24;">${bar(s.cat2||0)}</td>
<td style="padding:10px 12px;text-align:center;color:#fb923c;">${bar(s.cat3||0)}</td>
<td style="padding:10px 12px;text-align:center;color:#f87171;">${bar(s.cat4||0)}</td>
</tr>`;
}).join('');
showEl('def-systems-block', 'block');
// Топ дефектов
const topList = document.getElementById('def-top-list');
const topDefs = data.top_defects || [];
const maxTop = topDefs[0]?.count || 1;
topList.innerHTML = topDefs.map((d,i) => `
<div style="display:flex;align-items:center;gap:12px;padding:8px 18px;${i%2===0?'background:rgba(255,255,255,0.02)':''}">
<span style="width:20px;text-align:right;font-size:11px;font-weight:700;color:rgba(180,210,255,0.4);">${i+1}</span>
<div style="flex:1;min-width:0;">
<div style="font-size:12px;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(d.name)}</div>
<div style="height:4px;border-radius:2px;background:rgba(255,255,255,0.06);margin-top:4px;">
<div style="width:${Math.round(d.count/maxTop*100)}%;height:100%;background:linear-gradient(90deg,#0056b2,#0099ff);border-radius:2px;transition:width .4s;"></div>
</div>
</div>
<span style="font-size:13px;font-weight:700;color:#4a9eff;min-width:40px;text-align:right;">${d.count.toLocaleString('ru')}</span>
</div>`).join('');
showEl('def-top-block', 'block');
showEl('def-export', 'flex');
} catch(e) {
alert('Ошибка: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<span class="material-symbols-outlined" style="font-size:18px;">analytics</span>Сформировать базу';
hideEl('def-loading');
}
};
// ── Анализ эффективности ────────────────────────────────
let _staffData = null;
window.loadStaffAnalysis = async function() {
const btn = document.getElementById('staff-load-btn');
btn.disabled = true;
btn.innerHTML = '<div style="display:flex;align-items:center;gap:10px;"><span class="material-symbols-outlined" style="font-size:22px;animation:spin 1s linear infinite;">autorenew</span><span style="font-size:16px;font-weight:800;">Анализирую...</span></div><div style="font-size:12px;color:rgba(200,225,255,0.75);">Загрузка данных по всем исполнителям</div>';
showEl('staff-loading', 'flex');
['staff-stats','staff-table-block','staff-export'].forEach(id => hideEl(id));
try {
const data = await fetch('/api/ai/staff-analysis').then(r => r.json());
_staffData = data;
if (data.error) { alert(data.error); return; }
document.getElementById('staff-stat-count').textContent = data.total_staff || 0;
document.getElementById('staff-stat-blds').textContent = (data.total_buildings || 0).toLocaleString('ru');
showEl('staff-stats', 'grid');
const tbody = document.getElementById('staff-tbody');
const complexColors = { 'Высокая': '#f87171', 'Средняя': '#fbbf24', 'Низкая': '#4ade80' };
tbody.innerHTML = (data.staff || []).map(s => {
const cc = complexColors[s.complexity] || '#94a3b8';
const barW = Math.min(s.score, 100);
// Все 9 систем в виде компактной сетки
const wearC = v => v <= 30 ? '#4ade80' : v <= 50 ? '#fbbf24' : v <= 65 ? '#fb923c' : '#f87171';
// Нормализуем названия систем
const normSys = obj => Object.fromEntries(Object.entries(obj).map(([k,v])=>[
k.replace(/Фасад\/Стены/g,'Фасад')
.replace(/^Кровля$/,'Крыша')
.replace(/^Подвал$/,'Подвальное помещение')
.replace(/^Отопление$/,'Теплоснабжение'),v]));
const sysAll = Object.entries(normSys(s.sys_wear||{})).filter(([,v])=>v!==null)
.map(([k,v])=>`<div style="display:flex;justify-content:space-between;align-items:center;padding:1px 0;gap:8px;">
<span style="font-size:10px;color:rgba(180,210,255,0.55);white-space:nowrap;">${k}</span>
<span style="font-size:10px;font-weight:700;color:${wearC(v)};white-space:nowrap;">${v}%</span>
</div>`).join('');
// Системы — 3 колонки для лучшей читаемости
const sysEntry = ([k,v]) => `<div style="display:flex;justify-content:space-between;align-items:center;padding:2px 0;gap:12px;">
<span style="font-size:12px;color:rgba(180,210,255,0.65);white-space:nowrap;">${k}</span>
<span style="font-size:12px;font-weight:700;color:${wearC(v)};white-space:nowrap;">${v}%</span>
</div>`;
const sysEntries = Object.entries(normSys(s.sys_wear||{})).filter(([,v])=>v!==null);
const half = Math.ceil(sysEntries.length / 2);
const col1 = sysEntries.slice(0, half).map(sysEntry).join('');
const col2 = sysEntries.slice(half).map(sysEntry).join('');
const sysGrid = sysEntries.length
? `<div style="display:grid;grid-template-columns:1fr 1fr;gap:0 24px;">${col1}${col2}</div>`
: '<span style="color:rgba(180,210,255,0.3);font-size:12px;">нет данных</span>';
return `<tr style="border-bottom:1px solid rgba(255,255,255,0.06);">
<td style="padding:14px 20px;">
<div style="font-size:14px;color:#fff;font-weight:700;">${escHtml(s.name)}</div>
${s.municipals ? `<div style="font-size:11px;color:rgba(180,210,255,0.45);margin-top:3px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(s.municipals)}">${escHtml(s.municipals)}</div>` : ''}
</td>
<td style="padding:14px 20px;text-align:center;font-size:16px;font-weight:800;color:#4a9eff;">${s.buildings}</td>
<td style="padding:14px 20px;text-align:center;">
<div style="font-size:16px;font-weight:800;color:rgba(255,255,255,0.9);">${s.defects}</div>
<div style="font-size:11px;color:rgba(180,210,255,0.45);margin-top:2px;">${s.defects_per_bld}/об.</div>
</td>
<td style="padding:14px 20px;">${sysGrid}</td>
<td style="padding:14px 20px;text-align:center;">
<span style="padding:5px 14px;border-radius:20px;font-size:12px;font-weight:700;background:${cc}22;border:1px solid ${cc}55;color:${cc};">${s.complexity}</span>
</td>
<td style="padding:14px 20px;">
<div style="display:flex;align-items:center;gap:10px;">
<div style="flex:1;height:8px;border-radius:4px;background:rgba(255,255,255,0.08);">
<div style="width:${barW}%;height:100%;border-radius:4px;background:linear-gradient(90deg,#0056b2,#00aaff);"></div>
</div>
<span style="font-size:15px;font-weight:800;color:#fff;min-width:32px;text-align:right;">${s.score}</span>
</div>
</td>
</tr>`;
}).join('');
showEl('staff-table-block', 'block');
showEl('staff-export', 'flex');
} catch(e) {
alert('Ошибка: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<div style="display:flex;align-items:center;gap:10px;"><span class="material-symbols-outlined" style="font-size:22px;">insights</span><span style="font-size:16px;font-weight:800;">Глобальный анализ эффективности</span></div><div style="font-size:12px;color:rgba(200,225,255,0.75);">По всем исполнителям · все муниципалитеты · износ из всех систем МКД · Запустить анализ</div>';
hideEl('staff-loading');
}
};
window.exportDefectsExcel = function() {
if (!_defData) return;
const wb = XLSX.utils.book_new();
// Лист 1: износ по системам (из zapolnenie_lenobl)
const sysRows = (_defData.systems_wear || []).map(s => ({
'Система': s.system,
'МКД с данными': s.buildings || 0,
'Ср. износ %': s.avg_wear ?? '—',
'Мин. износ %': s.min_wear ?? '—',
'Макс. износ %': s.max_wear ?? '—',
'0–30% (Хорошее)': s.cat1 || 0,
'30–50% (Удовлетворительное)': s.cat2 || 0,
'50–65% (Плохое)': s.cat3 || 0,
'>65% (Аварийное)': s.cat4 || 0,
}));
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(sysRows), 'Износ по системам');
// Лист 2: матрица дефектов (из Test_foto)
const cats = _defData.all_defect_cats || [];
const matrixRows = Object.entries(_defData.matrix || {}).map(([ctype, catMap]) => {
const row = { 'Тип конструкции': ctype };
cats.forEach(c => { row[c] = catMap[c] || 0; });
row['Итого'] = Object.values(catMap).reduce((a,b)=>a+b, 0);
return row;
});
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(matrixRows), 'Матрица дефектов');
// Лист 3: топ дефектов
const topRows = (_defData.top_defects || []).map((d, i) => ({
'№': i + 1,
'Наименование дефекта': d.name,
'Количество': d.count,
}));
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(topRows), 'Топ дефектов');
_xlsxDownload(wb, 'база_дефектов.xlsx');
};
window.exportStaffExcel = function() {
if (!_staffData?.staff) return;
const wb = XLSX.utils.book_new();
// Лист 1: сводная таблица
const rows = _staffData.staff.map(s => {
const row = {
'Исполнитель': s.name,
'Муниципалитеты': s.municipals || '',
'Объектов': s.buildings,
'МКД с дефектами': s.blds_with_defects || 0,
'Дефектов выявлено': s.defects,
'Дефектов на объект': s.defects_per_bld ?? '—',
'Ср. общий износ %': s.avg_wear ?? '—',
'Ср. этажность': s.avg_floors,
'Сложность задач': s.complexity,
'Рейтинг эффективности (0–100)': s.score,
};
// Добавляем системный износ
Object.entries(s.sys_wear || {}).forEach(([sys, val]) => {
row[`Износ: ${sys} %`] = val ?? '—';
});
return row;
});
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), 'Эффективность');
_xlsxDownload(wb, 'эффективность_сотрудников.xlsx');
};
window.exportStaffPDF = function() {
if (!_staffData?.staff) return;
const win = window.open('', '_blank');
const wearC = v => v <= 30 ? '#16a34a' : v <= 50 ? '#d97706' : v <= 65 ? '#ea580c' : '#dc2626';
const rows = (_staffData.staff || []).map(s => {
const sysList = Object.entries(s.sys_wear||{}).filter(([,v])=>v!==null)
.map(([k,v])=>`${k}: <b style="color:${wearC(v)};">${v}%</b>`).join(' · ');
return `<tr>
<td><b>${escHtml(s.name)}</b><br><small style="color:#888;">${escHtml(s.municipals||'')}</small></td>
<td style="text-align:center;">${s.buildings}</td>
<td style="text-align:center;">${s.defects}<br><small style="color:#888;">${s.defects_per_bld}/об.</small></td>
<td style="font-size:10px;">${sysList || '—'}</td>
<td style="text-align:center;">${s.complexity}</td>
<td style="text-align:center;"><b>${s.score}</b></td>
</tr>`;
}).join('');
win.document.write(`<!DOCTYPE html><html><head><meta charset="utf-8">
<title>Анализ эффективности сотрудников</title>
<style>
body{font-family:Arial,sans-serif;font-size:11px;padding:20px;color:#111;}
h1{font-size:15px;margin-bottom:4px;}
p{color:#555;margin-bottom:12px;font-size:11px;}
table{width:100%;border-collapse:collapse;margin-top:8px;}
th,td{border:1px solid #ccc;padding:5px 7px;text-align:left;vertical-align:top;}
th{background:#eef2ff;font-weight:bold;font-size:10px;text-transform:uppercase;}
tr:nth-child(even){background:#f9f9f9;}
.legend{margin-top:14px;font-size:10px;color:#555;}
</style></head>
<body>
<h1>Анализ эффективности сотрудников</h1>
<p>Всего исполнителей: ${_staffData.total_staff || 0} · Объектов: ${(_staffData.total_buildings||0).toLocaleString('ru')} · Дефектов: ${(_staffData.total_defects||0).toLocaleString('ru')}</p>
<table>
<thead><tr>
<th>Исполнитель</th><th>Объектов</th><th>Дефектов</th>
<th>Системы МКД (износ)</th><th>Сложность</th><th>Рейтинг</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="legend">
<b>Сложность:</b> Высокая — износ>65% или дефектов>50 | Средняя — износ>50% или дефектов>20 | Низкая — иначе<br>
<b>Рейтинг (0–100):</b> Качество (до 40) + Производительность (до 40) + Сложность объектов (до 20)
</div>
</body></html>`);
win.document.close();
setTimeout(() => win.print(), 500);
};
// Инициализируем страницу при переходе на неё
const _origActivate = window._aiPageInited;
document.addEventListener('hashchange', () => {
if (location.hash === '#ai') initAIPage();
});
if (location.hash === '#ai') initAIPage();
})();
</script>
<!-- ════════════════ MOBILE RESPONSIVE ════════════════ -->
<div id="mob-overlay" onclick="closeMobileSidebar()" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:199;backdrop-filter:blur(2px);"></div>
<style>
/* ── Hamburger button (hidden on desktop) ── */
.mob-burger {
display: none;
align-items: center;justify-content: center;
width: 38px; height: 38px; border-radius: 10px;
background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.12);
cursor: pointer; flex-shrink: 0; transition: background .2s;
}
.mob-burger:hover { background: rgba(255,255,255,0.14); }
.mob-burger span { color: #94a3b8; font-size: 20px; }
/* ── Sidebar drawer on mobile ── */
@media (max-width: 767px) {
/* Show hamburger */
.mob-burger { display: flex !important; }
/* Sidebar → hidden fixed drawer */
.mob-sidebar {
position: fixed !important;
left: 0; top: 0;
height: 100vh !important;
width: 16rem !important;
z-index: 200;
transform: translateX(-100%);
transition: transform 0.28s cubic-bezier(0.4,0,0.2,1);
}
.mob-sidebar.mob-open {
transform: translateX(0);
box-shadow: 4px 0 32px rgba(0,0,0,0.6);
}
#mob-overlay.mob-active { display: block !important; }
/* Registry / Map: hide header search shrink, wrap */
#page-registry header,
#page-map header {
flex-wrap: wrap;
padding: 10px 12px !important;
gap: 8px;
}
#page-registry header > div:first-child {
flex: 1 1 100%;
max-width: 100% !important;
}
#page-registry header .flex.items-center.gap-3 {
flex-wrap: wrap; gap: 6px;
}
#page-registry header .flex.items-center.gap-3 button span:last-child {
display: none;
}
/* Card page: shrink header */
#page-card header {
flex-wrap: wrap;
padding: 10px 12px !important;
gap: 8px;
}
#page-card header > div:first-child {
flex: 1 1 100%;
overflow-x: auto;
}
#page-card header h1 { font-size: 14px !important; }
#page-card header > div:last-child {
display: flex; flex-wrap: wrap; gap: 6px; width: 100%;
}
#page-card header button {
padding: 6px 10px !important;
font-size: 12px !important;
}
/* Card metrics: horizontal scroll on narrow screens */
#page-card #card-metrics-row {
overflow-x: auto;
gap: 0;
-webkit-overflow-scrolling: touch;
}
#page-card #card-metrics-row > div {
min-width: 0;
}
#page-card #card-metrics-row > div:not(:last-child) {
min-width: 64px;
}
/* Card main: stack vertically */
#page-card main {
flex-direction: column !important;
overflow-y: auto !important;
overflow-x: hidden !important;
}
#page-card aside.w-72 {
width: 100% !important;
flex-direction: row !important;
flex-wrap: wrap;
gap: 8px;
}
#page-card aside.w-72 > * {
flex: 1 1 calc(50% - 4px);
min-width: 160px;
}
/* Card photo grid: 2 cols instead of 4 */
#photo-gallery-new { grid-template-columns: repeat(2, 1fr) !important; }
/* AI page */
#ai-page-wrap main {
padding: 14px 12px !important;
}
/* Preset buttons: wrap (already flex-wrap) */
/* Date + Mo grid: single column */
#page-ai .ai-glass [style*="grid-template-columns"] {
grid-template-columns: 1fr !important;
}
#page-ai #ai-generate-btn {
width: 100% !important;
}
/* Stats grid: single column */
#ai-stats-block {
grid-template-columns: 1fr 1fr !important;
}
/* Export buttons: full width */
#ai-export-block {
flex-direction: column !important;
}
#ai-export-block button {
width: 100% !important;
justify-content: center;
}
/* Table: horizontal scroll */
#ai-schedule-block > div[style*="overflow-x"] {
overflow-x: auto !important;
}
#ai-table { min-width: 560px; }
/* AI title */
#page-ai h1 { font-size: 20px !important; }
/* Results hero */
#page-results h1 { font-size: 1.6rem !important; line-height: 1.25 !important; }
#page-results .relative.z-10 { padding: 16px !important; }
#page-results nav.hidden { display: none !important; }
/* Registry table: horizontal scroll container */
#page-registry .overflow-auto { overflow-x: auto !important; }
#registry-table { min-width: 700px; }
/* General padding reduce for pages */
#page-registry main { min-width: 0; }
#page-registry header .relative.flex-1 input {
font-size: 13px !important;
}
/* Map: full width */
#page-map main { flex: 1; min-width: 0; }
}
@media (max-width: 480px) {
#page-results h1 { font-size: 1.25rem !important; }
#ai-stats-block { grid-template-columns: 1fr !important; }
#page-card aside.w-72 > * { flex: 1 1 100%; }
}
</style>
<script>
(function() {
// Инициализация мобильного меню после загрузки DOM
function initMobileMenus() {
// Страницы с сайдбарами
const sidebarPages = [
{ pageId: 'page-registry', headerSel: '#page-registry header' },
{ pageId: 'page-map', headerSel: '#page-map main header, #page-map > div > main header' },
];
sidebarPages.forEach(({ pageId, headerSel }) => {
const page = document.getElementById(pageId);
if (!page) return;
const aside = page.querySelector('aside');
if (!aside) return;
// Помечаем aside как drawer
aside.classList.add('mob-sidebar');
// Ищем header для кнопки
const header = page.querySelector('header');
if (!header) return;
// Создаём кнопку-гамбургер
const btn = document.createElement('button');
btn.className = 'mob-burger';
btn.setAttribute('aria-label', 'Открыть меню');
btn.innerHTML = '<span class="material-symbols-outlined">menu</span>';
btn.onclick = () => openMobileSidebar(aside);
// Вставляем первым ребёнком header
header.insertBefore(btn, header.firstChild);
});
}
window.openMobileSidebar = function(aside) {
if (typeof aside === 'string') aside = document.querySelector(aside);
if (!aside) return;
aside.classList.add('mob-open');
const overlay = document.getElementById('mob-overlay');
if (overlay) { overlay.classList.add('mob-active'); overlay._sidebar = aside; }
};
window.closeMobileSidebar = function() {
// Закрываем все открытые сайдбары
document.querySelectorAll('.mob-sidebar.mob-open').forEach(s => s.classList.remove('mob-open'));
const overlay = document.getElementById('mob-overlay');
if (overlay) overlay.classList.remove('mob-active');
};
// Закрываем сайдбар при смене страницы
document.addEventListener('hashchange', closeMobileSidebar);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMobileMenus);
} else {
initMobileMenus();
}
})();
</script>
<!-- Глобальный тултип -->
<div id="ai-global-tip"></div>
<script>
(function(){
const tip = document.getElementById('ai-global-tip');
let hideTimer;
document.addEventListener('mouseover', function(e) {
const icon = e.target.closest('.ai-tip-icon');
if (!icon) return;
clearTimeout(hideTimer);
tip.innerHTML = icon.dataset.tip || '';
tip.style.display = 'block';
const r = icon.getBoundingClientRect();
let top = r.top - tip.offsetHeight - 10;
let left = r.left + r.width/2 - tip.offsetWidth/2;
if (top < 8) top = r.bottom + 10;
if (left < 8) left = 8;
if (left + tip.offsetWidth > window.innerWidth - 8) left = window.innerWidth - tip.offsetWidth - 8;
tip.style.top = top + 'px';
tip.style.left = left + 'px';
});
document.addEventListener('mouseout', function(e) {
if (e.target.closest('.ai-tip-icon')) {
hideTimer = setTimeout(() => { tip.style.display = 'none'; }, 150);
}
});
})();
</script>
<!-- ════════════════════════════════════════════════════ -->
</body>
</html>
Sitemap
Кол-во: 0
XML-карта сайта для поисковиков
?
Sitemap.xml помогает поисковику быстрее находить и индексировать страницы. Особенно важен для крупных сайтов и новых страниц, на которые ещё нет входящих ссылок.
Robots.txt не содержит ссылку на карту сайта. Рекомендуется добавить карту сайта и указать ссылку на нее в robots.txt.
Внутренние ссылки
Не найдено
Ссылки на другие страницы своего сайта
?
Внутренние ссылки распределяют ссылочный вес между страницами и помогают поисковику обходить сайт. Пустые анкоры и ссылки на запрещённые robots.txt страницы — типичные ошибки.
Внутренних ссылок на странице 0 оптимально.
Внешние ссылки
Не найдено
Ссылки на сторонние сайты
?
Исходящие внешние ссылки передают часть ссылочного веса на чужие сайты. Ссылки на авторитетные ресурсы безопасны; ссылки на мусорные сайты могут навредить репутации страницы.
Внешних ссылок на странице не найдено.
Конкуренты Готовность: 0%
Конкуренты в Яндексе
Кол-во: 0
Топ сайтов-конкурентов в Яндексе
?
Сайты, чаще всего появляющиеся в ТОПе Яндекса по запросам из семантического ядра этой страницы.
Мы не нашли у вас конкурентов в Яндексе. Сайт или очень молодой или плохо продвигается.
Конкурентов в ТОП-10 Яндекса не нашлось.
Конкуренты в Google
Кол-во: 0
Топ сайтов-конкурентов в Google
?
Сайты, чаще всего появляющиеся в ТОПе Google по запросам из семантического ядра этой страницы.
Конкуренты в Google тоже не найдены. Займитесь продвижением сайта!
Конкурентов в ТОП-10 Google не нашлось.
ФЗ-152: ПД Готовность: 40%
РКН
ОК
Сайт не заблокирован
Контакты
https://fkr-lenobl.ru/contacts/
Страница с контактами найдена
Политика ПД
https://fkr-lenobl.ru/policy/
Страница политики обработки ПД найдена
Согласие
https://fkr-lenobl.ru/personal/
Страница согласия на обработку ПД найдена
Чекбокс согласия
Не найден
Чекбокс согласия на обработку ПД в формах сайта не найден
Счётчики до согласия
ОК
Счётчики аналитики не собирают данные до согласия пользователя
ЗоЗПП: права потребителей Готовность: 100%
Нарушения
Не выявлены
Признаков дистанционной продажи товаров (интернет-магазина) не обнаружено — требования ЗоЗПП о раскрытии информации продавца к сайту не применяются. Нарушений нет.
ФЗ-149: рекомендательные технологии Готовность: 100%
Нарушения
Не выявлены
Рекомендательные блоки («с этим покупают», «похожие товары» и т.п.) на сайте не обнаружены — требования ст. 10.7 ФЗ-149 к сайту не применяются. Нарушений нет.
ФЗ-38: реклама Готовность: 100%
Нарушения
Не выявлены
Рекламных тематик с обязательными оговорками (медицина, БАД, кредиты и займы, новостройки) на сайте не обнаружено. Нарушений нет.
ФЗ-436: защита детей Готовность: 100%
Нарушения
Не выявлены
Признаков информационной продукции (новости, видео, книги, игры, курсы) не обнаружено — обязательная возрастная маркировка по ФЗ-436 сайту не требуется. Нарушений нет.
Вердикт
Таким сайт fkr-lenobl.ru видят поисковые системы. Процент оптимизации невысокий - 69%. Чтобы улучшить сайт и попасть в ТОП необходимо:
Исправьте ошибки в мета-тегах.
Исправьте ошибки оптимизации.
Поделитесь с друзьями: