Анализ сайта cp.courierexpert.ru
Проверено 11 ноября 2026, 10:21
Открыть сайт
Требует доработки
Итог проверки
Анализ сайта cp.courierexpert.ru, слабо оптимизирован на 68%. Для хорошей оптимизации и выхода на первые места в поиске требуется:
- Ошибка Используйте для продвижения только домен второго уровня.
- Ошибка Длина домена велика. Но если вы продвигаете запрос, входящий в название домена, то это хорошо.
- Ошибка В нашей базе нет данных по данному домену
Основное
100%
Домен
Важность 3 из 3
cp.courierexpert.ru
Проверяем корректность доменного имени и наличие технических проблем на уровне домена.
ОшибкаИспользуйте для продвижения только домен второго уровня.
ОшибкаДлина домена велика. Но если вы продвигаете запрос, входящий в название домена, то это хорошо.
ОшибкаВ нашей базе нет данных по данному домену
СоветПодобрать домен
Ответ сервера
Важность 3 из 3
200 Успешный ответ
Код 200 — страница доступна. Коды 3xx — редиректы (цепочки замедляют загрузку и размывают ссылочный вес). Коды 4xx/5xx — ошибки, поисковик не сможет проиндексировать страницу.
НормаСервер настроен корректно.
Цепочка редиректов:
↓
http://cp.courierexpert.ru301 MovedPermanently
✓
https://cp.courierexpert.ru/200 OK
Безопасность
Важность 3 из 3
Сайт безопасен
HTTPS — обязательный стандарт. Google и Яндекс отдают предпочтение защищённым сайтам. Отсутствие SSL или просроченный сертификат ведут к предупреждениям в браузере и снижению позиций.
СоветНе настроена политика безопасности контента (CSP). Она защищает посетителей от подмены скриптов, если сайт взломают или на страницу попадёт чужой код.
СоветНе задан Permissions-Policy — заголовок, который запрещает странице обращаться к камере, микрофону и геолокации. Если сайту эти возможности не нужны, их стоит закрыть.
НормаНа сайте работает защищенный протокол ssl и сайт открывается по https.
НормаSsl-сертификат действителен до 04.02.2027 7:17:57.
НормаВключён HSTS (Strict-Transport-Security) — защита от подмены на http.
НормаHTTP автоматически перенаправляется на HTTPS.
НормаПоздравляем! Сайт не содержится в реестре РКН.
Кодировка
Важность 2 из 3
UTF-8
Стандарт — UTF-8. Неправильная кодировка вызывает нечитаемые символы и мешает поисковику корректно распознать текст страницы.
НормаУказана кодировка на странице UTF-8.
Язык
Важность 2 из 3
ru
Атрибут lang (<html lang="ru">) сообщает поисковикам и браузерам, на каком языке написана страница. Помогает при ранжировании в региональном поиске.
НормаУказан язык документа ru.
Скорость загрузки
Важность 2 из 3
56мс
Time To First Byte — время до получения первого байта от сервера. Норма до 200 мс. Медленный отклик ухудшает пользовательский опыт и ранжирование: Яндекс и Google учитывают скорость страниц.
НормаСкорость загрузки сайта 56мсек высокая. У сайта cp.courierexpert.ru шустрый хостинг!
Предзагрузка переходов
Важность 3 из 3
Не настроена
Speculation Rules — правила, по которым браузер подтягивает страницу заранее, ещё до клика: посетитель навёл курсор на ссылку, и переход открывается мгновенно. Настраивается одним файлом правил или тегом в разметке, переделывать сайт не нужно. На оценку фактор не влияет: это ускорение, а не требование.
СоветНе настроена предзагрузка переходов. Браузер умеет заранее подтягивать страницу, на которую посетитель навёл курсор, — переход становится мгновенным. Настраивается одним файлом правил, без переделки сайта.
Объем документа
Важность 2 из 3
308Кб
Слишком большой HTML замедляет парсинг браузером и сканирование поисковым роботом. Рекомендуется не более 200 Кб.
НормаОбъем html-документа 308Кб оптимален.
НормаСтруктура html-документа корректна.
Ресурсы
Важность 2 из 3
Ресурсы: 2
Количество и тип подключённых ресурсов влияют на скорость загрузки. Большое число запросов увеличивает время рендеринга страницы.
НормаКол-во файлов ресурсов 2 достаточно.
| Тип | Название | Значение |
|---|---|---|
| stylesheet | /leaflet/leaflet.css | |
| js | /leaflet/leaflet.js |
Серверные заголовки
Кол-во: 5
Заголовки сервера передают браузеру и поисковику служебную информацию: кеширование, безопасность (CSP, HSTS), сжатие (gzip). Правильная настройка ускоряет загрузку и повышает защищённость.
НормаНайдены серверные заголовки 5шт. Подробнее про серверные заголовки.
| Ключ | Значение |
|---|---|
| Server | nginx/1.24.0 |
| Date | Wed, 11 Nov 2026 07:21:09 GMT |
| Cache-Control | no-store, must-revalidate |
| Strict-Transport-Security | max-age=31536000 |
| X-Content-Type-Options | nosniff |
CMS
Важность 1 из 3
Не определена
CMS — это движок, на котором работает сайт (WordPress, 1C-Bitrix, Tilda и др.). Знание CMS помогает понять возможности SEO-оптимизации и подобрать подходящие инструменты. «Не определена» — вероятно, самописный сайт или нестандартная сборка.
НормаCMS не определена. Вероятно, сайт самописный либо движок надёжно скрыт. Это не ошибка.
Веб-сервер
Важность 1 из 3Веб-сервер — это ПО, которое отдаёт страницы посетителям (nginx, Apache, IIS, LiteSpeed и др.). Определяется по серверным заголовкам ответа (Server, X-Powered-By и т.п.). «Не определён» — сервер намеренно скрывает эти заголовки, это нормальная практика безопасности.
НормаСайт работает на веб-сервере nginx.
Мета-теги
32%
Title
Важность 3 из 3
CourierExpert — кабинет диспетчера
Title — главный SEO-заголовок страницы. Влияет на CTR в поиске и ранжирование. Оптимальная длина: 50–70 символов. Ключевые слова — ближе к началу.
ОшибкаНеобходимо увеличить число символов в title (текущее значение: 34, оптимально: от 40 до 45)
НормаДублей словоформ в title не найдено.
Description
Важность 3 из 3Meta Description — текст под заголовком в выдаче. Напрямую на позиции не влияет, но влияет на CTR. Оптимальная длина: 120–160 символов.
ОшибкаУстановите мета-тег description!
Keywords
Важность 1 из 3Meta Keywords не учитывается Яндексом и Google для ранжирования с 2009–2012 годов. Заполнение не обязательно, но не вредит. Конкурент может использовать содержимое для анализа.
ОшибкаУстановите мета-тег keywords!
Канонический Url
Важность 3 из 3Canonical (rel=canonical) предотвращает проблему дублей страниц. Должен указывать на ту же страницу, которую проверяем: завершающий слеш роли не играет, а вот другой адрес или домен передаст ссылочный вес на чужую страницу.
СоветРекомендуем прописать канонический Url.
Robots
Важность 3 из 3
Ошибок нет
Meta Robots управляет индексацией конкретной страницы: index/noindex — индексировать ли, follow/nofollow — следовать ли по ссылкам. Noindex полностью исключает страницу из поиска.
НормаMeta-тег robots не указан. Страница свободна для индексации.
Адаптивность
Важность 2 из 3
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
Важность 2 из 3
Не найдено
OpenGraph (og:title, og:description, og:image) управляет тем, как страница выглядит при репосте в социальных сетях и мессенджерах. Отсутствие OG-тегов — невзрачный превью при шеринге.
ОшибкаРазметка OpenGraph не задана. Страница не оптимизирована под социальные сети. Мета-теги с разметкой Og помогают социальным роботам лучше структурировать Ваш сайт.
Все мета-теги
Кол-во: 1
Таблица всех meta-тегов, включая нестандартные. Позволяет найти опечатки, дубли и лишние теги.
НормаНайдены мета-теги 1шт. Мета-теги не видимы для человека и предназначены для обмена информацией между веб-страницей и поисковыми системами, браузерами и другими веб-службами. С ними роботы 🤖 и устройства ведут себя более ожидаемо.
| Тип | Название | Значение |
|---|---|---|
| name | viewport | width=device-width, initial-scale=1.0 |
Оптимизация
82%
Структура
Важность 3 из 3
Ошибок нет
Проверяет каркас страницы: есть ли теги html и body, не задвоены ли они и закрыт ли документ тегами </body> и </html>. Оборванный на выводе документ поисковик дочитывает на свой страх и риск — часть контента может не попасть в индекс.
НормаСтруктура документа корректна (теги <html> и <body> присутствуют по одному на документ).
Контент
Важность 3 из 3
Ошибок нет
Анализирует объём полезного текста на странице. Слишком мало — страница может считаться малополезной. Слишком много — ухудшается читаемость и восприятие.
НормаСлова из title 3 встречаются в тексте достаточно.
НормаАбзацев с текстом 25 достаточно.
НормаСреднее число слов в абзаце 10 достаточно.
НормаКол-во знаков контента 7664 на странице оптимально.
НормаКол-во слов 1133 на странице оптимально.
Заголовки
Важность 3 из 3
Ошибок нет
H1 должен быть один и содержать ключевой запрос. H2–H6 описывают подразделы. Пропуск уровней (H1 → H3) и несколько H1 — типичные ошибки, снижающие понятность страницы для поисковика.
НормаНа странице присутствуют заголовки <h1> 18. Это прекрасно.
НормаНа странице присутствуют заголовки <h3> 15.
Тошнота
Важность 2 из 3
4,12
Классическая тошнота = √(частота самого повторяющегося слова). Норма до 7–8: текст воспринимается естественно. Выше — поисковик может счесть страницу переспамленной.
ОшибкаТошнота превышает норму 3. Измените текст страницы!
Академич. тошнота
Важность 2 из 3
9,62%
Академическая тошнота = (частота слова / общее количество слов) × 100%. Показывает долю конкретного слова в тексте. Норма 5–15%.
НормаАкадемическая тошнота страницы в пределах нормы.
Семантическое ядро
20
Топ слов по частоте использования. Показывает, какие слова доминируют в тексте с точки зрения поисковика.
НормаКонтент страницы содержит осмысленный текст и слова.
| Слово | Кол-во | Частота |
|---|---|---|
| заказы | 17 | 1,50% |
| курьера | 16 | 1,41% |
| курьер | 15 | 1,32% |
| добавить | 12 | 1,06% |
| курьеры | 11 | 0,97% |
| отмена | 10 | 0,88% |
| вперёд | 10 | 0,88% |
| статус | 7 | 0,62% |
| название | 6 | 0,53% |
| маршруты | 6 | 0,53% |
| назначить | 6 | 0,53% |
| выплате | 6 | 0,53% |
| закрыть | 6 | 0,53% |
| настройки | 5 | 0,44% |
| доставки | 5 | 0,44% |
| маршрут | 5 | 0,44% |
| выплаты | 5 | 0,44% |
| пространства | 5 | 0,44% |
| приглашение | 5 | 0,44% |
| компании | 4 | 0,35% |
Индексация
60%
Индексирование
Важность 3 из 3
Есть ошибки
Проверяет, не закрыта ли страница от индексации через robots.txt, meta robots или X-Robots-Tag. Страница, закрытая от индексации, не появится в поисковой выдаче.
ОшибкаАнкоров на странице 2 слишком мало или они запрещены к индексации. Добавьте новые страницы на сайт и расставьте ссылки на них на главной странице и в файле robots.txt.
Robots.txt
Важность 3 из 3
Найден корректный robots.txt
Robots.txt указывает поисковым роботам, какие страницы сканировать, а какие — нет. Ошибки в файле могут случайно закрыть важные разделы от индексации.
НормаRobots.txt настроен корректно. Размер файла: 315553 байт. Загружен за: 77мсек.
НормаПроверяемая страница не запрещена в robots.txt.
НормаRobots.txt доступен по постоянному адресу
Цепочка редиректов для файла robots.txt:
↓
http://cp.courierexpert.ru/robots.txt301 MovedPermanently
✓
https://cp.courierexpert.ru/robots.txt200 OK
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CourierExpert — кабинет диспетчера</title>
<link rel="stylesheet" href="/leaflet/leaflet.css">
<style>
:root {
--bg: #f4f6f8;
--paper: #ffffff;
--divider: #e5e7eb;
--text: #111827;
--muted: #4b5563;
--dim: #9aa3af;
--primary: #1c6dd0;
--primary-dark: #1552a3;
--primary-soft: #e8f1fd;
--success: #16a34a;
--success-soft: #e7f8ee;
--warning: #d97706;
--warning-soft: #fdf1e3;
--danger: #dc2626;
--danger-soft: #fdeceb;
--code-bg: #0f1b2d;
--code-ink: #cfe1ff;
--ink-900: #111111;
--ink-600: #666666;
--line: #dddddd;
--row-work: #fff7ed;
--accent-amber: #f59e0b;
--accent-red: #ef4444;
--shell: #0f172a;
--shell-line: #1e3a5f;
--shell-input: #1b2a42;
--shell-border: #2b3d5c;
--shell-ink: #a9bcd6;
--shell-ink-soft: #9fb2cd;
--shell-ink-dim: #8ea3c0;
--shell-brand: #6cb2ff;
--surface-soft: #fbfcfd;
--surface-mute: #f8fafc;
--surface-line: #eef2f7;
--line-soft: #e2e7ee;
--line-mute: #cfd6df;
--ink-soft: #555555;
--ink-mute: #444444;
--ink-faint: #8a97a8;
--link: #2f6fed;
--info: #1e40af;
--info-soft: #e0e9ff;
--info-line: #c7ddfa;
--ok-strong: #0e7a4b;
--ok-line: #b6e7c8;
--warn-ink: #a16207;
--warn-line: #fff7e0;
--danger-ink: #b42318;
--radius: 12px;
--radius-sm: 9px;
--shadow: 0 1px 2px rgba(17,24,39,.05), 0 8px 24px -14px rgba(17,24,39,.18);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-padding-top: 84px; }
main > section { scroll-margin-top: 84px; }
body { position: relative; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
button { font-family: inherit; }
/* Top bar */
.topbar { background: var(--code-bg); color: var(--paper); padding: 12px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 16px; position: sticky; top: 0; z-index: 40; }
.brand { font-size: 19px; font-weight: 800; color: var(--shell-brand); letter-spacing: -.01em; display: flex; align-items: center; gap: 8px; }
.brand .tag { font-size: 11px; font-weight: 600; color: var(--shell-ink-dim); background: rgba(255,255,255,.08); padding: 3px 8px; border-radius: 999px; }
.ws-pick { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--shell-ink-soft); }
.ws-pick select { background: var(--shell-input); color: var(--paper); border: 1px solid var(--shell-border); padding: 7px 10px; border-radius: 8px; font-size: 13px; outline: none; max-width: 240px; }
.spacer { flex: 1; }
.nav { display: flex; flex-wrap: wrap; gap: 6px; }
.nav button { background: transparent; border: none; color: var(--shell-ink); font-size: 13.5px; font-weight: 600; padding: 8px 13px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 7px; }
.nav button:hover { color: var(--paper); background: rgba(255,255,255,.07); }
.nav button.active { color: var(--paper); background: var(--primary); }
main { width: 100%; max-width: none; margin: 0; padding: 22px 20px 60px; display: flex; flex-direction: column; gap: 20px; }
main > section { width: 100%; }
main .panel { width: 100%; }
.page-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 14px; }
.page-head h1 { font-size: 23px; font-weight: 800; letter-spacing: -.01em; }
.page-head p { color: var(--muted); font-size: 13.5px; margin-top: 3px; }
.actions { display: flex; flex-wrap: wrap; gap: 10px; }
.btn { border: none; border-radius: var(--radius-sm); font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 11px 16px; font-size: 14px; transition: filter .15s, transform .06s; }
.btn:active { transform: scale(.98); }
.btn-primary { background: var(--primary); color: var(--paper); }
/* Опасное действие: только токен темы, никаких литеральных цветов. */
.btn-danger { background: var(--danger); color: var(--paper); }
/* Панель массовых действий: появляется, когда в списке что-то выбрано. */
.bulk-bar { display: none; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 4px; border-top: 1px solid var(--divider); }
.bulk-bar .bulk-count { color: var(--muted); font-size: 12.5px; margin-right: 4px; }
.row-check { margin-right: 8px; vertical-align: middle; }
.btn-primary:hover { filter: brightness(1.06); }
.btn-success { background: var(--success); color: var(--paper); }
.btn-success:hover { filter: brightness(1.06); }
.btn-ghost { background: var(--surface-line); color: var(--text); }
.btn-ghost:hover { background: var(--line-soft); }
.kpis { display: flex; flex-wrap: wrap; gap: 14px; }
.kpi { flex: 1 1 210px; background: var(--paper); border: 1px solid var(--divider); border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow); }
.kpi .cap { font-size: 11.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; display: flex; align-items: center; gap: 6px; }
.kpi .val { font-size: 30px; font-weight: 800; margin-top: 6px; }
.kpi .val.green { color: var(--success); }
.kpi .sub { font-size: 12px; color: var(--dim); margin-top: 2px; }
.panel { background: var(--paper); border: 1px solid var(--divider); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; padding: 14px; border-bottom: 1px solid var(--divider); background: var(--surface-soft); }
.search { flex: 1 1 240px; }
.input, .select { width: 100%; padding: 10px 12px; border: 1px solid var(--line-mute); border-radius: var(--radius-sm); font-size: 14px; outline: none; background: var(--paper); }
.input:focus, .select:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-soft); }
.fld { display: flex; flex-direction: column; gap: 6px; }
.fld label { font-size: 12.5px; font-weight: 700; color: var(--muted); display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.fld .tip { font-size: 11.5px; color: var(--dim); }
.tbl-wrap { overflow-x: auto; }
.row-actions { width: 1%; white-space: nowrap; text-align: right; padding-left: 4px; padding-right: 4px; }
.icon-btn { width: 30px; height: 30px; padding: 0; margin-left: 2px; display: inline-flex; align-items: center; justify-content: center; border: none; border-radius: 8px; background: transparent; cursor: pointer; font-size: 15px; line-height: 1; }
.icon-btn:hover { background: var(--primary-soft); }
.icon-btn.danger { color: var(--danger); }
.icon-btn.danger:hover { background: var(--danger-soft); }
.icon-btn .ico { width: 16px; height: 16px; display: block; }
.ui-tip { position: fixed; z-index: 999; transform: translate(-50%, -100%); background: var(--shell); color: var(--paper); font-size: 11.5px; font-weight: 600; padding: 4px 8px; border-radius: 6px; pointer-events: none; white-space: nowrap; box-shadow: 0 8px 20px rgba(0,0,0,.25); }
.icon-btn.ok:hover { background: var(--success-soft); }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
th { text-align: left; padding: 12px 14px; background: var(--surface-soft); color: var(--muted); font-weight: 700; font-size: 11.5px; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid var(--divider); white-space: nowrap; }
td { padding: 13px 14px; border-bottom: 1px solid var(--surface-line); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: var(--surface-soft); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.strong { font-weight: 700; }
.muted { color: var(--muted); }
.dim { color: var(--dim); }
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 700; padding: 4px 10px; border-radius: 999px; white-space: nowrap; }
.chip.pool { background: var(--warning-soft); color: var(--warning); }
.chip.route { background: var(--primary-soft); color: var(--primary); }
.chip.pick { background: var(--info-soft); color: var(--info); }
.chip.delivered { background: var(--success-soft); color: var(--success); }
.chip.part { background: var(--warn-line); color: var(--warn-ink); }
.chip.cancel { background: var(--danger-soft); color: var(--danger); }
.chip.open { background: var(--success-soft); color: var(--success); }
/* Cards for mobile */
.cards { display: none; flex-direction: column; gap: 12px; padding: 14px; }
.mcard { border: 1px solid var(--divider); border-radius: var(--radius); padding: 14px; display: flex; flex-direction: column; gap: 10px; background: var(--paper); }
.mcard .h { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.mcard .kv { display: flex; flex-direction: column; gap: 2px; }
.mcard .kv .k { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--dim); font-weight: 700; }
.mcard .kv .v { font-size: 14px; }
/* Modal */
/* Пока открыт диалог, страница видна сквозь затемнение, но стоит на месте:
её фиксируем, а основная полоса прокрутки окна прокручивает ровно
диалог. Своей прокрутки у диалога нет — растёт он сам. */
/* Документ растягивается диалогом: прокручивается окно, а не диалог.
Страницу не фиксируем: диалог лежит в начале документа и при открытии
страница к нему возвращается. */
body.modal-open { height: auto; min-height: 100vh; }
/* Диалог лежит в начале документа и едет вместе со страницей: absolute от
body, а не relative (иначе он оказывается там, где объявлен, — в конце). */
.overlay { position: absolute; top: 0; left: 0; right: 0; min-height: 100vh; background: rgba(15,23,42,.55); z-index: 100; display: none; justify-content: center; align-items: flex-start; padding: 24px 16px; }
.overlay.open { display: flex; }
/* Диалоги подтверждения всегда выше обычных модалок: иначе из карточки
маршрута нельзя нажать «OK» — он оказывается под ней. */
#dsDialog { z-index: 1000; }
/* Строка списка, которая открывается двойным кликом. */
tr.row-click { cursor: pointer; }
/* Пагинация: один вид на все списки — счётчик, номера страниц, шаги. */
.pager { display: none; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; padding: 12px 4px 4px; border-top: 1px solid var(--divider); margin-top: 10px; }
.pager.on { display: flex; }
.pager .pager-info { color: var(--muted); font-size: 12.5px; }
.pager .pager-pages { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.pager .pager-pages button { min-width: 32px; padding: 0 6px; }
.pager .pager-pages button[disabled] { opacity: 1; cursor: default; }
.pager .pager-gap { color: var(--muted); padding: 0 2px; }
.pager .pager-actions { display: flex; gap: 8px; }
.pager button[disabled] { opacity: .45; cursor: default; }
.modal { background: var(--paper); width: 100%; max-width: 560px; border-radius: 16px; padding: 22px; display: flex; flex-direction: column; gap: 16px; margin: auto; box-shadow: 0 30px 60px -20px rgba(0,0,0,.4); }
.modal h3 { font-size: 19px; font-weight: 800; }
.modal .lead { color: var(--muted); font-size: 13.5px; }
.grid2 { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.grid2 > .fld { flex: 1 1 220px; min-width: 0; }
.modal-foot { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
.invite-result { background: var(--success-soft); border: 1px solid var(--ok-line); border-radius: var(--radius-sm); padding: 14px; text-align: center; display: none; flex-direction: column; gap: 10px; }
.invite-result.on { display: flex; }
.code-box { background: var(--surface-mute); border: 1px dashed var(--line-mute); border-radius: var(--radius-sm); padding: 12px; font-size: 13px; word-break: break-all; }
.notice { display: flex; gap: 10px; align-items: flex-start; background: var(--primary-soft); border: 1px solid var(--info-line); border-radius: var(--radius-sm); padding: 12px 14px; font-size: 13px; color: var(--shell-line); }
.notice .ico { font-size: 16px; }
@media (max-width: 768px) {
.topbar { padding: 12px 14px; }
main { padding: 16px 14px 50px; }
.page-head h1 { font-size: 20px; }
.tbl-wrap { display: none; }
.cards { display: flex; }
.nav { width: 100%; }
}
.map-toggles { display:flex; gap:14px; align-items:center; flex-wrap:wrap; font-size:13px; }
.map-toggles label { display:flex; gap:6px; align-items:center; cursor:pointer; }
/* Своя система z-index у Leaflet (панели 400–800) иначе перекрывает
диалоги и тосты: карта создаёт собственный контекст наложения. */
/* Фон темнее страницы: без подложки заливка зон должна быть видна. */
.day-map { height:440px; background:var(--line-soft); position:relative; z-index:0; }
.map-dot span { display:block; width:14px; height:14px; border-radius:50%; background:var(--primary); border:2px solid var(--paper); box-shadow:0 1px 4px rgba(0,0,0,.35); }
.map-courier-icon { display:flex; align-items:center; justify-content:center; width:26px; height:26px; border-radius:50%; background:var(--paper); border:2px solid var(--success); font-size:14px; box-shadow:0 1px 4px rgba(0,0,0,.35); }
/* Склад: та же форма, другой цвет — на карте не спутать с курьером. */
.map-warehouse-icon { display:flex; align-items:center; justify-content:center; width:26px; height:26px; border-radius:50%; background:var(--paper); border:2px solid var(--link); font-size:13px; box-shadow:0 1px 4px rgba(0,0,0,.35); }
@media (max-width: 600px) { .day-map { height:320px; } .map-toggles { gap:8px; font-size:12px; } }
.toast-wrap { position: fixed; right: 16px; bottom: 16px; z-index: 400; display: flex; flex-direction: column; gap: 10px; max-width: min(420px, 92vw); }
.toast { background: var(--code-bg); color: var(--paper); border-radius: 12px; padding: 12px 16px; box-shadow: 0 14px 34px -12px rgba(0,0,0,.5); font-size: 14px; font-weight: 600; line-height: 1.35; transition: opacity .2s; }
.toast.ok { background: var(--ok-strong); }
.toast.err { background: var(--danger-ink); }
@media (prefers-reduced-motion: no-preference) { .toast { animation: toastIn .18s ease-out; } }
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
.shell { display:flex; min-height:100vh; }
.sidebar { width:248px; flex:none; background:var(--paper); border-right:1px solid var(--divider); display:flex; flex-direction:column; position:sticky; top:0; height:100vh; }
.sidebar .brand-row { padding:16px 18px; border-bottom:1px solid var(--divider); }
.sidebar .menus { padding:10px; display:flex; flex-direction:column; gap:4px; overflow-y:auto; }
.sidebar .menus button { width:100%; justify-content:flex-start; background:transparent; border:none; color:var(--text); padding:10px 12px; border-radius:9px; font-size:14px; font-weight:600; cursor:pointer; display:flex; align-items:center; gap:10px; }
.sidebar .menus button:hover { background:var(--primary-soft); }
.sidebar .menus button.active { background:var(--primary); color:var(--paper); }
.main { flex:1; min-width:0; display:flex; flex-direction:column; }
.headerbar { background:var(--paper); border-bottom:1px solid var(--divider); padding:12px 20px; display:flex; flex-wrap:wrap; gap:14px; align-items:center; position:sticky; top:0; z-index:40; }
.menu-btn { display:none; }
.backdrop { display:none; }
@media (max-width: 899px) {
.sidebar { position:fixed; left:0; top:0; z-index:80; transform:translateX(-100%); transition:transform .2s ease; }
.sidebar.open { transform:none; }
.backdrop.open { display:block; position:fixed; inset:0; background:rgba(17,24,39,.45); z-index:70; }
.menu-btn { display:inline-flex; }
}
@media (max-width: 600px) {
main { padding:16px 12px 48px; }
.headerbar { padding:10px 12px; gap:10px; }
/* Первая строка шапки: «Меню» и переключатель компаний рядом — раньше
селектор уезжал на отдельную строку, оставляя справа пустое место. */
.headerbar .menu-btn { flex: 0 0 auto; }
.headerbar .spacer { display: none; }
.ws-pick { flex: 1 1 auto; min-width: 0; gap: 6px; }
.ws-pick > span { display: none; }
.ws-pick select { max-width: none; width: 100%; }
/* Плитки ресурсов на телефоне — по две в ряд: семь плиток в столбик
заставляли прокручивать полэкрана до маршрутов. */
.kpis { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.kpi { padding: 12px; }
.kpi .val { font-size: 22px; }
.kpi .sub { font-size: 11px; }
}
tr.inwork td { background-color: var(--row-work); }
tr.inwork { box-shadow: inset 3px 0 0 var(--accent-amber); }
.mcard.inwork { border-color: var(--accent-amber); background: var(--row-work); }
.notif-wrap { position: relative; }
.notif-panel { position: absolute; right: 0; top: 44px; width: 360px; max-width: calc(100vw - 24px); max-height: 60vh; overflow: auto; background: var(--paper); color: var(--shell); border: 1px solid var(--divider); border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,.18); z-index: 80; }
.notif-item { padding: 8px 12px; border-bottom: 1px solid var(--surface-line); font-size: 13px; }
.notif-item:last-child { border-bottom: none; }
.notif-item .when { color: var(--dim); font-size: 11px; margin-top: 2px; }
.notif-badge { background: var(--accent-red); color: var(--paper); border-radius: 999px; padding: 0 6px; font-size: 11px; margin-left: 4px; }
</style>
</head>
<body>
<!-- Ворота: вход и мастер создания рабочего пространства -->
<div id="gate" style="position:fixed;inset:0;z-index:300;background:linear-gradient(135deg,var(--primary),var(--primary));display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto;">
<div style="background:var(--paper);border-radius:18px;max-width:520px;width:100%;padding:26px;box-shadow:0 30px 60px -20px rgba(0,0,0,.4);">
<div style="text-align:center;margin-bottom:18px;">
<div style="font-size:32px;">🚚</div>
<div style="font-size:22px;font-weight:800;color:var(--primary);margin-top:6px;">CourierExpert</div>
<div style="color:var(--muted);font-size:14px;">Кабинет компании</div>
</div>
<!-- Вход -->
<div id="gateAuth">
<div style="font-weight:700;font-size:17px;margin-bottom:4px;">Вход и регистрация</div>
<p style="color:var(--muted);font-size:13.5px;margin-bottom:16px;">Введите email — пришлём 6-значный код. Новая компания создаётся после входа в мастере.</p>
<div class="fld" id="gaEmail">
<label>✉️ Email</label>
<input class="input" type="email" id="gaEmailInput" placeholder="director@myshop.ru">
</div>
<div class="fld" id="gaCode" style="display:none;">
<label>🔐 Код из письма <span class="tip">в демо-режиме показывается сразу</span></label>
<input class="input" id="gaCodeInput" placeholder="123456">
</div>
<div id="gaErr" style="display:none;color:var(--danger);font-size:13px;margin-top:10px;"></div>
<button class="btn btn-primary" id="gaBtn" style="width:100%;margin-top:16px;" onclick="gateAuthStep()">Получить код</button>
<button class="btn btn-ghost" id="gaDemo" style="width:100%;margin-top:10px;" onclick="startDemo()">Посмотреть демо кабинета</button>
</div>
<!-- Мастер создания пространства -->
<div id="gateWizard" style="display:none;">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:16px;">
<span class="gwstep gw-on">1</span><span class="gwline"></span>
<span class="gwstep">2</span><span class="gwline"></span>
<span class="gwstep">3</span>
</div>
<div class="gwpanel" data-s="1">
<div style="font-weight:700;font-size:17px;margin-bottom:4px;">Создайте рабочее пространство</div>
<p style="color:var(--muted);font-size:13.5px;margin-bottom:14px;">Это ваша компания или курьерская служба. Внутри будут заказы, курьеры и настройки.</p>
<div class="fld"><label>Название *</label><input class="input" id="gwName" placeholder="Обувь-Сити"></div>
<div class="fld" style="margin-top:12px;"><label>Тип</label>
<select class="select" id="gwType">
<option value="shop">Интернет-магазин</option>
<option value="courier">Курьерская служба</option>
</select>
</div>
</div>
<div class="gwpanel" data-s="2" style="display:none;">
<div style="font-weight:700;font-size:17px;margin-bottom:4px;">Объём доставок</div>
<p style="color:var(--muted);font-size:13.5px;margin-bottom:14px;">Подскажем оптимальные слоты и число курьеров.</p>
<div class="fld"><label>Сколько доставок в день</label>
<select class="select" id="gwOrders">
<option value="до 20">До 20 заказов в день</option>
<option value="20-100" selected>20 – 100 заказов в день</option>
<option value="100-300">100 – 300 заказов в день</option>
<option value="более 300">Более 300 заказов в день</option>
</select>
</div>
</div>
<div class="gwpanel" data-s="3" style="display:none;">
<div style="font-weight:700;font-size:17px;margin-bottom:4px;">Режимы доставки</div>
<p style="color:var(--muted);font-size:13.5px;margin-bottom:14px;">Можно включить оба: и плановые рейсы, и свободные экспресс-заказы.</p>
<label style="display:flex;gap:10px;align-items:center;padding:12px;border:1px solid var(--divider);border-radius:10px;margin-bottom:10px;">
<input type="checkbox" id="gwRoute" checked> <span>🚚 Плановые рейсы (маршрут со склада)</span>
</label>
<label style="display:flex;gap:10px;align-items:center;padding:12px;border:1px solid var(--divider);border-radius:10px;">
<input type="checkbox" id="gwOndemand" checked> <span>⚡ Свободные экспресс-заказы</span>
</label>
</div>
<div id="gwErr" style="display:none;color:var(--danger);font-size:13px;margin-top:10px;"></div>
<div style="display:flex;gap:10px;margin-top:18px;">
<button class="btn btn-ghost" id="gwBack" style="display:none;flex:1;" onclick="gwBack()">Назад</button>
<button class="btn btn-primary" id="gwNext" style="flex:2;" onclick="gwNext()">Продолжить</button>
</div>
</div>
</div>
</div>
<div class="shell">
<div class="backdrop" id="navBackdrop" onclick="toggleMenu(false)"></div>
<aside class="sidebar" id="sidebar">
<div class="brand-row"><div class="brand">CourierExpert <span class="tag">кабинет</span></div></div>
<nav class="menus">
<button id="navDay" onclick="switchSection('day')">📅 Рабочий день</button>
<button id="navDashboard" onclick="switchSection('dashboard')">📊 Обзор</button>
<button id="navOrders" onclick="switchSection('orders')">📦 Заказы</button>
<button id="navCouriers" onclick="switchSection('couriers')">🧑✈️ Курьеры</button>
<button id="navPayroll" onclick="switchSection('payroll')">💰 Выплаты</button>
<button id="navReports" onclick="switchSection('reports')">📈 Отчёты</button>
<button id="navMembers" onclick="switchSection('members')">👥 Участники</button>
<button id="navGroups" onclick="switchSection('groups')">🛡 Группы</button>
<button id="navInvites" onclick="switchSection('invites')">✉️ Приглашения</button>
<button id="navJoin" onclick="switchSection('join')">📝 Заявки</button>
<button id="navRoutes" onclick="switchSection('routes')">🗺 Маршруты</button>
<button id="navIntegrations" onclick="switchSection('integrations')">🔗 Интеграции</button>
<button id="navSettings" onclick="switchSection('settings')">⚙ Настройки</button>
<button id="navProfile" onclick="switchSection('profile')">👤 Профиль</button>
<button id="navAuth" onclick="logout()">🚪 Выход</button>
</nav>
</aside>
<div class="main">
<div class="headerbar">
<button class="btn btn-ghost menu-btn" onclick="toggleMenu(true)">☰ Меню</button>
<div class="ws-pick" style="color:var(--muted);display:flex;align-items:center;gap:8px;font-size:13px;">
<span>Компания:</span>
<select id="wsSelector" class="select" style="max-width:260px;" onchange="changeWorkspace(this.value)"></select>
</div>
<div class="spacer"></div>
<a class="btn btn-ghost" href="?lang=en" title="Язык" style="text-decoration:none;">English</a>
<div id="profileName" class="muted" style="font-size:13px;"></div>
<div class="notif-wrap">
<button class="btn btn-ghost" id="notifBell" onclick="toggleNotifPanel()" title="Уведомления">🔔<span id="notifCount" class="notif-badge" style="display:none;">0</span></button>
<div class="notif-panel" id="notifPanel" style="display:none;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:6px 8px;">
<span class="strong">Уведомления</span>
<button class="btn btn-ghost" style="padding:4px 8px;font-size:12px;" onclick="clearNotifs()">Очистить</button>
</div>
<div id="notifList"></div>
</div>
</div>
</div>
<main>
<section id="sectionDay" style="display:none;flex-direction:column;gap:16px;">
<div class="page-head">
<div>
<h1>Рабочий день</h1>
<p>Заказы, маршруты и курьеры на выбранную дату. Требующее внимания — сверху.</p>
</div>
<div class="actions" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button class="btn btn-ghost" title="Предыдущий день" onclick="dayShift(-1)">‹</button>
<input class="input" type="date" id="dayDate" style="width:158px;" onchange="loadDay()">
<button class="btn btn-ghost" title="Следующий день" onclick="dayShift(1)">›</button>
<span id="dayTz" class="muted" style="font-size:12.5px;" title="Границы дня считаются по часовому поясу компании; филиал или клиент может прислать свою зону."></span>
<button class="btn btn-primary" onclick="openRouteModal()">➕ Создать маршрут</button>
<button class="btn btn-success" onclick="planDay()">🚚 Спланировать день</button>
</div>
</div>
<div class="kpis" id="dayKpis"></div>
<div class="notice" id="dayTzNotice" style="display:none;"></div>
<div class="panel" style="overflow:hidden;">
<div class="toolbar" style="justify-content:space-between;align-items:center;gap:10px;">
<div class="strong">Карта дня</div>
<div class="map-toggles">
<label><input type="checkbox" id="mapShowOrders" checked onchange="mapToggleLayers()"> Заказы</label>
<label><input type="checkbox" id="mapShowRoutes" checked onchange="mapToggleLayers()"> Маршруты</label>
<label><input type="checkbox" id="mapShowCouriers" checked onchange="mapToggleLayers()"> Курьеры</label>
<label><input type="checkbox" id="mapShowZones" checked onchange="mapToggleLayers()"> Зоны</label>
<label><input type="checkbox" id="mapShowWarehouses" checked onchange="mapToggleLayers()"> Склады</label>
<button class="btn btn-ghost" id="mapZoneStart" onclick="mapStartZone()">✏️ Добавить зону</button>
<button class="btn btn-primary" id="mapZoneDone" style="display:none;" onclick="mapFinishZone()">✓ Готово</button>
<button class="btn btn-ghost" id="mapZoneCancel" style="display:none;" onclick="mapCancelZone()">✕ Отмена</button>
</div>
</div>
<div id="dayMap" class="day-map"></div>
<div id="dayMapHint" class="muted" style="padding:8px 12px;font-size:12px;border-top:1px solid var(--divider);"></div>
</div>
<div class="panel">
<div class="toolbar" style="justify-content:space-between;align-items:center;gap:10px;">
<div class="strong">Курьеры на смене</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-ghost" onclick="openVehicleModal()">🚗 Добавить машину</button>
<button class="btn btn-ghost" onclick="loadDayBoard()">↻</button>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Курьер</th>
<th>Машина</th>
<th>Смена</th>
<th>Точек</th>
<th>Загрузка</th>
<th>Состояние</th>
<th></th>
</tr>
</thead>
<tbody id="dayBoardBody"></tbody>
</table>
</div>
<div class="cards" id="dayBoardCards"></div>
</div>
<div class="panel">
<div class="toolbar" style="justify-content:space-between;align-items:center;gap:10px;">
<div class="strong">Маршруты дня</div>
<div id="dayBulk" style="display:none;align-items:center;gap:8px;">
<span class="muted" style="font-size:13px;">Выбрано: <b id="dayBulkCount">0</b></span>
<button class="btn btn-ghost" onclick="dayBulkConfirm()">✅ Подтвердить выбранные</button>
<button class="btn btn-ghost" style="color:var(--danger);" onclick="dayBulkDelete()">🗑 Удалить выбранные</button>
</div>
</div>
<div class="toolbar" id="dayChips" style="gap:8px;"></div>
<div class="toolbar">
<div class="search">
<input class="input" id="daySearch" placeholder="Поиск: код маршрута или курьер" oninput="daySearchChanged()">
</div>
<div style="flex:0 1 220px;">
<select class="select" id="dayCourier" onchange="loadDayRoutes()">
<option value="">Все курьеры</option>
</select>
</div>
</div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th style="width:34px;"><input type="checkbox" id="dayCheckAll" onchange="dayToggleAll(this.checked)"></th>
<th>Код</th>
<th>Курьер</th>
<th>Точек</th>
<th>Состояние</th>
<th>Нарушения</th>
<th></th>
</tr>
</thead>
<tbody id="dayRoutesBody"></tbody>
</table>
</div>
<div class="cards" id="dayRoutesCards"></div>
</div>
<div class="panel" style="padding:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:10px;">
<div class="strong">Лента дня</div>
<div style="display:flex;gap:8px;align-items:center;">
<select class="select" id="dayFeedType" style="width:auto;font-size:13px;" onchange="loadDayFeed(true)">
<option value="">Все события</option>
<option value="order.">Заказы</option>
<option value="route.">Маршруты</option>
</select>
<button class="btn btn-ghost" title="Повторить" onclick="loadDayFeed()">↻</button>
</div>
</div>
<div id="dayFeed" style="display:flex;flex-direction:column;gap:6px;"></div>
<div id="dayFeedMore" style="display:none;padding-top:10px;text-align:center;">
<button class="btn btn-ghost" onclick="loadDayFeed(false)">Показать ещё</button>
</div>
</div>
</section>
<section id="sectionDashboard" style="display:none;flex-direction:column;gap:20px;">
<div class="page-head"><div><h1>Обзор</h1><p>Что происходит в доставке сейчас.</p></div></div>
<div class="kpis">
<div class="kpi"><div class="cap">К распределению</div><div class="val" id="dbQueue">—</div><div class="sub">Плановые, ждут/в работе</div></div>
<div class="kpi"><div class="cap">Свободная доставка</div><div class="val" id="dbPool">—</div><div class="sub">В свободном пуле</div></div>
<div class="kpi"><div class="cap">Курьеры онлайн</div><div class="val green" id="dbOnline">—</div><div class="sub">На смене</div></div>
<div class="kpi"><div class="cap">Вручено</div><div class="val" id="dbDelivered">—</div><div class="sub">Всего</div></div>
</div>
<div class="panel" style="padding:16px;">
<div class="strong" style="margin-bottom:10px;">Быстрые действия</div>
<div style="display:flex;flex-wrap:wrap;gap:10px;">
<button class="btn btn-primary" onclick="switchSection('orders');openModal('createOrderModal')">➕ Новый заказ</button>
<button class="btn btn-success" onclick="switchSection('couriers')">🧑✈️ Пригласить курьера</button>
<button class="btn btn-ghost" onclick="switchSection('routes')">🗺 Маршруты</button>
<button class="btn btn-ghost" onclick="switchSection('join')">📝 Заявки</button>
</div>
</div>
</section>
<!-- ORDERS -->
<section id="sectionOrders" style="display:flex;flex-direction:column;gap:20px;">
<details class="instructions">
<summary>Как работать с заказами</summary>
<ol class="steps-list">
<li>Вкладка выбирает срез: пул, рейсы или все заказы; фильтры и поиск — на сервере.</li>
<li>Двойной клик по строке открывает карточку: состав, адрес, статус и действия.</li>
<li>Заказы без координат отмечены и не попадают в планирование — добавьте точку.</li>
</ol>
</details>
<div class="page-head">
<div>
<h1>Заказы на доставку</h1>
<p>Создавайте заказы, следите за статусами и назначайте курьеров прямо в таблице.</p>
</div>
<div class="actions">
<button class="btn btn-success" onclick="openInviteModal()">➕ Пригласить курьера</button>
<button class="btn btn-primary" onclick="openEntity('order')">➕ Добавить</button>
</div>
</div>
<div class="kpis">
<div class="kpi">
<div class="cap">📦 Активные заказы</div>
<div class="val" id="statOrders">0</div>
<div class="sub">В работе и в пуле</div>
</div>
<div class="kpi">
<div class="cap">⚡️ В свободном пуле</div>
<div class="val" id="statPool">0</div>
<div class="sub">Ждут, пока их возьмёт курьер</div>
</div>
<div class="kpi">
<div class="cap">✅ Вручено сегодня</div>
<div class="val" id="statDelivered">0</div>
<div class="sub">Полные и частичные выкупы</div>
</div>
<div class="kpi">
<div class="cap">💰 Фонд выплат курьерам</div>
<div class="val green" id="statPayroll">0 ₽</div>
<div class="sub">Сдельно, за сегодня</div>
</div>
</div>
<div class="notice">
<span class="ico">💡</span>
<span>Свободный пул — для экспресс-доставки: курьер забирает заказ в точке отправления и везёт получателю. Курьер сам берёт такой заказ из приложения.</span>
</div>
<div class="panel">
<div class="toolbar" style="gap:8px;">
<button class="btn btn-ghost" id="tabOrdersQueue" onclick="setOrdersMode('queue')">К распределению</button>
<button class="btn btn-ghost" id="tabOrdersPool" onclick="setOrdersMode('pool')">Свободная доставка</button>
<button class="btn btn-primary" id="tabOrdersAll" onclick="setOrdersMode('')">Все заказы</button>
</div>
<div class="toolbar">
<div class="search">
<input class="input" id="searchInput" placeholder="🔎 Поиск: № заказа, адрес, телефон или компания" oninput="filterOrders()">
</div>
<div style="flex:0 1 220px;">
<select class="select" id="statusFilter" onchange="filterOrders()">
<option value="">Все статусы</option>
<option value="open_pool">В свободном пуле</option>
<option value="claimed">Взял курьер (шаг 1)</option>
<option value="picked_up">Забран в точке отправления (шаг 2)</option>
<option value="delivered">Вручён полностью</option>
<option value="partially_delivered">Частичный выкуп</option>
<option value="cancelled">Отказ / возврат</option>
</select>
</div>
</div>
</div>
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Заказы на доставку</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Создавайте заказы, следите за статусами и назначайте курьеров прямо в таблице.</p></div></div><div class="bulk-bar" id="bulkBar"><span class="bulk-count" id="bulkCount"></span><button class="btn btn-ghost btn-sm" onclick="bulkSelectPage()">Выбрать все на странице</button><button class="btn btn-primary btn-sm" onclick="bulkAssignOpen()">Назначить курьера</button><button class="btn btn-ghost btn-sm" onclick="bulkMoveOpen()">Перенести на дату</button><button class="btn btn-danger btn-sm" onclick="bulkCancel()">Отменить</button><button class="btn btn-ghost btn-sm" onclick="bulkClear()">Сбросить</button></div><div class="tbl-wrap"><table><thead><tr><th>№ Заказа</th><th>Тип</th><th>Точка отправления</th><th>Вручить покупателю</th><th>Сумма</th><th>Курьер</th><th>Выплата курьеру</th><th>Дата доставки</th><th>ETA</th><th>Статус</th><th></th></tr></thead><tbody id="ordersBody"></tbody></table></div><div class="cards" id="ordersCards"></div><div class="pager" id="pager-orders"><span class="pager-info" id="pager-info-orders"></span><span class="pager-pages" id="pager-pages-orders"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="orders" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="orders" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
</section>
<!-- COURIERS -->
<section id="sectionCouriers" style="display:none;flex-direction:column;gap:20px;">
<details class="instructions">
<summary>Как работать с курьерами</summary>
<ol class="steps-list">
<li>Список показывает лимиты, транспорт и состояние смены.</li>
<li>Двойной клик открывает форму: контакты, лимиты и транспорт.</li>
<li>Смена и машина задаются в «Рабочем дне» и в карточке курьера.</li>
</ol>
</details>
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Курьеры компании</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Добавьте курьера и отправьте ему ссылку-приглашение — он подключится за 30 секунд, без логинов и паролей.</p></div><div class="actions"><button class="btn btn-primary" onclick="openInviteModal()">Добавить</button></div></div><div class="tbl-wrap"><table><thead><tr><th>Курьер</th><th>Телефон</th><th>Транспорт</th><th>Режим</th><th>Статус</th><th></th></tr></thead><tbody id="couriersBody"></tbody></table></div><div class="cards" id="couriersCards"></div><div class="pager" id="pager-couriers"><span class="pager-info" id="pager-info-couriers"></span><span class="pager-pages" id="pager-pages-couriers"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="couriers" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="couriers" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
</section>
<!-- PAYROLL -->
<section id="sectionPayroll" style="display:none;flex-direction:column;gap:20px;">
<div class="page-head">
<div>
<h1>Взаиморасчёты с курьерами</h1>
<p>Начисления за заказы, выплаты документами и остаток к выплате по каждому курьеру.</p>
</div>
<div class="actions">
<button class="btn btn-ghost" onclick="loadPayroll()">🔄 Обновить</button>
</div>
</div>
<div class="kpis">
<div class="kpi"><div class="cap">Начислено</div><div class="val" id="payrollSumAccrued">—</div></div>
<div class="kpi"><div class="cap">Выплачено</div><div class="val" id="payrollSumPaid">—</div></div>
<div class="kpi"><div class="cap">К выплате</div><div class="val" id="payrollSumDue">—</div></div>
</div>
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Баланс курьеров</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Начисления за заказы, выплаты документами и остаток к выплате по каждому курьеру.</p></div></div><div class="tbl-wrap"><table><thead><tr><th>Курьер</th><th>Начислено</th><th>Выплачено</th><th>К выплате</th><th>Документов</th><th></th></tr></thead><tbody id="payrollBody"></tbody></table></div><div class="cards" id="payrollCards"></div><div class="pager" id="pager-payroll"><span class="pager-info" id="pager-info-payroll"></span><span class="pager-pages" id="pager-pages-payroll"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="payroll" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="payroll" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Документы выплат</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Начисления за заказы, выплаты документами и остаток к выплате по каждому курьеру.</p></div></div><div class="tbl-wrap"><table><thead><tr><th>Документ</th><th>Дата</th><th>Курьер</th><th>Сумма</th><th>Способ</th><th></th></tr></thead><tbody id="payoutsBody"></tbody></table></div><div class="cards" id="payoutsCards"></div><div class="pager" id="pager-payouts"><span class="pager-info" id="pager-info-payouts"></span><span class="pager-pages" id="pager-pages-payouts"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="payouts" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="payouts" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
</section>
<!-- API -->
<!-- MEMBERS -->
<section id="sectionMembers" style="display:none;flex-direction:column;gap:20px;">
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Участники пространства</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Сотрудники и курьеры — участники с правами из групп.</p></div><div class="actions"><button class="btn btn-primary" onclick="openMemberCreate()">Добавить</button></div></div><div class="tbl-wrap"><table><thead><tr><th>Имя</th><th>Email</th><th>Роли</th><th></th></tr></thead><tbody id="membersBody"></tbody></table></div><div class="cards" id="membersCards"></div><div class="pager" id="pager-members"><span class="pager-info" id="pager-info-members"></span><span class="pager-pages" id="pager-pages-members"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="members" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="members" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
</section>
<!-- GROUPS -->
<section id="sectionGroups" style="display:none;flex-direction:column;gap:20px;">
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Группы и права</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Группа задаёт права участников. Флаг «Администратор» даёт полный доступ.</p></div><div class="actions"><button class="btn btn-primary" onclick="openGroupCreate()">Добавить</button></div></div><div class="tbl-wrap"><table><thead><tr><th>Название</th><th>Админ</th><th>Права</th><th></th></tr></thead><tbody id="groupsBody"></tbody></table></div><div class="cards" id="groupsCards"></div><div class="pager" id="pager-groups"><span class="pager-info" id="pager-info-groups"></span><span class="pager-pages" id="pager-pages-groups"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="groups" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="groups" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
</section>
<!-- INVITES -->
<section id="sectionInvites" style="display:none;flex-direction:column;gap:20px;">
<div id="invResult" class="notice" style="display:none;"></div>
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Приглашения</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Ссылка выдаёт набор групп. После входа человек возвращается на неё и присоединяется.</p></div><div class="actions"><button class="btn btn-primary" onclick="openEntity('invite')">Добавить</button></div></div><div class="tbl-wrap"><table><thead><tr><th>Название</th><th>Токен</th><th>Статус</th><th></th></tr></thead><tbody id="invitesBody"></tbody></table></div><div class="cards" id="invitesCards"></div><div class="pager" id="pager-invites"><span class="pager-info" id="pager-info-invites"></span><span class="pager-pages" id="pager-pages-invites"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="invites" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="invites" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
</section>
<!-- JOIN REQUESTS -->
<section id="sectionJoin" style="display:none;flex-direction:column;gap:20px;">
<div class="panel">
<div class="toolbar" style="justify-content:space-between; align-items:center; gap:14px;">
<div><h1 style="font-size:20px;font-weight:800;">Заявки на подключение</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Публичная форма-виджет: заявка сразу выдаёт приглашение или ждёт одобрения.</p></div>
<div class="actions"><button class="btn btn-primary" onclick="openFormCreate()">➕ Добавить</button></div>
</div>
<div id="jfResult" class="notice" style="display:none;"></div>
<div class="tbl-wrap"><table><thead><tr><th>Форма / заявка</th><th>Статус</th><th>Ссылка</th><th></th></tr></thead><tbody id="joinBody"></tbody></table></div><div class="cards" id="joinCards"></div>
</div>
</section>
<!-- ROUTES -->
<section id="sectionRoutes" style="display:none;flex-direction:column;gap:20px;">
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Маршруты</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Соберите маршрут курьера на дату и задайте порядок остановок.</p></div><div class="actions"><button class="btn btn-primary" onclick="openEntity('route')">Добавить</button></div></div><div class="tbl-wrap"><table><thead><tr><th>Дата</th><th>Курьер</th><th>Остановок</th><th>Статус</th><th></th></tr></thead><tbody id="routesBody"></tbody></table></div><div class="cards" id="routesCards"></div><div class="pager" id="pager-routes"><span class="pager-info" id="pager-info-routes"></span><span class="pager-pages" id="pager-pages-routes"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="routes" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="routes" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
</section>
<!-- INTEGRATIONS -->
<section id="sectionIntegrations" style="display:none;flex-direction:column;gap:20px;">
<div class="panel">
<details class="instructions" style="margin-bottom:14px;">
<summary>Как это работает</summary>
<ol class="steps-list">
<li>Добавьте внешнюю систему: название, адрес вебхука и кодовую фразу для подписи.</li>
<li>Нажмите «Выпустить ключ» — он показывается один раз; этим ключом система вызывает наш API.</li>
<li>Отметьте события: какие статусы заказа отправлять. Флаг «только свои» оставляет системе лишь её заказы.</li>
<li>Проверьте связь кнопкой «Проверить»; журнал ниже показывает каждую отправку и повторы.</li>
</ol>
</details>
</div>
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Интеграции и вебхуки</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Внешние системы, куда при смене статуса заказа уходит подписанное уведомление.</p></div><div class="actions"><button class="btn btn-primary" onclick="openIntegrationCard()">Добавить</button></div></div><div class="tbl-wrap"><table><thead><tr><th>Система</th><th>Адрес</th><th>События</th><th>Статус</th><th></th></tr></thead><tbody id="integrationsBody"></tbody></table></div><div class="cards" id="integrationsCards"></div><div class="pager" id="pager-integrations"><span class="pager-info" id="pager-info-integrations"></span><span class="pager-pages" id="pager-pages-integrations"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="integrations" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="integrations" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
<div class="panel">
<div style="padding:14px;">
<div class="muted" style="font-size:12.5px;margin-bottom:8px;">Последние отправки:</div>
<div id="webhookLogs"></div>
</div>
</div>
</section>
<!-- REPORTS -->
<section id="sectionReports" style="display:none;flex-direction:column;gap:20px;">
<div class="page-head">
<div>
<h1>Отчёт</h1>
<p>Итоги дня: доставки, деньги, курьеры и нарушения.</p>
</div>
<div class="actions" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button class="btn btn-ghost" title="Предыдущий день" onclick="reportShift(-1)">‹</button>
<input class="input" type="date" id="reportDate" style="width:150px;" onchange="loadReport()">
<span class="dim">—</span>
<input class="input" type="date" id="reportDateTo" style="width:150px;" onchange="loadReport()">
<button class="btn btn-ghost" title="Следующий день" onclick="reportShift(1)">›</button>
<button class="btn btn-ghost" onclick="exportReportCsv()">⬇ Выгрузить CSV</button>
<button class="btn btn-primary" onclick="printReport()">🖨 Печать отчёта</button>
</div>
</div>
<div class="kpis" id="reportKpis"></div>
<div class="panel" style="padding:16px 18px;">
<div class="toolbar" style="justify-content:space-between;margin-bottom:8px;">
<div class="strong">Динамика по дням</div>
<div class="dim" style="font-size:12px;">доставки и отказы — столбики, выплаты — линия</div>
</div>
<div id="reportChart" style="width:100%;overflow-x:auto;"></div>
</div>
<div class="panel">
<div class="toolbar"><div class="strong">По курьерам</div></div>
<div class="tbl-wrap">
<table>
<thead>
<tr>
<th>Курьер</th>
<th>Доставлено</th>
<th>Частично</th>
<th>Отказы</th>
<th>Точек</th>
<th>Пробег</th>
<th>Выплата</th>
</tr>
</thead>
<tbody id="reportBody"></tbody>
</table>
</div>
<div class="cards" id="reportCards"></div>
</div>
<div class="panel" style="padding:16px;">
<div class="strong" style="margin-bottom:10px;">Нарушения</div>
<div id="reportViolations" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
</div>
</section>
<!-- SETTINGS -->
<section id="sectionSettings" style="display:none;flex-direction:column;gap:20px;">
<div class="page-head"><div><h1>Настройки организации</h1><p>Правила выдачи заказов и часовой пояс этого рабочего пространства.</p></div></div>
<div class="panel" style="padding:20px;display:flex;flex-direction:column;gap:16px;">
<div class="fld">
<label>Часовой пояс компании <span class="tip">По нему считаются границы рабочего дня диспетчера. Филиал или клиент может прислать свою зону в запросе — она важнее этой настройки.</span></label>
<input class="input" id="setTimezone" list="tzOptions" placeholder="Europe/Moscow" autocomplete="off">
<datalist id="tzOptions">
<option value="Europe/Kaliningrad" label="Калининград (UTC+2)"></option>
<option value="Europe/Moscow" label="Москва (UTC+3)"></option>
<option value="Europe/Samara" label="Самара (UTC+4)"></option>
<option value="Asia/Yekaterinburg" label="Екатеринбург (UTC+5)"></option>
<option value="Asia/Omsk" label="Омск (UTC+6)"></option>
<option value="Asia/Krasnoyarsk" label="Красноярск (UTC+7)"></option>
<option value="Asia/Irkutsk" label="Иркутск (UTC+8)"></option>
<option value="Asia/Yakutsk" label="Якутск (UTC+9)"></option>
<option value="Asia/Vladivostok" label="Владивосток (UTC+10)"></option>
<option value="Asia/Magadan" label="Магадан (UTC+11)"></option>
<option value="Asia/Kamchatka" label="Петропавловск-Камчатский (UTC+12)"></option>
</datalist>
</div>
<label style="display:flex;gap:10px;align-items:center;font-size:14px;">
<input type="checkbox" id="setRequireScan">
<span>Требовать сканирование кодов маркировки перед вручением</span>
</label>
<label style="display:flex;gap:10px;align-items:center;font-size:14px;">
<input type="checkbox" id="setRequireReturnScan">
<span>Требовать скан кода возврата при отказе от маркированного товара</span>
</label>
<label style="display:flex;gap:10px;align-items:center;font-size:14px;">
<input type="checkbox" id="setPickupScan">
<span>Требовать сканирование кодов маркировки при заборе заказа</span>
</label>
<label style="display:flex;gap:10px;align-items:center;font-size:14px;">
<input type="checkbox" id="setSmsEnabled">
<span>Подтверждение выдачи кодом по SMS</span>
</label>
<div class="fld">
<label>Ключ аккаунта sms.ru <span class="tip">Нужен для отправки кодов клиентам; используется только для SMS этого пространства</span></label>
<input class="input" id="setSmsKey" placeholder="API-ключ sms.ru">
</div>
<div class="fld">
<label>Язык SMS клиентам <span class="tip">Все сообщения клиентам этого пространства уходят на выбранном языке</span></label>
<select class="input" id="setSmsLocale">
<option value="ru">Русский</option>
<option value="en">English</option>
</select>
</div>
<div class="fld">
<label>Приглашения агентов
<span class="tip">Кто может подключать новых агентов к этому пространству</span>
</label>
<select class="input" id="setInvitesMode">
<option value="link">По ссылке-приглашению</option>
<option value="admin">Только администратор</option>
<option value="off">Приглашения выключены</option>
</select>
</div>
<div class="fld">
<label>Параметры курьера по умолчанию
<span class="tip">С ними создаётся курьер, когда принимает приглашение по ссылке</span>
</label>
<div class="grid2">
<select class="input" id="setCourierTransport">
<option value="foot">Пеший</option>
<option value="bicycle">Велосипед</option>
<option value="car">Легковое авто</option>
<option value="van">Фургон</option>
<option value="truck">Грузовик</option>
</select>
<select class="input" id="setCourierMode">
<option value="both">Рейсы и экспресс</option>
<option value="route">Только рейсы</option>
<option value="ondemand">Только экспресс</option>
</select>
<input class="input" type="number" step="0.01" id="setCourierVolume" placeholder="Объём, м³">
<input class="input" type="number" id="setCourierWeight" placeholder="Грузоподъёмность, кг">
<input class="input" type="number" id="setCourierMaxOrders" placeholder="Заказов сразу">
<input class="input" id="setCourierRegion" placeholder="Регион работы">
<input class="input" type="number" id="setInviteTtl" placeholder="Срок приглашения, часов">
<input class="input" type="number" id="setInviteLimit" placeholder="Предел активных приглашений">
</div>
</div>
<div><button class="btn btn-primary" onclick="saveSettings()">Сохранить настройки</button></div>
</div>
<div class="panel"><div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;"><div><h1 style="font-size:20px;font-weight:800;">Склады отправки</h1><p style="color:var(--muted);font-size:13px;margin-top:2px;">Точка, откуда курьеры начинают рейс. Планировщик считает километраж от склада.</p></div><div class="actions"><button class="btn btn-primary" onclick="openWarehouseModal()">Добавить склад</button></div></div><div class="tbl-wrap"><table><thead><tr><th>Название</th><th>Адрес</th><th>Координаты</th><th></th></tr></thead><tbody id="warehousesBody"></tbody></table></div><div class="cards" id="warehousesCards"></div><div class="pager" id="pager-warehouses"><span class="pager-info" id="pager-info-warehouses"></span><span class="pager-pages" id="pager-pages-warehouses"></span><span class="pager-actions"><button class="btn btn-ghost btn-sm" data-kind="warehouses" data-step="-1" onclick="listStep(this)">‹ Назад</button><button class="btn btn-ghost btn-sm" data-kind="warehouses" data-step="1" onclick="listStep(this)">Вперёд ›</button></span></div></div>
</section>
<section id="sectionProfile" style="display:none;flex-direction:column;gap:20px;">
<div class="page-head"><div><h1>Профиль</h1><p>Кто вошёл и какие у него пространства и права.</p></div></div>
<div class="panel" style="padding:20px;display:flex;flex-direction:column;gap:12px;">
<div id="profileBody" class="muted">Загрузка…</div>
<div><button class="btn btn-ghost" onclick="logout()">🚪 Выйти</button></div>
</div>
</section>
<div class="overlay" id="courierSettlementModal"><div class="modal" style="max-width:760px;">
<h3 id="settlementTitle">Взаиморасчёты</h3>
<div class="kpis">
<div class="kpi"><div class="cap">Начислено</div><div class="val" id="settlementAccrued">—</div></div>
<div class="kpi"><div class="cap">Выплачено</div><div class="val" id="settlementPaid">—</div></div>
<div class="kpi"><div class="cap">К выплате</div><div class="val" id="settlementDue">—</div></div>
</div>
<div class="strong" style="margin-top:6px;">Начисления по заказам</div>
<div class="tbl-wrap">
<table><thead><tr><th>Заказ</th><th>Дата</th><th>Сумма</th><th>Выплата</th></tr></thead><tbody id="settlementAccruals"></tbody></table>
</div>
<div class="strong" style="margin-top:10px;">Документы выплат</div>
<div class="tbl-wrap">
<table><thead><tr><th>Документ</th><th>Дата</th><th>Сумма</th><th></th></tr></thead><tbody id="settlementPayouts"></tbody></table>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" onclick="closeModal('courierSettlementModal')">Закрыть</button>
<button class="btn btn-primary" id="settlementPayBtn" onclick="openPayoutCreate()">Выплатить</button>
</div>
</div></div>
<div class="overlay" id="entityModal"><div class="modal" style="max-width:620px;">
<h3 id="entityTitle"></h3>
<p class="lead" id="entityLead" style="display:none;"></p>
<div id="entityFields" style="display:flex;flex-direction:column;gap:10px;"></div>
<div id="entityError" class="notice" style="display:none;background:var(--danger-soft);color:var(--danger);"></div>
<div class="modal-foot">
<button class="btn btn-ghost" id="entityDelete" style="display:none;margin-right:auto;" onclick="removeEntity()">Удалить</button>
<button class="btn btn-ghost" onclick="closeModal('entityModal')">Отмена</button>
<button class="btn btn-primary" id="entitySave" onclick="saveEntity()">Сохранить</button>
</div>
</div></div>
<div class="overlay" id="courierInviteModal">
<div class="modal">
<h3>Пригласить курьера</h3>
<p class="lead">Введите имя и телефон — система выдаст персональную ссылку и QR. Курьер откроет их на телефоне и сразу начнёт работу.</p>
<div class="grid2">
<div class="fld">
<label>👤 Имя и фамилия курьера</label>
<input class="input" id="invCourierName" placeholder="Дмитрий Соколов">
</div>
<div class="fld">
<label>📞 Телефон</label>
<input class="input" id="invCourierPhone" placeholder="+7 (916) 000-00-00">
</div>
</div>
<div class="fld">
<label>🚦 Режим работы курьера <span class="tip">В одной компании могут работать и «рейсовые», и «экспресс» курьеры</span></label>
<select class="select" id="invCourierMode">
<option value="both">И рейсы, и экспресс (универсальный)</option>
<option value="route">Только плановые рейсы (маршрут со склада)</option>
<option value="ondemand">Только свободные экспресс-заказы</option>
</select>
</div>
<div class="grid2">
<div class="fld">
<label>🚗 Транспорт</label>
<select class="select" id="invVehicle">
<option value="foot">Пеший</option>
<option value="bicycle">Велосипед</option>
<option value="car" selected>Легковое авто</option>
<option value="van">Фургон</option>
<option value="truck">Грузовик</option>
</select>
</div>
<div class="fld">
<label>Максимум заказов сразу</label>
<input class="input" type="number" id="invMaxOrders" value="1" min="1">
</div>
</div>
<div class="grid2">
<div class="fld">
<label>Объём груза, м³</label>
<input class="input" type="number" step="0.01" id="invVolume" placeholder="например 0.5">
</div>
<div class="fld">
<label>Грузоподъёмность, кг</label>
<input class="input" type="number" id="invWeight" placeholder="например 20">
</div>
</div>
<div class="fld">
<label>Регион / зона работы</label>
<input class="input" id="invRegion" placeholder="Например, Москва и МО">
</div>
<div class="invite-result" id="inviteResultArea">
<div class="strong">Ссылка-приглашение готова</div>
<input class="input mono" id="generatedInviteUrl" readonly style="text-align:center;">
<a id="openCourierTermBtn" href="#" target="_blank" class="btn btn-success">Открыть кабинет курьера</a>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" onclick="closeModal('courierInviteModal')">Закрыть</button>
<button class="btn btn-success" id="btnGenInvite" onclick="submitGenerateInvite()">Сгенерировать приглашение</button>
</div>
</div>
</div>
<!-- Modal: Auth -->
<div class="overlay" id="authModal">
<div class="modal">
<h3>Вход по email</h3>
<p class="lead">Введите email — пришлём 6-значный код. Так вы войдёте в свою компанию без пароля.</p>
<div class="fld" id="authEmailGroup">
<label>✉️ Email</label>
<input class="input" type="email" id="authEmailInput" placeholder="director@myshop.ru">
</div>
<div class="fld" id="authCodeGroup" style="display:none;">
<label>🔐 Код из письма <span class="tip">В демо-режиме код показывается сразу</span></label>
<input class="input" id="authCodeInput" placeholder="123456">
</div>
<div class="modal-foot">
<button class="btn btn-ghost" onclick="closeModal('authModal')">Отмена</button>
<button class="btn btn-primary" id="btnAuthStep" onclick="authStep()">Получить код</button>
</div>
</div>
</div>
<div class="toast-wrap" id="dsToasts"></div>
<div class="overlay" id="dsDialog">
<div class="modal" style="max-width:540px;">
<h3 id="dsTitle">Сообщение</h3>
<p class="lead" id="dsMessage" style="white-space:pre-wrap;"></p>
<div class="fld" id="dsPromptWrap" style="display:none;">
<textarea class="input" id="dsPromptInput" rows="4" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;"></textarea>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" id="dsCancel">Отмена</button>
<button class="btn btn-primary" id="dsOk">OK</button>
</div>
</div>
</div>
<div class="overlay" id="orderViewModal"><div class="modal">
<h3 id="orderViewTitle">Заказ</h3>
<div id="orderViewBody" style="display:flex;flex-direction:column;gap:10px;"></div>
<div class="modal-foot"><button class="btn btn-ghost" onclick="closeModal('orderViewModal')">Закрыть</button></div>
</div></div>
<div class="overlay" id="orderAssignModal"><div class="modal">
<h3>Назначить курьера</h3>
<input type="hidden" id="oaId">
<div class="fld"><label>Курьер</label><select class="select" id="oaCourier"></select></div>
<div class="modal-foot"><button class="btn btn-ghost" onclick="closeModal('orderAssignModal')">Отмена</button><button class="btn btn-primary" onclick="saveOrderAssign()">Назначить</button></div>
</div></div>
<div class="overlay" id="bulkAssignModal"><div class="modal">
<h3>Назначить курьера выбранным заказам</h3>
<div class="fld"><label>Курьер</label><select class="select" id="bulkCourier"></select></div>
<div class="modal-foot"><button class="btn btn-ghost" onclick="closeModal('bulkAssignModal')">Отмена</button><button class="btn btn-primary" onclick="bulkRun('assign')">Применить</button></div>
</div></div>
<div class="overlay" id="bulkMoveModal"><div class="modal">
<h3>Перенести выбранные заказы на дату</h3>
<div class="fld"><label>Дата доставки</label><input class="input" id="bulkDate" type="date"></div>
<div class="modal-foot"><button class="btn btn-ghost" onclick="closeModal('bulkMoveModal')">Отмена</button><button class="btn btn-primary" onclick="bulkRun('move')">Применить</button></div>
</div></div>
<div class="overlay" id="inviteEditModal"><div class="modal">
<h3>Изменить приглашение</h3>
<input type="hidden" id="ieId">
<div class="fld"><label>Название</label><input class="input" id="ieName"></div>
<div class="fld" style="margin-top:12px;"><label>Группы</label><select class="select" id="ieGroups" multiple size="4"></select></div>
<div class="modal-foot"><button class="btn btn-ghost" onclick="closeModal('inviteEditModal')">Отмена</button><button class="btn btn-primary" onclick="saveInviteEdit()">Сохранить</button></div>
</div></div>
<div class="overlay" id="formModal"><div class="modal">
<h3 id="formModalTitle">Создать форму заявок</h3>
<input type="hidden" id="jfEditId">
<div class="fld"><label>Заголовок</label><input class="input" id="jfTitle" placeholder="Требуются курьеры"></div>
<div class="fld" style="margin-top:12px;"><label>Режим</label><select class="select" id="jfMode"><option value="auto">Авто-приглашение</option><option value="moderation">С одобрением</option></select></div>
<div class="fld" style="margin-top:12px;"><label>Группы</label><select class="select" id="jfGroups" multiple size="4"></select></div>
<div class="modal-foot"><button class="btn btn-ghost" onclick="closeModal('formModal')">Отмена</button><button class="btn btn-primary" id="formModalSubmit" onclick="saveForm()">Создать</button></div>
</div></div>
<div class="overlay" id="issuedKeyModal"><div class="modal" style="max-width:560px;">
<h3>Ключ выпущен</h3>
<p class="muted" style="font-size:13px;">Ключ показывается один раз — сохраните его на стороне системы. Второй раз посмотреть нельзя, только выпустить новый.</p>
<div class="mono" id="issuedKey" style="word-break:break-all;background:var(--soft);border:1px solid var(--divider);border-radius:8px;padding:10px;"></div>
<div id="issuedSecretRow" style="display:none;">
<div class="muted" style="font-size:12.5px;margin:10px 0 4px;">Кодовая фраза</div>
<div class="mono" id="issuedSecret" style="word-break:break-all;background:var(--soft);border:1px solid var(--divider);border-radius:8px;padding:10px;"></div>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" onclick="copyIssuedKey()">Копировать</button>
<button class="btn btn-primary" onclick="closeModal('issuedKeyModal')">Закрыть</button>
</div>
</div></div>
<div class="overlay" id="vehicleModal"><div class="modal">
<h3>Добавить машину</h3>
<div class="grid2">
<div class="fld"><label>Госномер</label><input class="input" id="vehNumber" placeholder="A123BC77"></div>
<div class="fld"><label>Тип</label><select class="select" id="vehType">
<option value="car">🚗 Авто</option>
<option value="van">🚐 Фургон</option>
<option value="truck">🚚 Грузовик</option>
<option value="bicycle">🚲 Вело</option>
<option value="foot">🚶 Пеший</option>
</select></div>
</div>
<div class="grid2">
<div class="fld"><label>Грузоподъёмность, кг</label><input class="input" type="number" id="vehWeight" placeholder="1500"></div>
<div class="fld"><label>Объём, м³</label><input class="input" type="number" step="0.1" id="vehVolume" placeholder="8"></div>
</div>
<div class="fld"><label>Закрепить за курьером</label><select class="select" id="vehCourier"></select></div>
<div class="modal-foot">
<button class="btn btn-ghost" onclick="closeModal('vehicleModal')">Отмена</button>
<button class="btn btn-primary" onclick="createVehicle()">Создать</button>
</div>
</div></div>
<div class="overlay" id="dayRouteModal"><div class="modal" style="max-width:760px;">
<h3 id="dayRouteTitle">Открыть маршрут</h3>
<div class="muted" id="dayRouteMeta" style="font-size:13px;"></div>
<div class="grid2">
<div class="fld"><label>Назначить курьера <span class="tip">После смены курьера план снова черновик — подтвердите заново.</span></label><select class="select" id="dayRouteCourier"></select></div>
<div class="fld" style="flex:0 0 auto;min-width:0;"><button class="btn btn-primary" onclick="dayAssignCourier()">Назначить курьера</button></div>
</div>
<div class="fld">
<label>Порядок остановок <span class="tip">Перетащите строку, чтобы изменить порядок.</span></label>
<div id="dayRouteStops" style="display:flex;flex-direction:column;gap:6px;max-height:320px;overflow:auto;"></div>
</div>
<div class="fld"><label>Нарушения</label><div id="dayRouteViolations"></div></div>
<div class="modal-foot" style="flex-wrap:wrap;">
<button class="btn btn-ghost" style="color:var(--danger);" onclick="dayDeleteRoute()">🗑 Удалить маршрут</button>
<button class="btn btn-ghost" onclick="dayRecalcRoute()">🔄 Пересчитать</button>
<button class="btn btn-ghost" onclick="dayPrintRoute()">🖨 Печать листа</button>
<button class="btn btn-ghost" onclick="dayPrintInvoices()">🧾 Печать накладных</button>
<button class="btn btn-ghost" onclick="dayLockRoute()">🔒 Заблокировать</button>
<button class="btn btn-primary" onclick="dayConfirmRoute()">✅ Подтвердить</button>
<button class="btn btn-ghost" onclick="closeModal('dayRouteModal')">Закрыть</button>
</div>
</div></div>
<div class="overlay" id="inviteViewModal"><div class="modal">
<h3 id="inviteViewTitle">Приглашение</h3>
<div id="inviteViewBody" style="display:flex;flex-direction:column;gap:10px;"></div>
<div class="modal-foot"><button class="btn btn-ghost" id="inviteViewDelete" style="display:none;color:var(--danger);" onclick="removeInviteFromView()">Удалить</button><button class="btn btn-primary" onclick="closeModal('inviteViewModal')">Закрыть</button></div>
</div></div>
<script src="/leaflet/leaflet.js"></script>
<script>
// Словарь этого же языка — для тостов, диалогов и сообщений об ошибках.
// Сервер уже отрендерил разметку; браузеру остаётся перевести то, что
// появляется после действий человека.
window.__I18N__ = {"lang":"ru","t":{"meta":{"cabinetTitle":"CourierExpert — кабинет диспетчера"},"common":{"save":"Сохранить","addRow":"Добавить строку","saving":"Сохраняем…","deleteConfirm":"Удалить запись?","deleted":"Удалено","removeRouteConfirm":"Удалить маршрут? Заказы вернутся в пул.","fieldRequired":"поле обязательно","errorWord":"ошибка","cancel":"Отмена","delete":"Удалить","edit":"Изменить","create":"Создать","add":"Добавить","close":"Закрыть","back":"Назад","next":"Продолжить","yes":"Да","no":"Нет","loading":"Загрузка…","empty":"Пусто","error":"Ошибка","retry":"Повторить","search":"Поиск","all":"Все","actions":"Действия","name":"Название","email":"Email","phone":"Телефон","company":"Компания","status":"Статус","sum":"Сумма","date":"Дата","copy":"Копировать","copied":"Скопировано","rangeOf":"{from}–{to} из {total}","pageOf":"страница {page} из {pages}","prev":"Назад","nextStep":"Вперёд","daySaved":"Сохранено","required":"Заполните обязательные поля","confirm":"Подтверждение","value":"Значение","input":"Ввод","noData":"Данных нет"},"gate":{"cabinet":"Кабинет компании","authTitle":"Вход и регистрация","authHint":"Введите email — пришлём 6-значный код. Новая компания создаётся после входа в мастере.","email":"Email","code":"Код из письма","codeHint":"в демо-режиме показывается сразу","getCode":"Получить код","demo":"Посмотреть демо кабинета","step":"Шаг {step} из 3","wizardTitle":"Создайте рабочее пространство","wizardHint":"Это ваша компания или курьерская служба. Внутри будут заказы, курьеры и настройки.","name":"Название","namePlaceholder":"Обувь-Сити","type":"Тип","typeShop":"Интернет-магазин","typeCourier":"Курьерская служба","volumeTitle":"Объём доставок","volumeHint":"Подскажем оптимальные слоты и число курьеров.","volumeLabel":"Сколько доставок в день","volumeUpTo20":"До 20 заказов в день","volume20to100":"20 – 100 заказов в день","volume100to300":"100 – 300 заказов в день","volumeOver300":"Более 300 заказов в день","modesTitle":"Режимы доставки","modesHint":"Можно включить оба: и плановые рейсы, и свободные экспресс-заказы.","modeRoute":"Плановые рейсы (маршрут со склада)","createSpace":"Создать пространство","modeOnDemand":"Свободные экспресс-заказы"},"nav":{"day":"Рабочий день","dashboard":"Обзор","orders":"Заказы","couriers":"Курьеры","payroll":"Выплаты","reports":"Отчёты","members":"Участники","groups":"Группы","invites":"Приглашения","join":"Заявки","routes":"Маршруты","integrations":"Интеграции","settings":"Настройки","profile":"Профиль","logout":"Выход","menu":"Меню","cabinet":"кабинет","company":"Компания:","notifications":"Уведомления","clear":"Очистить","language":"Язык","langOther":"English"},"day":{"title":"Рабочий день","lead":"Заказы, маршруты и курьеры на выбранную дату. Требующее внимания — сверху.","prev":"Предыдущий день","next":"Следующий день","plan":"Создать маршрут","resActive":"Активных заказов","resActiveHint":"Ждут, назначены, в работе","resPool":"В свободном пуле","resPoolHint":"Ждут курьера","resCouriers":"Курьеров на смене","resCouriersHint":"Свободны и заняты","resFree":"Свободны","resFreeHint":"На смене без маршрута","resNoGeo":"Без координат","resNoGeoHint":"Не попадут в автоплан","resViolations":"Нарушений","resViolationsHint":"В подтверждённых планах","resMoney":"Выплаты за день","resMoneyHint":"Сдельно курьерам","routesTitle":"Маршруты дня","mapTitle":"Карта дня","mapOrders":"Заказы","mapRoutes":"Маршруты","mapCouriers":"Курьеры","mapZones":"Зоны","mapWarehouses":"Склады","mapError":"Не удалось загрузить точки карты","mapLimited":"показаны не все заказы — приблизьте карту","mapNoTiles":"подложка карты не подключена","autoPlan":"Спланировать день","autoPlanConfirm":"Распределить свободный пул по курьерам на смене и пересчитать порядок остановок?","autoPlanAssigned":"Назначено заказов","autoPlanRoutes":"Маршрутов затронуто","autoPlanNoGeo":"Пропущено без координат","autoPlanRejected":"Не распределено","reason_no_courier":"нет подходящего курьера","reason_no_geo":"нет координат","distance":"Расстояние","duration":"В пути","km":"км","min":"мин","estimated":"оценка","recalc":"Пересчитать","arrival":"Прибытие","boardTitle":"Курьеры на смене","boardCourier":"Курьер","boardVehicle":"Машина","boardShift":"Смена","boardLoad":"Загрузка","boardFree":"Свободен","boardBusy":"В рейсе","boardOffShift":"Не на смене","addVehicle":"Добавить машину","vehicleNumber":"Госномер","vehicleType":"Тип","vehicleWeight":"Грузоподъёмность, кг","vehicleVolume":"Объём, м³","vehicleCourier":"Закрепить за курьером","shiftOpen":"Открыть смену","shiftIsOpen":"Смена открыта","shiftClosed":"Смена не открыта","kg":"кг","mapDrawZone":"Добавить зону","mapDrawDone":"Готово","mapDrawHint":"Кликайте по карте, чтобы поставить вершины зоны, затем нажмите «Готово».","mapDrawPoints":"Вершин","mapZoneFew":"Нужно минимум три вершины","mapZoneName":"Название зоны","feed":"Лента дня","feedEmpty":"Событий за этот день нет.","showMore":"Показать ещё","feedAll":"Все события","timezoneHint":"Границы дня считаются по часовому поясу компании; филиал или клиент может прислать свою зону.","tzNotice":"Внимание: есть заказы из других часовых поясов.","search":"Поиск: код маршрута или курьер","courierAll":"Все курьеры","thCode":"Код","thCourier":"Курьер","thStops":"Точек","thState":"Состояние","thViolations":"Нарушения","stateDraft":"Черновик","statePlanned":"Спланирован","stateConfirmed":"Подтверждён","stateLocked":"Заблокирован","chipDraft":"Черновики","chipPlanned":"Спланированные","chipConfirmed":"Подтверждённые","chipViolations":"С нарушениями","chipAll":"Все","emptyRoutes":"На этот день маршрутов нет. Соберите первый из заказов.","emptyFiltered":"Под фильтр ничего не подошло.","openRoute":"Открыть маршрут","confirm":"Подтвердить","lock":"Заблокировать","assign":"Назначить курьера","assignHint":"После смены курьера план снова черновик — подтвердите заново.","unassign":"Снять курьера","noCourier":"Не назначен","deleteDraft":"Удалить маршрут","print":"Печать листа","printInvoices":"Печать накладных","invoiceTitle":"Накладная","invoiceItem":"Позиция","invoiceQty":"Кол-во","invoicePrice":"Цена","invoiceSum":"Сумма","invoiceTotal":"Итого","invoicePaid":"Оплачено","invoicePayment":"Оплата","invoiceSign":"Подпись получателя","selected":"Выбрано","confirmSelected":"Подтвердить выбранные","deleteSelected":"Удалить выбранные","stop":"Остановка","orderMissing":"Заказ не найден","noViolations":"Нарушений нет","violationsTitle":"Нарушения","routeSheet":"Маршрутный лист","sheetDate":"Дата","sheetCourier":"Курьер","sheetCode":"Код","sheetStops":"Остановки","sheetTotal":"Всего точек","sheetSlot":"Интервал","sheetAddress":"Адрес","sheetRecipient":"Получатель","sheetPhone":"Телефон","sheetPrintHint":"Печать — из браузера, лист без карты и кнопок.","stopsOrder":"Порядок остановок","dragHint":"Перетащите строку, чтобы изменить порядок.","saved":"Сохранено"},"violations":{"no_geo":"Нет координат точки доставки","courier_missing":"Не назначен курьер","courier_offline":"Курьер не в сети","over_orders":"Точек больше предела курьера","over_weight":"Вес больше вместимости","over_volume":"Объём больше вместимости","weight_unknown":"Вес заказов не указан — сумма неполная","volume_unknown":"Объём заказов не указан — сумма неполная","timezone_mismatch":"Заказ из другого часового пояса, чем рабочий день","slot_missed":"Не успеваем в окно доставки"},"plural":{"events":{"one":"событие","few":"события","many":"событий","other":"событий"},"stops":{"one":"точка","few":"точки","many":"точек","other":"точек"}},"tz":{"kaliningrad":"Калининград (UTC+2)","moscow":"Москва (UTC+3)","samara":"Самара (UTC+4)","yekaterinburg":"Екатеринбург (UTC+5)","omsk":"Омск (UTC+6)","krasnoyarsk":"Красноярск (UTC+7)","irkutsk":"Иркутск (UTC+8)","yakutsk":"Якутск (UTC+9)","vladivostok":"Владивосток (UTC+10)","magadan":"Магадан (UTC+11)","kamchatka":"Петропавловск-Камчатский (UTC+12)"},"dashboard":{"title":"Обзор","lead":"Что происходит в доставке сейчас.","queue":"К распределению","queueHint":"Плановые, ждут/в работе","pool":"Свободная доставка","poolHint":"В свободном пуле","online":"Курьеры онлайн","onlineHint":"На смене","delivered":"Вручено","deliveredHint":"Всего","quick":"Быстрые действия","newOrder":"Новый заказ","inviteCourier":"Пригласить курьера","routes":"Маршруты","join":"Заявки"},"orders":{"helpTitle":"Как работать с заказами","help1":"Вкладка выбирает срез: пул, рейсы или все заказы; фильтры и поиск — на сервере.","help2":"Двойной клик по строке открывает карточку: состав, адрес, статус и действия.","help3":"Заказы без координат отмечены и не попадают в планирование — добавьте точку.","title":"Заказы на доставку","lead":"Создавайте заказы, следите за статусами и назначайте курьеров прямо в таблице.","inviteCourier":"Пригласить курьера","add":"Добавить","statActive":"Активные заказы","statActiveHint":"В работе и в пуле","statPool":"В свободном пуле","statPoolHint":"Ждут, пока их возьмёт курьер","statDelivered":"Вручено сегодня","statDeliveredHint":"Полные и частичные выкупы","statPayroll":"Фонд выплат курьерам","statPayrollHint":"Сдельно, за сегодня","poolNotice":"Свободный пул — для экспресс-доставки: курьер забирает заказ в точке отправления и везёт получателю. Курьер сам берёт такой заказ из приложения.","tabQueue":"К распределению","tabPool":"Свободная доставка","tabAll":"Все заказы","searchPlaceholder":"🔎 Поиск: № заказа, адрес, телефон или компания","statusAll":"Все статусы","thNumber":"№ Заказа","thType":"Тип","thPickup":"Точка отправления","thEta":"ETA","thDate":"Дата доставки","thRecipient":"Вручить покупателю","thSum":"Сумма","thCourier":"Курьер","thPayout":"Выплата курьеру","thStatus":"Статус"},"couriers":{"helpTitle":"Как работать с курьерами","help1":"Список показывает лимиты, транспорт и состояние смены.","help2":"Двойной клик открывает форму: контакты, лимиты и транспорт.","help3":"Смена и машина задаются в «Рабочем дне» и в карточке курьера.","title":"Курьеры компании","lead":"Добавьте курьера и отправьте ему ссылку-приглашение — он подключится за 30 секунд, без логинов и паролей.","add":"Добавить","notice":"Как подключить курьера: нажмите «Добавить», введите имя и телефон — получите ссылку и QR. Курьер открывает ссылку на телефоне и сразу видит маршрут.","thCourier":"Курьер","thPhone":"Телефон","thTransport":"Транспорт","thMode":"Режим","thStatus":"Статус","online":"На смене","offline":"Не на смене","empty":"Курьеров пока нет","transportFoot":"Пеший","transportBicycle":"Велосипед","transportCar":"Легковое авто","transportVan":"Фургон","transportTruck":"Грузовик","modeBoth":"Рейсы и экспресс","modeRoute":"Только рейсы","modeOnDemand":"Только экспресс"},"payroll":{"sumAccrued":"Начислено","sumPaid":"Выплачено","sumDue":"К выплате","thAccrued":"Начислено","thPaid":"Выплачено","thDue":"К выплате","thDocuments":"Документов","thNumber":"Документ","thAmount":"Сумма","thMethod":"Способ","thComment":"Комментарий","thOrder":"Заказ","thCovered":"Выплата","balancesTitle":"Баланс курьеров","documentsTitle":"Документы выплат","accrualsTitle":"Начисления по заказам","cardTitle":"Взаиморасчёты","pay":"Выплатить","payTitle":"Новая выплата","paidChip":"выплачено","openChip":"к выплате","settled":"закрыто","emptyBalances":"Начислений пока нет — они появятся после вручения заказов","emptyDocuments":"Выплат ещё не было","emptyAccruals":"Начислений нет","emptyCovered":"ничего не закрыто","documentsShort":"док.","openCard":"Открыть карточку взаиморасчётов","openDoc":"Открыть документ выплаты","methodCash":"Наличные","methodBank":"На счёт","methodCard":"На карту","dueHint":"{name}: к выплате {due}","nothingToPay":"Выплачивать нечего","pickCourier":"Выберите курьера","paidDone":"Выплата {number} проведена","docTitle":"Выплата {number} от {date}","cancelDoc":"Отменить выплату","cancelDocConfirm":"Отменить выплату? Начисления снова станут к выплате.","cancelled":"Выплата отменена","coveredTitle":"Закрытые начисления:","title":"Взаиморасчёты с курьерами","lead":"Начисления за заказы, выплаты документами и остаток к выплате по каждому курьеру.","refresh":"Обновить","rates":"<b>Ставки:</b> 250 ₽ за доставку · +100 ₽ за забор в точке отправления · +50 ₽ за оформление возврата. Считается автоматически при закрытии заказа.","thCourier":"Курьер","thDate":"Дата","thShift":"Статус смены","thDeliveries":"Доставок","thReturns":"Возвратов","thEarned":"Заработал","thCash":"Наличные","thBank":"Безнал","shiftOpen":"Открыта","shiftClosed":"Закрыта","empty":"Смен пока нет"},"members":{"title":"Участники пространства","lead":"Сотрудники и курьеры — участники с правами из групп.","add":"Добавить","thName":"Имя","thEmail":"Email","thRoles":"Роли","empty":"Участников пока нет"},"groups":{"title":"Группы и права","lead":"Группа задаёт права участников. Флаг «Администратор» даёт полный доступ.","add":"Добавить","thName":"Название","thAdmin":"Админ","thRights":"Права","noRights":"Права не заданы","rightsSummary":"объектов: {objects} · команд: {commands}","empty":"Групп нет"},"invites":{"title":"Приглашения","lead":"Ссылка выдаёт набор групп. После входа человек возвращается на неё и присоединяется.","add":"Добавить","thName":"Название","thToken":"Токен","thStatus":"Статус","empty":"Приглашений нет"},"join":{"title":"Заявки на подключение","lead":"Публичная форма-виджет: заявка сразу выдаёт приглашение или ждёт одобрения.","add":"Добавить","thForm":"Форма / заявка","thStatus":"Статус","thLink":"Ссылка","empty":"Заявок нет"},"routes":{"title":"Маршруты","lead":"Соберите маршрут курьера на дату и задайте порядок остановок.","add":"Добавить","thDate":"Дата","thCourier":"Курьер","thStops":"Остановок","thStatus":"Статус","empty":"Маршрутов нет"},"integrations":{"title":"Интеграции и вебхуки","lead":"Внешние системы, куда при смене статуса заказа уходит подписанное уведомление.","add":"Добавить","thSystem":"Система","thUrl":"Адрес","thEvents":"События","thToken":"Токен","thStatus":"Статус","empty":"Интеграций нет","lastSends":"Последние отправки:","connectTitle":"Как подключиться","helpTitle":"Как это работает","help1":"Добавьте внешнюю систему: название, адрес вебхука и кодовую фразу для подписи.","help2":"Нажмите «Выпустить ключ» — он показывается один раз; этим ключом система вызывает наш API.","help3":"Отметьте события: какие статусы заказа отправлять. Флаг «только свои» оставляет системе лишь её заказы.","help4":"Проверьте связь кнопкой «Проверить»; журнал ниже показывает каждую отправку и повторы.","connectLead":"Ключ берётся из карточки системы: «Показать токен». Отправляйте заказы с этим ключом — повтор с тем же номером обновит заказ, а не создаст дубль.","connectToken":"<ключ системы>","connectHint":"Вебхуки приходят на адрес из карточки; подпись проверяется кодовой фразой."},"report":{"title":"Отчёт","lead":"Итоги дня: доставки, деньги, курьеры и нарушения.","print":"Печать отчёта","exportCsv":"Выгрузить CSV","prev":"Предыдущий день","next":"Следующий день","delivered":"Доставлено","deliveredHint":"Вручено получателю","partially":"Частично","partiallyHint":"Выкуплена часть состава","cancelled":"Отказы","cancelledHint":"Отменено и возвращено","routes":"Маршрутов / точек","routesHint":"Создано за выбранный срок","distance":"Пробег","distanceHint":"Сумма по маршрутам, оценка","payout":"Выплаты","payoutHint":"Сдельно курьерам","cash":"Наличные","cashHint":"Оплата при получении","card":"Безнал","cardHint":"Оплата картой","byCouriers":"По курьерам","dynamics":"Динамика по дням","dynamicsHint":"доставки и отказы — столбики, выплаты — линия","thCourier":"Курьер","thDelivered":"Доставлено","thPartially":"Частично","thCancelled":"Отказы","thStops":"Точек","thDistance":"Пробег","thPayout":"Выплата","violations":"Нарушения","noViolations":"Нарушений нет","empty":"За этот день данных нет"},"settings":{"warehousesTitle":"Склады отправки","warehousesHint":"Точка, откуда курьеры начинают рейс. Планировщик считает километраж от склада.","editWarehouse":"Изменить склад","addWarehouse":"Добавить склад","warehouseName":"Название","warehouseAddress":"Адрес","warehouseLat":"Широта","warehouseLng":"Долгота","warehouseGeo":"Координаты","warehousesEmpty":"Складов пока нет","warehouseRemoveConfirm":"Снять склад с учёта? Старые маршруты продолжат на него ссылаться.","title":"Настройки организации","lead":"Правила выдачи заказов и часовой пояс этого рабочего пространства.","timezone":"Часовой пояс компании","timezoneHint":"По нему считаются границы рабочего дня диспетчера. Филиал или клиент может прислать свою зону в запросе — она важнее этой настройки.","timezonePlaceholder":"Europe/Moscow","timezoneInvalid":"Неизвестный часовой пояс: укажите зону IANA, например Asia/Yekaterinburg","requireMarking":"Требовать сканирование кодов маркировки перед вручением","requireReturnMark":"Требовать скан кода возврата при отказе от маркированного товара","requirePickupScan":"Требовать сканирование кодов маркировки при заборе заказа","smsConfirm":"Подтверждение выдачи кодом по SMS","smsKey":"Ключ аккаунта sms.ru","smsKeyHint":"Нужен для отправки кодов клиентам; используется только для SMS этого пространства","smsKeyPlaceholder":"API-ключ sms.ru","smsLocale":"Язык SMS клиентам","smsLocaleHint":"Все сообщения клиентам этого пространства уходят на выбранном языке","smsLocaleRu":"Русский","smsLocaleEn":"English","invitesMode":"Приглашения агентов","invitesHint":"Кто может подключать новых агентов к этому пространству","invitesLink":"По ссылке-приглашению","invitesAdmin":"Только администратор","invitesOff":"Приглашения выключены","courierDefaults":"Параметры курьера по умолчанию","courierDefaultsHint":"С ними создаётся курьер, когда принимает приглашение по ссылке","volume":"Объём, м³","weight":"Грузоподъёмность, кг","maxOrders":"Заказов сразу","region":"Регион работы","inviteTtl":"Срок приглашения, часов","inviteLimit":"Предел активных приглашений","save":"Сохранить настройки","saved":"Настройки сохранены"},"profile":{"title":"Профиль","lead":"Кто вошёл и какие у него пространства и права.","loading":"Загрузка…","logout":"Выйти"},"orderModal":{"title":"Новый заказ","lead":"Создайте заказ вручную: с доставкой со склада или с забором в точке отправления.","number":"№ заказа","numberHint":"Номер из вашей системы","type":"Тип доставки","typeHint":"От него зависит маршрут курьера","pool":"⚡ Свободный пул","scheduled":"Рейс (плановый)","request":"📡 По запросу (оффер/авто)","pickupPoint":"🏬 Точка отправления","pickupPointPlaceholder":"Адрес или название точки","pickupAddress":"Адрес забора","pickupAddressPlaceholder":"ул. Тверская, д. 15","assign":"Как назначать","offer":"Оффер курьеру (60 с, ротация)","auto":"Автоназначение","zone":"Зона / регион","zoneHint":"Необязательно","zonePlaceholder":"Москва и МО","recipientName":"👤 Имя покупателя","recipientAddress":"📍 Адрес доставки покупателю","recipientNamePlaceholder":"Ольга Васильева","recipientAddressPlaceholder":"Ленинградский пр-т, д. 36","geoLat":"🌐 Широта","geoLng":"🌐 Долгота","geoHint":"Широта и долгота точки доставки. Без них заказ не попадёт в автоплан и на карту.","geoInvalid":"Заполните оба поля координат в верном диапазоне","timezone":"🌍 Часовой пояс точки","timezoneHint":"Нужен, если заказ из другого города. Пусто — зона компании.","timezonePlaceholder":"Europe/Moscow","item":"📦 Товар","itemPlaceholder":"Туфли замшевые","price":"Цена, ₽","create":"Создать заказ"},"inviteModal":{"title":"Пригласить курьера","lead":"Введите имя и телефон — система выдаст персональную ссылку и QR. Курьер откроет их на телефоне и сразу начнёт работу.","name":"👤 Имя и фамилия курьера","namePlaceholder":"Дмитрий Соколов","phone":"📞 Телефон","phonePlaceholder":"+7 (916) 000-00-00","mode":"🚦 Режим работы курьера","modeHint":"В одной компании могут работать и «рейсовые», и «экспресс» курьеры","modeBoth":"И рейсы, и экспресс (универсальный)","modeRoute":"Только плановые рейсы (маршрут со склада)","modeOnDemand":"Только свободные экспресс-заказы","transport":"🚗 Транспорт","maxOrders":"Максимум заказов сразу","volume":"Объём груза, м³","volumePlaceholder":"например 0.5","weight":"Грузоподъёмность, кг","weightPlaceholder":"например 20","region":"Регион / зона работы","regionPlaceholder":"Например, Москва и МО","linkReady":"Ссылка-приглашение готова","openCourier":"Открыть кабинет курьера","generate":"Сгенерировать приглашение"},"authModal":{"title":"Вход по email","lead":"Введите email — пришлём 6-значный код. Так вы войдёте в свою компанию без пароля.","codeHint":"В демо-режиме код показывается сразу"},"dialog":{"message":"Сообщение","notice":"Уведомление","ok":"OK"},"memberModal":{"addTitle":"Добавить участника","editTitle":"Изменить участника","name":"Имя","namePlaceholder":"Имя участника","email":"Email для входа","emailPlaceholder":"user@example.com","roles":"Роли","groups":"Группы","groupsHint":"Расширенные права","add":"Добавить","save":"Сохранить"},"orderView":{"title":"Заказ"},"orderAssign":{"title":"Назначить курьера","courier":"Курьер","assign":"Назначить"},"courierEdit":{"title":"Изменить курьера","name":"Имя","phone":"Телефон","mode":"Режим работы","transport":"Транспорт","maxOrders":"Максимум заказов сразу","volume":"Объём, м³","weight":"Грузоподъёмность, кг","region":"Регион / зона","save":"Сохранить"},"groupModal":{"createTitle":"Создать группу","editTitle":"Изменить группу","name":"Название","namePlaceholder":"Название группы","admin":"Администратор (полный доступ)","rights":"Права","create":"Создать","save":"Сохранить"},"inviteCreate":{"title":"Создать приглашение","name":"Название","namePlaceholder":"Например, Курьер","groups":"Группы","createCourier":"Создать курьера по этой ссылке","transport":"Транспорт","maxOrders":"Максимум заказов сразу","volume":"Объём, м³","weight":"Грузоподъёмность, кг","region":"Регион / зона","create":"Создать"},"inviteEdit":{"title":"Изменить приглашение","name":"Название","groups":"Группы","save":"Сохранить"},"formModal":{"createTitle":"Создать форму заявок","editTitle":"Изменить форму заявок","titleLabel":"Заголовок","titlePlaceholder":"Требуются курьеры","mode":"Режим","modeAuto":"Авто-приглашение","modeModeration":"С одобрением","groups":"Группы","create":"Создать","save":"Сохранить"},"integrationModal":{"addTitle":"Добавить внешнюю систему","editTitle":"Изменить внешнюю систему","name":"Название","namePlaceholder":"Название системы","url":"Адрес вебхука","urlPlaceholder":"https://…","secret":"Кодовая фраза","secretPlaceholder":"Секрет для подписи","add":"Добавить","save":"Сохранить","urlHint":"куда отправлять уведомление","secretHint":"только для подписи вебхуков, не для входа в API","secretHint2":"подпись вебхука","tokenHint":"для входящих запросов к API","generate":"Сгенерировать","events":"События","eventsHint":"какие статусы заказа отправлять","allEvents":"Все события","eventsShort":"событий","secretTitle":"Система подключена","secretLead":"Токен и кодовую фразу показываем один раз — сохраните их на стороне получателя. Подпись считается HMAC-SHA256 от тела запроса.","token":"Токен доступа","example":"Пример отправки и проверки подписи","exampleHeaders":"Заголовки и тело уведомления","exampleCheck":"Проверка подписи (Node.js)","showToken":"Показать токен","cardTitle":"Внешняя система","openCard":"Карточка системы","copy":"Копировать","rotate":"Перевыпустить","issue":"Выпустить ключ","issuedTitle":"Ключ выпущен","issuedLead":"Ключ показывается один раз — сохраните его на стороне системы. Второй раз посмотреть нельзя, только выпустить новый.","tokenHidden":"ключ не показывается","tokenHiddenHint":"ключ показывается один раз при выпуске; потеряли — выпустите новый","secretHiddenHint":"оставьте пустым, чтобы не менять фразу","rotateConfirm":"Выпустить новый токен? Старый перестанет работать.","rotated":"Токен перевыпущен","tokenAfterSave":"появится после сохранения","ownOnly":"Только свои заказы","ownOnlyHint":"уведомлять только о том, что пришло от этой системы","ownOnlyShort":"свои","ips":"IP-адреса","ipsHint":"пусто — пускаем всех; список — только эти адреса и сети","retry":"Отправить снова","retryOk":"Отправлено","retryFailed":"Снова не доставлено","ip_not_allowed":"Адрес не входит в белый список системы","webhook_log_not_found":"Запись журнала не найдена"},"routeModal":{"title":"Создать маршрут","date":"Дата","courier":"Курьер","orders":"Заказы (порядок = порядок в списке)","create":"Создать маршрут"},"inviteView":{"title":"Приглашение"},"bulk":{"selected":"Выбрано: {count}","allPage":"Выбрать все на странице","assign":"Назначить курьера","cancel":"Отменить","move":"Перенести на дату","clear":"Сбросить","assignTitle":"Назначить курьера выбранным заказам","moveTitle":"Перенести выбранные заказы на дату","date":"Дата доставки","confirmCancel":"Отменить выбранные заказы ({count})?","result":"Сделано: {done}, отказов: {failed}","apply":"Применить"},"js":{"sessionDemoExpired":"Сессия демо истекла — откройте демо заново.","sessionExpired":"Сессия истекла — войдите снова.","noConnection":"Нет связи с сервером — проверьте сеть и повторите.","loadFailed":"Не удалось загрузить данные","numberPrefix":"№","pickupPointDefault":"Точка отправления","errorWord":"ошибка","statusNew":"Новый","notifCreated":"Новый заказ {number}","notifReady":"Заказ {number} готов к выдаче","notifClaimed":"Заказ {number} взял курьер","notifAssigned":"Заказ {number} назначен курьеру","notifPickedUp":"Заказ {number} забран в точке отправления","notifHandedOver":"Заказ {number} вручён","notifCancelled":"Заказ {number} отменён","notifPooled":"Заказ {number} ушёл в свободный пул","notifOffered":"Заказ {number} предложен курьеру","notifShiftOpened":"Курьер открыл смену","notifShiftClosed":"Курьер закрыл смену","notifCourierCreated":"Новый курьер","notifCourierLinked":"Курьер подключён к пользователю","notifEmpty":"Событий пока нет.","ordersEmpty":"Заказов нет. Нажмите «Новый заказ».","fromWarehouse":"Со склада","waiting":"ожидает","etaLate":"опаздывает на {min} мин","etaMin":"мин","open":"Открыть","assignCourier":"Назначить курьера","cancelOrder":"Отменить","chooseCourier":"Выберите курьера","addCourierFirst":"Сначала добавьте курьера","cancelConfirm":"Отменить заказ? Он уйдёт в отказ/возврат.","orderTitle":"Заказ № {number}","fieldStatus":"Статус","fieldType":"Тип","fieldPickup":"Точка отправления","fieldRecipient":"Получатель","fieldSum":"Сумма","fieldCourier":"Курьер","fieldCourierInfo":"Инфо курьеру","fieldPhotos":"Фото","fieldCreated":"Создан","photosUnit":"шт.","pickupPointLabel":"🏬 Забрать в точке отправления","deliverToLabel":"📍 Вручить покупателю","sumLabel":"💰 Сумма","couriersEmpty":"Курьеров нет. Нажмите «Добавить».","couriersEmptyShort":"Курьеров нет.","getLink":"Получить ссылку","online":"Онлайн","offline":"Офлайн","transportFoot":"🚶 Пеший","transportBicycle":"🚲 Вело","transportCar":"🚗 Авто","transportVan":"🚐 Фургон","transportTruck":"🚚 Грузовик","capacityWeight":"· до {value} кг","capacityVolume":"· {value} м³","capacityOrders":"· {value} зак.","phoneTransport":"Телефон / транспорт","modeLabel":"Режим","fillNamePhone":"Введите имя и телефон","courierInviteNamePhone":"Введите имя и телефон курьера","inviteCreateFailed":"Не удалось создать приглашение","shiftOpen":"Открыта","shiftClosed":"Закрыта","earned":"💰 Заработал","deliveriesReturns":"Доставок / возвратов","cashBank":"Наличные / безнал","moneyEarned":"💰 Заработал","apiCopied":"API-ключ скопирован","enterEmail":"Введите email","sendCodeFailed":"Ошибка отправки кода","signIn":"Войти","wrongCode":"Неверный код","profileLoadFailed":"Не удалось загрузить профиль","membersEmpty":"Участников нет","rolesLabel":"Роли","enterMemberName":"Введите имя участника","removeMemberConfirm":"Удалить участника?","groupsEmpty":"Групп нет","rightsFailed":"Не удалось получить права","catalogEmpty":"Объекты и команды не получены","enterGroupName":"Введите название группы","removeGroupConfirm":"Удалить группу?","yes":"Да","invitesEmpty":"Приглашений нет","inviteUsed":"Использовано","inviteActive":"Активно","tokenLabel":"Токен","inviteLinkLabel":"Ссылка","groupsLabel":"Группы","memberLabel":"Участник","inviteJoined":"Присоединился: {name}","inviteNobody":"Пока никто не присоединился","inviteEnterName":"Введите название","inviteDefaultName":"Приглашение","linkLabel":"<b>Ссылка:</b>","inviteRemoved":"Приглашение удалено","removeInviteConfirm":"Удалить приглашение?","joinEmpty":"Форм и заявок нет","formPrefix":"Форма: {title}","requestPrefix":"Заявка: {name}","noName":"без имени","linkWord":"ссылка","approve":"Одобрить","reject":"Отклонить","inviteTitle":"Приглашение","widgetLabel":"<b>Виджет:</b>","enterFormTitle":"Введите заголовок формы","routesEmpty":"Маршрутов нет","noAvailableOrders":"Нет доступных заказов","chooseCourierOption":"— выберите курьера —","routeStart":"Старт","routeComplete":"Завершить","routeComposition":"Состав","stopsLabel":"Остановок","dayConfirmDone":"План подтверждён","dayLockDone":"План заблокирован","dayDeleteConfirm":"Удалить черновик маршрута? Заказы вернутся в пул.","dayDeleted":"Черновик удалён","dayBulkConfirmDone":"Выбранные планы подтверждены","dayBulkDeleteConfirm":"Удалить выбранные черновики? Заказы вернутся в пул.","dayNothingSelected":"Ничего не выбрано","dayAssignDone":"Курьер назначен","dayNoRoutes":"Маршрутов нет","dayFeedLoadFailed":"Не удалось загрузить ленту","dayPrintBlocked":"Браузер заблокировал окно печати","markOrders":"Отметьте заказы","routeTitle":"Состав маршрута","integrationsEmpty":"Систем не подключено","enabledOn":"Вкл","enabledOff":"Выкл","enable":"Включить","disable":"Выключить","newToken":"Новый токен","test":"Проверить","logsEmpty":"Отправок пока не было","sentWithCode":"Отправлено, код {code}","failedWith":"Ошибка: {detail}","fillNameUrl":"Заполните название и адрес","fillSecret":"Заполните пароль подписи","tokenSaved":"Токен доступа (сохраните):","rotateTitle":"Новый токен","removeIntegrationConfirm":"Удалить внешнюю систему?","addressLabel":"Адрес","dashboardFailed":"Не удалось обновить обзор.","profileUnavailable":"Профиль недоступен","profileUser":"Пользователь","profileAdmin":"Администратор","profileMember":"Участник","profileNoWorkspaces":"Нет пространств","gateEnterName":"Введите название","gateCreateSpace":"Создать пространство","demoFailed":"Не удалось открыть демо","demoNoWorkspace":"Демо недоступно — в нём нет рабочего пространства."},"events":{"order_created":"Новый заказ","order_ready":"Заказ готов к выдаче","order_assigned":"Заказ назначен курьеру","order_claimed":"Курьер взял заказ из пула","order_in_progress":"Курьер начал выполнение","order_picked_up":"Забран в точке отправления","order_pooled":"Заказ ушёл в свободный пул","order_handed_over":"Заказ вручён получателю","order_delivered":"Вручён покупателю","order_partially_delivered":"Вручён частично","order_cancelled":"Отменён","route_created":"Маршрут создан","route_planned":"Маршрут спланирован","route_confirmed":"План подтверждён","route_locked":"План заблокирован","route_deleted":"Маршрут удалён","route_reordered":"Изменён порядок остановок","route_order_added":"В маршрут добавлены заказы","route_order_removed":"Из маршрута убран заказ","route_assigned":"Маршрут назначен курьеру","route_started":"Маршрут начат","route_completed":"Маршрут завершён","unknown":"Событие"},"public":{"loading":"Загрузка…","inviteTitle":"Приглашение в «{workspace}»","inviteUnavailable":"Приглашение недоступно","inviteComment":"Вас приглашают присоединиться к рабочему пространству.","workspaceDefault":"рабочее пространство","groupsCount":"Групп доступа: {count}","join":"Присоединиться","loginJoin":"Войти и присоединиться","formUnavailable":"Форма недоступна","formTitle":"Заявка","name":"Имя","phone":"Телефон","comment":"Комментарий","submit":"Отправить заявку","sent":"Заявка отправлена.","sentHint":"Мы свяжемся с вами после рассмотрения.","inviteReady":"Готово! Ваша ссылка-приглашение:","invitePageTitle":"Приглашение — CourierExpert","joinPageTitle":"Заявка — CourierExpert"},"status":{"open_pool":"В свободном пуле","claimed":"Взял курьер (шаг 1)","picked_up":"Забран в точке отправления (шаг 2)","delivered":"Вручён полностью","partially_delivered":"Частичный выкуп","cancelled":"Отказ / возврат","assigned":"Назначен","in_progress":"В работе"},"role":{"admin":"Администратор","dispatcher":"Диспетчер","courier":"Курьер","warehouse":"Кладовщик","finance":"Финансы","viewer":"Наблюдатель","integrator":"Интегратор"},"toast":{},"reasons":{"auth_required":"Нужно войти заново","access_denied":"Нет прав на это действие","validation_failed":"Проверьте заполненные поля","ws_required":"Не выбрана компания","order_not_found":"Заказ не найден","order_closed":"Заказ уже закрыт","order_not_pickable":"Заказ нельзя забрать","order_not_available":"Заказ сейчас недоступен","order_taken":"Заказ уже взял другой курьер","courier_not_found":"Курьер не найден","shift_not_found":"Смена не найдена","offer_invalid":"Предложение недействительно","offer_expired":"Время предложения истекло","invite_invalid":"Приглашение недействительно или уже использовано","invite_expired":"Срок действия приглашения истёк","invite_limit_reached":"Достигнут предел активных приглашений","invites_disabled":"Приглашения выключены в настройках","invites_admin_only":"Приглашать может только администратор","no_couriers":"Нет курьеров на смене","sms_key_missing":"Не задан ключ sms.ru","sms_failed":"Не удалось отправить SMS","recipient_phone_required":"Нет телефона получателя","client_code_required":"Введите код подтверждения","client_code_invalid":"Неверный код подтверждения","client_code_expired":"Срок действия кода истёк","photo_invalid":"Некорректное фото","route_not_found":"Маршрут не найден","route_locked":"План заблокирован: сначала снимите блокировку","route_in_work":"Маршрут уже в работе — удалять нельзя","geo_invalid":"Координаты точки указаны неверно","vehicle_number_required":"Укажите номер машины","vehicle_number_taken":"Машина с таким номером уже есть","vehicle_not_found":"Машина не найдена","zone_not_found":"Зона не найдена","zone_name_required":"Укажите название зоны","polygon_invalid":"Полигон зоны нарисован неверно","bbox_invalid":"Рамка карты указана неверно","period_invalid":"Начало периода позже конца","external_system_not_found":"Внешняя система не найдена","external_system_name_required":"Укажите название системы","webhook_url_invalid":"Укажите корректный адрес вебхука","webhook_secret_required":"Укажите пароль подписи","order_number_required":"Укажите номер заказа","service_unavailable":"Сервер недоступен, попробуйте позже","service_timeout":"Сервер не ответил вовремя","service_bad_response":"Некорректный ответ службы","object_unavailable":"Раздел недоступен","command_unavailable":"Действие недоступно","service_not_found":"Служба недоступна","generic":"Не удалось выполнить действие"}}};
window.__MAP__ = {"tilesUrl":"/tiles/{z}/{x}/{y}.png","attribution":"© OpenStreetMap contributors","center":[55.7558,37.6173],"zoom":11};
</script>
<script>
let currentWs = '';
let ordersMode = '';
let activeSection = 'dashboard';
let loadedOrders = [];
let couriersList = [];
let authPendingEmail = '';
let authStepSend = true;
// --- Язык и форматы -------------------------------------------------
var I18N = window.__I18N__ || { lang: 'ru', t: {} };
var LOCALE = I18N.lang === 'en' ? 'en-GB' : 'ru-RU';
/** Текст по точечному пути ключа; незнакомый ключ виден как есть. */
function T(path) {
var value = I18N.t;
var parts = String(path).split('.');
for (var i = 0; i < parts.length; i++) {
if (value == null) return path;
value = value[parts[i]];
}
return value == null ? path : value;
}
/** Сумма по правилам языка: разделитель разрядов зависит от него. */
/** Дробное число по правилам языка: в русском разделитель — запятая. */
function num(value) {
var text = String(value == null ? 0 : value);
return (I18N.lang === 'ru') ? text.replace('.', ',') : text;
}
function money(value) {
return new Intl.NumberFormat(LOCALE).format(Math.round(Number(value) || 0));
}
/**
* Склонение числительных по правилам языка: «1 точка», «3 точки»,
* «11 точек» (ru) и «1 stop», «2 stops» (en). Формы приходят словарём.
*/
function plural(n, forms) {
if (!forms) return '';
var abs = Math.abs(Number(n) || 0);
if (I18N.lang === 'en') return abs === 1 ? forms.one : forms.other;
var lastTwo = abs % 100;
var last = abs % 10;
if (lastTwo >= 11 && lastTwo <= 14) return forms.many;
if (last === 1) return forms.one;
if (last >= 2 && last <= 4) return forms.few;
return forms.many;
}
function fmtDate(value) {
if (!value) return '—';
var d = new Date(value);
if (isNaN(d.getTime())) return String(value);
return new Intl.DateTimeFormat(LOCALE, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(d);
}
function fmtDateTime(value) {
if (!value) return '';
var d = new Date(value);
if (isNaN(d.getTime())) return String(value);
return new Intl.DateTimeFormat(LOCALE, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).format(d);
}
/** Фраза для кода причины, который вернул сервер. */
function reasonText(reason) {
var reasons = I18N.t.reasons || {};
return reasons[reason] || reasons.generic || T('reasons.generic');
}
/** Причина из ответа службы: data.reason, затем старые коды в message. */
function reasonOf(json) {
var data = json && json.data;
if (data && data.reason) return data.reason;
if (json && json.message) return json.message;
return '';
}
function showApiError(json) {
showErrorOnce(reasonText(reasonOf(json)));
}
function statusText(code) {
return I18N.t.status && I18N.t.status[code] ? I18N.t.status[code] : code;
}
function roleText(code) {
return I18N.t.role && I18N.t.role[code] ? I18N.t.role[code] : code;
}
const stored = localStorage.getItem('ce_workspace');
const qs = new URLSearchParams(window.location.search);
currentWs = qs.get('ws') || stored || '';
const CLOUD_OBJECTS = ['auth', 'workspaces', 'members', 'groups', 'invites', 'join_forms', 'join_requests'];
// Ошибка никогда не должна исчезать молча: сообщение уходит в тост, а
// повтор того же текста (опрос заказов раз в 10 секунд) гасится.
let lastErrorToast = { text: '', at: 0 };
function showErrorOnce(text) {
const now = Date.now();
if (lastErrorToast.text === text && now - lastErrorToast.at < 5000) return;
lastErrorToast = { text: text, at: now };
toast(text, 'err');
}
let sessionExpired = false;
function clearSession() {
localStorage.removeItem('ce_token');
localStorage.removeItem('ce_refresh');
localStorage.removeItem('ce_user_id');
localStorage.removeItem('ce_workspace');
}
// Сессия кончилась (нет refresh или он отвергнут): не оставляем пустые
// таблицы, а показываем вход и говорим, что произошло.
function handleSessionExpired() {
if (sessionExpired) return;
sessionExpired = true;
clearSession();
notifClose();
showGateAuth();
showErrorOnce(localStorage.getItem('ce_demo') === '1'
? T('js.sessionDemoExpired')
: T('js.sessionExpired'));
}
// Демо-вход — публичная команда core:orders.demo. Если сервис авторизации
// недоступен (или его нет в локальном стеке), демо-сессию можно перевыпустить
// тем же путём, что и при первом входе, не теряя демо-данные.
async function renewDemoSession() {
try {
const r = await fetch('/api/universal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: 'core', object: 'orders', command: 'demo', params: {} })
}).then(res => res.json());
if (!(r && r.code === 0 && r.data && r.data.demo_token)) return false;
localStorage.setItem('ce_token', r.data.demo_token);
if (r.data.refresh_token) localStorage.setItem('ce_refresh', r.data.refresh_token);
localStorage.setItem('ce_user_id', r.data.demo_user_id || '');
return true;
} catch (e) {
console.warn('[demo] renew', e);
return false;
}
}
async function apiUniversal(object, command, params, opts) {
const options = opts || {};
const service = CLOUD_OBJECTS.indexOf(object) !== -1 ? 'cloud' : 'core';
const run = () => fetch('/api/universal', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-authorization': 'Bearer ' + (localStorage.getItem('ce_token') || 'dev_admin') },
body: JSON.stringify({ service, object, command, params })
}).then(res => res.json());
// Повтор после обновления токена. null — сеть недоступна, ответа нет.
const retry = async () => {
try { return await run(); }
catch (e) {
showErrorOnce(T('js.noConnection'));
return null;
}
};
const json0 = await retry();
if (!json0) return { code: -1, message: 'service_unavailable' };
let json = json0;
// Access-токен живёт полчаса: по 401 один раз перевыпускаем его по refresh
// и повторяем запрос. Refresh без прав, хранится отдельно.
if (json.code === 401) {
const refreshToken = localStorage.getItem('ce_refresh');
let refreshed = null;
if (refreshToken) {
try {
refreshed = await fetch('/api/universal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: 'cloud', object: 'auth', command: 'refresh', params: { refresh_token: refreshToken } })
}).then(res => res.json());
} catch (e) {
refreshed = null;
}
}
const refreshedOk = Boolean(refreshed && refreshed.code === 0 && refreshed.data && refreshed.data.token);
if (refreshedOk) {
localStorage.setItem('ce_token', refreshed.data.token);
if (refreshed.data.refresh_token) localStorage.setItem('ce_refresh', refreshed.data.refresh_token);
} else if (!(localStorage.getItem('ce_demo') === '1' && await renewDemoSession())) {
// Ни refresh, ни демо-вход не помогли: сессия кончилась.
handleSessionExpired();
return json;
}
json = await retry();
if (!json) return { code: -1, message: 'service_unavailable' };
if (json.code === 401) {
handleSessionExpired();
return json;
}
}
// Загрузка разделов идёт без своей обработки: ошибку нельзя превращать в
// пустой список. notifyErrors включают именно такие запросы.
if (options.notifyErrors && json && json.code !== 0) {
showErrorOnce(json.message
? reasonText(json.message)
: T('js.loadFailed') + ' (' + json.code + ')');
}
return json;
}
let notifWs = null;
let notifTimer = null;
let notifRefreshTimer = null;
let notifUnread = 0;
let notifItems = [];
let notifOpen = false;
function notifEsc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
// Текст события — из словаря: сервер присылает только машинные имена.
var NOTIF_KEYS = {
'order.created': 'js.notifCreated',
'order.ready': 'js.notifReady',
'order.claimed': 'js.notifClaimed',
'order.assigned': 'js.notifAssigned',
'order.picked_up': 'js.notifPickedUp',
'order.handed_over': 'js.notifHandedOver',
'order.cancelled': 'js.notifCancelled',
'order.pooled': 'js.notifPooled',
'offer.created': 'js.notifOffered',
'shift.opened': 'js.notifShiftOpened',
'shift.closed': 'js.notifShiftClosed',
'courier.created': 'js.notifCourierCreated',
'courier.linked': 'js.notifCourierLinked'
};
function notifFormat(ev) {
var key = NOTIF_KEYS[ev.event];
if (!key) return ((ev.object || '') + '.' + (ev.event || ev.action || ''));
var text = String(T(key));
if (ev.order_number) return text.replace('{number}', T('js.numberPrefix') + ' ' + ev.order_number);
return text.replace(/\s*\{number\}/, '');
}
function notifClose() {
clearTimeout(notifTimer);
notifTimer = null;
if (notifWs) {
notifWs.onclose = null;
try { notifWs.close(); } catch (e) { console.warn('[notif] close', e); }
notifWs = null;
}
}
function notifConnect() {
if (!currentWs) return;
var token = localStorage.getItem('ce_token');
if (!token) return;
notifClose();
var proto = location.protocol === 'https:' ? 'wss' : 'ws';
try { notifWs = new WebSocket(proto + '://' + location.host + '/ws'); } catch (e) { console.warn('[notif] connect', e); return; }
notifWs.onopen = function () {
notifWs.send(JSON.stringify({ type: 'auth', token: token, ws: currentWs }));
};
notifWs.onmessage = function (e) {
var m;
try { m = JSON.parse(e.data); } catch (err) { console.warn('[notif] parse', err); return; }
if (m.type === 'notification') notifHandle(m.data || {});
};
notifWs.onclose = function () {
notifWs = null;
clearTimeout(notifTimer);
notifTimer = setTimeout(notifConnect, 3000);
};
}
function notifHandle(ev) {
var text = notifFormat(ev);
notifItems.unshift({ text: text, at: new Date().toISOString() });
notifItems = notifItems.slice(0, 50);
if (!notifOpen) notifUnread++;
renderNotifs();
toast(text, 'ok');
notifRefresh();
}
function notifRefresh() {
clearTimeout(notifRefreshTimer);
notifRefreshTimer = setTimeout(function () {
if (activeSection === 'orders') loadOrders();
else if (activeSection === 'couriers') loadCouriers();
else if (activeSection === 'invites') loadInvites();
else if (activeSection === 'members') loadMembers();
else if (activeSection === 'dashboard') loadDashboard();
}, 500);
}
function renderNotifs() {
var badge = document.getElementById('notifCount');
if (badge) {
badge.textContent = notifUnread > 99 ? '99+' : String(notifUnread);
badge.style.display = notifUnread ? 'inline-block' : 'none';
}
var list = document.getElementById('notifList');
if (!list) return;
if (!notifItems.length) {
list.innerHTML = '<div class="notif-item muted">' + notifEsc(T('js.notifEmpty')) + '</div>';
return;
}
list.innerHTML = notifItems.map(function (n) {
var t = new Date(n.at).toLocaleTimeString(LOCALE, { hour: '2-digit', minute: '2-digit' });
return '<div class="notif-item"><div>' + notifEsc(n.text) + '</div><div class="when">' + t + '</div></div>';
}).join('');
}
function toggleNotifPanel() {
var panel = document.getElementById('notifPanel');
if (!panel) return;
notifOpen = panel.style.display === 'none';
panel.style.display = notifOpen ? 'block' : 'none';
if (notifOpen) { notifUnread = 0; renderNotifs(); }
}
function clearNotifs() {
notifItems = [];
notifUnread = 0;
renderNotifs();
}
var ICON_TRASH = '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path></svg>';
document.addEventListener('mouseover', function (e) {
var el = e.target.closest ? e.target.closest('.icon-btn[title]') : null;
if (!el || el._tip) return;
var text = el.getAttribute('title');
if (!text) return;
el.setAttribute('data-tip', text);
el.removeAttribute('title');
var tip = document.createElement('div');
tip.className = 'ui-tip';
tip.textContent = text;
document.body.appendChild(tip);
var r = el.getBoundingClientRect();
tip.style.left = (r.left + r.width / 2) + 'px';
tip.style.top = (r.top - 8) + 'px';
el._tip = tip;
});
document.addEventListener('mouseout', function (e) {
var el = e.target.closest ? e.target.closest('.icon-btn[data-tip]') : null;
if (!el) return;
if (e.relatedTarget && el.contains(e.relatedTarget)) return;
if (el._tip) { el._tip.remove(); el._tip = null; }
var t = el.getAttribute('data-tip');
if (t) el.setAttribute('title', t);
});
function toast(msg, type) {
var wrap = document.getElementById('dsToasts');
if (!wrap) return;
var el = document.createElement('div');
el.className = 'toast' + (type === 'err' ? ' err' : (type === 'ok' ? ' ok' : ''));
el.textContent = String(msg);
wrap.appendChild(el);
setTimeout(function () { el.style.opacity = '0'; setTimeout(function () { el.remove(); }, 220); }, 3500);
}
function _dsDialog(opts) {
return new Promise(function (resolve) {
var overlay = document.getElementById('dsDialog');
var title = document.getElementById('dsTitle');
var msg = document.getElementById('dsMessage');
var wrapPrompt = document.getElementById('dsPromptWrap');
var input = document.getElementById('dsPromptInput');
var ok = document.getElementById('dsOk');
var cancel = document.getElementById('dsCancel');
title.innerText = opts.title || T('dialog.message');
msg.innerText = opts.message || '';
if (opts.prompt !== undefined) { wrapPrompt.style.display = 'flex'; input.value = opts.prompt || ''; }
else { wrapPrompt.style.display = 'none'; }
cancel.style.display = opts.cancel ? 'inline-flex' : 'none';
overlay.classList.add('open');
function close(val) {
overlay.classList.remove('open');
ok.onclick = null; cancel.onclick = null;
resolve(val);
}
ok.onclick = function () { close(opts.prompt !== undefined ? input.value : true); };
cancel.onclick = function () { close(opts.prompt !== undefined ? null : false); };
});
}
function dsAlert(message, title) { return _dsDialog({ title: title || T('dialog.notice'), message: message, cancel: false }); }
function dsConfirm(message, title) { return _dsDialog({ title: title || T('common.confirm'), message: message, cancel: true }); }
function dsPrompt(message, value) { return _dsDialog({ title: message || T('common.input'), message: '', prompt: value, cancel: true }); }
function dsShowValue(title, value) { return _dsDialog({ title: title || T('common.value'), message: String(value == null ? '' : value), cancel: false }); }
function logout() {
notifClose();
clearSession();
localStorage.removeItem('ce_demo');
window.location.href = window.location.pathname;
}
function toggleMenu(open) {
var sb = document.getElementById('sidebar');
var bd = document.getElementById('navBackdrop');
if (sb) sb.classList.toggle('open', !!open);
if (bd) bd.classList.toggle('open', !!open);
}
/** Номер страницы из адреса — только для той секции, что в адресе. */
function pageFromUrl(sec) {
try {
var u = new URL(window.location.href);
if (u.searchParams.get('section') !== sec) return 0;
var p = Number(u.searchParams.get('page'));
return p > 0 ? p - 1 : 0;
} catch (e) { return 0; }
}
function switchSection(sec) {
var previous = activeSection;
activeSection = sec;
// Страница берётся из адреса, если адрес указывает на этот раздел (прямая
// ссылка на ?section=orders&page=3); при переходе из другого раздела —
// с первой. Иначе прямая ссылка «съезжала» на первую страницу.
if (!listState[sec] || previous !== sec) listState[sec] = { page: pageFromUrl(sec) };
// Номер страницы относится к своему разделу: при переходе убираем его,
// иначе он «переезжает» на чужой список.
if (previous && previous !== sec && pageFromUrl(sec) === 0) {
try {
var u = new URL(window.location.href);
if (u.searchParams.get('page')) {
u.searchParams.delete('page');
window.history.replaceState(null, '', u.pathname + u.search);
}
} catch (e) { /* адрес не трогаем */ }
}
['day', 'dashboard', 'orders', 'couriers', 'payroll', 'reports', 'members', 'groups', 'invites', 'join', 'routes', 'integrations', 'settings', 'profile'].forEach(s => {
document.getElementById('section' + s.charAt(0).toUpperCase() + s.slice(1)).style.display = (s === sec ? 'flex' : 'none');
document.getElementById('nav' + s.charAt(0).toUpperCase() + s.slice(1)).className = (s === sec ? 'active' : '');
});
if (sec === 'day') loadDay();
if (sec === 'dashboard') loadDashboard();
if (sec === 'orders') loadOrders();
if (sec === 'couriers') loadCouriers();
if (sec === 'payroll') loadPayroll();
if (sec === 'reports') loadReport();
if (sec === 'members') loadMembers();
if (sec === 'groups') loadGroups();
if (sec === 'invites') loadInvites();
if (sec === 'join') loadJoin();
if (sec === 'routes') loadRoutes();
if (sec === 'integrations') loadIntegrations();
if (sec === 'settings') loadSettings();
if (sec === 'profile') loadProfile();
try { var u = new URL(window.location.href); if (sec === 'dashboard') u.searchParams.delete('section'); else u.searchParams.set('section', sec); window.history.replaceState({}, '', u); } catch (e) { console.warn('[url] replaceState', e); }
if (window.innerWidth < 900) toggleMenu(false);
}
setInterval(function () {
if (sessionExpired) return;
if (activeSection === 'orders' && document.visibilityState === 'visible') loadOrders();
}, 10000);
function changeWorkspace(ws) {
currentWs = ws;
// У другой компании может быть другой часовой пояс — прежний не тащим.
dayState.tz = '';
localStorage.setItem('ce_workspace', ws);
const url = new URL(window.location.href);
url.searchParams.set('ws', ws);
window.history.pushState({}, '', url);
accessCatalog = null;
loadOrders();
notifConnect();
}
function statusChip(st) {
const map = {
open_pool: '<span class="chip pool">⚡️ ' + notifEsc(statusText('open_pool')) + '</span>',
new: '<span class="chip route">' + notifEsc(T('js.statusNew')) + '</span>',
claimed: '<span class="chip pool">' + notifEsc(statusText('claimed')) + '</span>',
assigned: '<span class="chip route">' + notifEsc(statusText('assigned')) + '</span>',
picked_up: '<span class="chip pick">' + notifEsc(statusText('picked_up')) + '</span>',
delivered: '<span class="chip delivered">✅ ' + notifEsc(statusText('delivered')) + '</span>',
partially_delivered: '<span class="chip part">' + notifEsc(statusText('partially_delivered')) + '</span>',
cancelled: '<span class="chip cancel">' + notifEsc(statusText('cancelled')) + '</span>'
};
return map[st] || '<span class="chip route">' + notifEsc(st) + '</span>';
}
function modeChip(o) {
if (o.assignment === 'auto') return '<span class="chip pool">🤖 ' + notifEsc(T('orderModal.auto')) + '</span>';
if (o.delivery_mode === 'offer' || o.assignment === 'offer') return '<span class="chip pool">📨 ' + notifEsc(T('orderModal.offer')) + '</span>';
return (o.delivery_mode === 'pool' || o.status === 'open_pool')
? '<span class="chip pool">⚡ ' + notifEsc(T('orderModal.pool')) + '</span>'
: '<span class="chip route">🚚 ' + notifEsc(T('orderModal.scheduled')) + '</span>';
}
function setOrdersMode(mode) {
ordersMode = mode;
// Новый срез начинается с первой страницы.
listReset('orders');
['Queue', 'Pool', 'All'].forEach(function (k) {
var map = { Queue: 'queue', Pool: 'pool', All: '' };
var el = document.getElementById('tabOrders' + k);
if (el) el.className = 'btn ' + (map[k] === mode ? 'btn-primary' : 'btn-ghost');
});
loadOrders();
}
// Имена курьеров для списков: в заказе лежит только идентификатор, а
// диспетчеру нужно имя, а не хвост идентификатора.
var couriersIndex = { at: 0, map: {} };
async function couriersById() {
if (Date.now() - couriersIndex.at < 60000 && Object.keys(couriersIndex.map).length) return couriersIndex.map;
const r = await apiUniversal('couriers', 'list', { ws: currentWs, limit: 500 });
const map = {};
((r.data && r.data.items) || []).forEach(function (c) { map[c._id] = c.name; });
couriersIndex = { at: Date.now(), map: map };
return map;
}
async function loadOrders() {
if (!currentWs) { renderOrders([]); return; }
const filter = ordersMode ? { mode: ordersMode } : {};
const json = await apiUniversal('orders', 'list', {
ws: currentWs, filter: filter, limit: LIST_PAGE, skip: listSkip('orders'),
}, { notifyErrors: true });
loadedOrders = (json.data && json.data.items) ? json.data.items : [];
renderPager('orders', loadedOrders.length, Number((json.data && json.data.total) || loadedOrders.length) || 0);
await couriersById();
renderOrders(loadedOrders);
}
function renderOrders(items) {
const tbody = document.getElementById('ordersTableBody');
const cards = document.getElementById('ordersCards');
document.getElementById('statOrders').innerText = items.length;
document.getElementById('statPool').innerText = items.filter(o => o.status === 'open_pool').length;
document.getElementById('statDelivered').innerText = items.filter(o => ['delivered', 'partially_delivered'].includes(o.status)).length;
let payroll = 0;
items.forEach(o => { if (o.courier_payout?.total_earned) payroll += o.courier_payout.total_earned; });
document.getElementById('statPayroll').innerText = money(payroll) + ' ₽';
listRender('orders', items);
}
async function openOrderView(id) {
var r = await apiUniversal('orders', 'view', { ws: currentWs, id: id });
if (r.code !== 0 || !r.data) { showApiError(r); return; }
var o = r.data;
var rows = [
[T('js.fieldStatus'), statusChip(o.status)],
[T('js.fieldType'), modeChip(o)],
[T('js.fieldPickup'), o.pickup ? (o.pickup.name + ' · ' + o.pickup.address) : T('js.fromWarehouse')],
[T('js.fieldRecipient'), ((o.recipient && o.recipient.name) || '—') + ' · ' + ((o.recipient && o.recipient.address) || '')],
[T('js.fieldSum'), money(o.total_amount || 0) + ' ₽'],
[T('orders.thEta'), o.eta_time
? notifEsc(o.eta_time) + (Number(o.eta_delay_min) > 0
? ' · <span style="color:var(--danger);">' + String(T('js.etaLate')).replace('{min}', String(o.eta_delay_min)) + '</span>'
: '')
: '—'],
[T('js.fieldCourier'), o.courier_id || T('js.waiting')],
[T('js.fieldCourierInfo'), [o.courier_info && o.courier_info.entrance, o.courier_info && o.courier_info.floor, o.courier_info && o.courier_info.apartment, o.courier_info && o.courier_info.intercom, o.courier_info && o.courier_info.comment].filter(Boolean).join(', ') || '—'],
[T('js.fieldPhotos'), ((o.photos || []).length) + ' ' + T('js.photosUnit')],
[T('js.fieldCreated'), fmtDateTime(o.created_at) || '—']
];
document.getElementById('orderViewTitle').innerText = String(T('js.orderTitle')).replace('{number}', (o.order_number || o._id));
document.getElementById('orderViewBody').innerHTML = rows.map(function (row) {
return '<div class="kv"><span class="k">' + row[0] + '</span><span class="v">' + (row[1] || '—') + '</span></div>';
}).join('');
openModal('orderViewModal');
}
/** Кнопка в строке заказа передаёт элемент — берём из него id. */
function openOrderViewById(el) { openOrderView(el.getAttribute('data-id')); }
function openOrderAssignFor(el) { openOrderAssign(el.getAttribute('data-id')); }
function cancelOrderFor(el) { cancelOrder(el.getAttribute('data-id')); }
async function openOrderAssign(id) {
var js = await apiUniversal('couriers', 'list', { ws: currentWs }, { notifyErrors: true });
if (js.code !== 0) return;
couriersList = (js.data && js.data.items) || [];
var sel = document.getElementById('oaCourier');
sel.innerHTML = couriersList.map(function (c) { return '<option value="' + c._id + '">' + c.name + (c.phone ? ' · ' + c.phone : '') + '</option>'; }).join('');
if (!couriersList.length) { toast(T('js.addCourierFirst'), 'err'); return; }
document.getElementById('oaId').value = id;
openModal('orderAssignModal');
}
async function saveOrderAssign() {
var id = document.getElementById('oaId').value;
var courier = document.getElementById('oaCourier').value;
if (!courier) { toast(T('js.chooseCourier'), 'err'); return; }
var r = await apiUniversal('orders', 'assignCourier', { ws: currentWs, order_id: id, courier_id: courier });
if (r.code !== 0) { showApiError(r); return; }
closeModal('orderAssignModal');
loadOrders();
}
async function cancelOrder(id) {
if (!(await dsConfirm(T('js.cancelConfirm')))) return;
var r = await apiUniversal('orders', 'cancel', { ws: currentWs, order_id: id });
if (r.code !== 0) { showApiError(r); return; }
loadOrders();
}
function filterOrders() {
const q = document.getElementById('searchInput').value.toLowerCase();
const st = document.getElementById('statusFilter').value;
renderOrders(loadedOrders.filter(o => {
const text = (o.order_number + ' ' + (o.recipient?.name || '') + ' ' + (o.recipient?.address || '') + ' ' + (o.pickup?.name || '') + ' ' + (o.pickup?.address || '')).toLowerCase();
return text.includes(q) && (!st || o.status === st);
}));
}
async function loadCouriers() {
if (!currentWs) return;
const json = await apiUniversal('couriers', 'list', {
ws: currentWs, limit: LIST_PAGE, skip: listSkip('couriers'),
}, { notifyErrors: true });
couriersList = json.data?.items || [];
renderPager('couriers', couriersList.length, (json.data && json.data.total) || couriersList.length);
listRender('couriers', couriersList);
}
function openCourierEdit(id) {
openEntity('courier', id);
}
/** Взаиморасчёты: балансы курьеров и документы выплат. */
async function loadPayroll() {
if (!currentWs) return;
var b = await apiUniversal('payouts', 'balances', { ws: currentWs }, { notifyErrors: true });
var data = (b && b.data) || { items: [], total: 0, accrued: 0, paid: 0, due: 0 };
var items = data.items || [];
renderPager('payroll', items.length, data.total || items.length);
var kpis = document.getElementById('payrollSumAccrued');
if (kpis) {
kpis.innerText = money(data.accrued || 0) + ' ₽';
document.getElementById('payrollSumPaid').innerText = money(data.paid || 0) + ' ₽';
document.getElementById('payrollSumDue').innerText = money(data.due || 0) + ' ₽';
}
listRender('payroll', items);
var pj = await apiUniversal('payouts', 'list', { ws: currentWs, limit: 50 }, { notifyErrors: true });
var docs = ((pj.data && pj.data.items) || []);
listRender('payouts', docs);
}
/** Способ выплаты словами. */
function methodText(method) {
if (method === 'bank') return T('payroll.methodBank');
if (method === 'card') return T('payroll.methodCard');
return T('payroll.methodCash');
}
/** Карточка курьера: начисления по заказам и что уже выплачено. */
var settlementCourier = null;
async function openCourierSettlement(courierId) {
if (!courierId) return;
settlementCourier = courierId;
var [a, p] = await Promise.all([
apiUniversal('payouts', 'accruals', { ws: currentWs, courier_id: courierId, limit: 100 }),
apiUniversal('payouts', 'list', { ws: currentWs, courier_id: courierId, limit: 100 }),
]);
var accruals = ((a.data && a.data.items) || []);
var docs = ((p.data && p.data.items) || []);
var accrued = accruals.reduce(function (s, x) { return s + Number(x.amount || 0); }, 0);
var paid = docs.reduce(function (s, x) { return s + Number(x.amount || 0); }, 0);
var due = accrued - paid;
var name = (docs[0] && docs[0].courier_name) || '';
if (!name) {
var row = [...document.querySelectorAll('#payrollTableBody tr')].find(function (tr) { return tr.getAttribute('data-id') === courierId; });
name = row ? row.querySelector('.strong').innerText : '';
}
document.getElementById('settlementTitle').innerText = T('payroll.cardTitle') + ': ' + name;
document.getElementById('settlementAccrued').innerText = money(accrued) + ' ₽';
document.getElementById('settlementPaid').innerText = money(paid) + ' ₽';
document.getElementById('settlementDue').innerText = money(due) + ' ₽';
document.getElementById('settlementPayBtn').style.display = due > 0 ? 'inline-flex' : 'none';
document.getElementById('settlementAccruals').innerHTML = accruals.length ? accruals.map(function (x) {
return '<tr><td class="mono">' + notifEsc(x.order_number || '') + '</td>' +
'<td>' + (x.completed_at ? fmtDateTime(x.completed_at) : '—') + '</td>' +
'<td class="strong">' + money(x.amount || 0) + ' ₽</td>' +
'<td>' + (x.payout_id ? '<span class="chip delivered">' + notifEsc(T('payroll.paidChip')) + '</span>' : '<span class="chip pool">' + notifEsc(T('payroll.openChip')) + '</span>') + '</td></tr>';
}).join('') : '<tr><td colspan="4" class="muted" style="padding:14px;">' + notifEsc(T('payroll.emptyAccruals')) + '</td></tr>';
document.getElementById('settlementPayouts').innerHTML = docs.length ? docs.map(function (d) {
return '<tr><td class="mono strong">' + notifEsc(d.number || '') + '</td><td>' + fmtDate(d.date) + '</td>' +
'<td class="strong">' + money(d.amount || 0) + ' ₽</td>' +
'<td class="row-actions"><button class="icon-btn" data-id="' + d._id + '" onclick="openPayoutView(this.dataset.id)">👁</button></td></tr>';
}).join('') : '<tr><td colspan="4" class="muted" style="padding:14px;">' + notifEsc(T('payroll.emptyDocuments')) + '</td></tr>';
openModal('courierSettlementModal');
}
/** Новый документ выплаты: сумма не больше остатка. */
async function openPayoutCreate(courierId) {
var id = courierId || settlementCourier;
if (!id) { toast(T('payroll.pickCourier'), 'err'); return; }
settlementCourier = id;
var b = await apiUniversal('payouts', 'balances', { ws: currentWs });
var row = ((b.data && b.data.items) || []).find(function (x) { return x.courier_id === id; });
var due = row ? Number(row.due || 0) : 0;
if (due <= 0) { toast(T('payroll.nothingToPay'), 'err'); return; }
await openEntity('payout');
entityState.courierId = id;
var hint = document.getElementById('entityLead');
if (hint) {
hint.style.display = 'block';
hint.innerText = T('payroll.dueHint').replace('{due}', money(due) + ' ₽').replace('{name}', row.name || '');
}
var amount = document.getElementById('ef_amount');
if (amount) { amount.value = String(due); amount.max = String(due); }
}
/** Удаление приглашения из списка. */
async function removeInviteById(el) {
var id = el.getAttribute('data-id');
var ok = await dsConfirm(T('js.removeInviteConfirm'), T('common.delete'));
if (!ok) return;
var r = await apiUniversal('invites', 'remove', { ws: currentWs, id: id });
if (r.code !== 0) { showApiError(r); return; }
toast(T('common.deleted'), 'ok');
loadInvites();
}
/** Удаление маршрута из списка. */
async function removeRouteById(el) {
var id = el.getAttribute('data-id');
var ok = await dsConfirm(T('common.removeRouteConfirm'), T('common.delete'));
if (!ok) return;
var r = await apiUniversal('routes', 'remove', { ws: currentWs, id: id });
if (r.code !== 0) { showApiError(r); return; }
toast(T('common.deleted'), 'ok');
loadRoutes();
}
/** Действия списка выплат: компонент передаёт элемент строки. */
function payrollCreateFor(el) { openPayoutCreate(el.getAttribute('data-id')); }
function payrollOpenFor(el) { openCourierSettlement(el.getAttribute('data-id')); }
function payoutViewFor(el) { openPayoutView(el.getAttribute('data-id')); }
function payoutRemoveFor(el) { removePayout(el.getAttribute('data-id')); }
/** Отменить выплату: документ удаляется, начисления снова открыты. */
async function removePayout(id) {
var ok = await dsConfirm(T('payroll.cancelDocConfirm'), T('payroll.cancelDoc'));
if (!ok) return;
var r = await apiUniversal('payouts', 'remove', { ws: currentWs, id: id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
toast(T('payroll.cancelled'), 'ok');
loadPayroll();
}
/** Документ выплаты: шапка и какие начисления он закрыл. */
async function openPayoutView(id) {
var r = await apiUniversal('payouts', 'view', { ws: currentWs, id: id }, { notifyErrors: true });
if (r.code !== 0 || !r.data) { showApiError(r); return; }
var d = r.data;
var covered = (d.covered || []).map(function (x) {
return notifEsc(x.order_number || '') + ' — ' + money(x.amount || 0) + ' ₽';
}).join('\n') || T('payroll.emptyCovered');
await dsShowValue(
T('payroll.docTitle').replace('{number}', d.number || '').replace('{date}', fmtDate(d.date)),
T('payroll.thCourier') + ': ' + (d.courier_name || '') + '\n'
+ T('payroll.thAmount') + ': ' + money(d.amount || 0) + ' ₽\n'
+ T('payroll.thMethod') + ': ' + methodText(d.method) + '\n'
+ (d.comment ? T('payroll.thComment') + ': ' + d.comment + '\n' : '')
+ '\n' + T('payroll.coveredTitle') + '\n' + covered
);
}
/**
* Пока открыт диалог, страница под ним стоит на месте: блокируем прокрутку
* body. Прокручивается только подложка — и только если карточка выше экрана.
*/
/** Позиция страницы до открытия диалога: вернём её при закрытии. */
var modalScrollY = 0;
function openModal(id) {
var overlay = document.getElementById(id);
// Пока диалог открыт, страница убрана: виден только диалог, и основная
// полоса прокрутки окна прокручивает ровно его.
if (!document.body.classList.contains('modal-open')) {
modalScrollY = window.pageYOffset || 0;
var shell = document.querySelector('.shell');
if (shell) shell.style.top = '-' + modalScrollY + 'px';
document.body.classList.add('modal-open');
window.scrollTo(0, 0);
}
overlay.classList.add('open');
// Диалог лежит в начале документа: возвращаемся туда, иначе на
// прокрученной странице он окажется выше видимой области.
if (!document.querySelector('.overlay.open:not(#' + id + ')')) window.scrollTo(0, 0);
}
function closeModal(id) {
document.getElementById(id).classList.remove('open');
if (!document.querySelector('.overlay.open')) {
document.body.classList.remove('modal-open');
var shell = document.querySelector('.shell');
if (shell) shell.style.top = '';
window.scrollTo(0, modalScrollY);
}
}
function togglePickupFields(mode) {
document.getElementById('pickupFieldsGroup').style.display = (mode === 'pool' || mode === 'request') ? 'flex' : 'none';
var af = document.getElementById('assignFieldsGroup');
if (af) af.style.display = mode === 'request' ? 'flex' : 'none';
}
function openInviteModal() {
openModal('courierInviteModal');
document.getElementById('inviteResultArea').classList.remove('on');
document.getElementById('btnGenInvite').style.display = 'inline-flex';
document.getElementById('invCourierName').disabled = false;
document.getElementById('invCourierPhone').disabled = false;
}
async function createCourierInviteFor(courierId, courierName) {
openInviteModal();
document.getElementById('invCourierName').value = courierName;
document.getElementById('invCourierName').disabled = true;
document.getElementById('invCourierPhone').disabled = true;
const json = await apiUniversal('couriers', 'createInvite', { ws: currentWs, courier_id: courierId });
if (json.data?.invite_url) showInvite(json.data.invite_url);
}
async function submitGenerateInvite() {
const name = document.getElementById('invCourierName').value.trim();
const phone = document.getElementById('invCourierPhone').value.trim();
const mode = document.getElementById('invCourierMode').value;
if (!name || !phone) { toast(T('js.courierInviteNamePhone'), 'err'); return; }
const vehicle = document.getElementById('invVehicle') ? document.getElementById('invVehicle').value : 'car';
const maxOrders = document.getElementById('invMaxOrders') ? document.getElementById('invMaxOrders').value : 1;
const volume = document.getElementById('invVolume') ? document.getElementById('invVolume').value : '';
const weight = document.getElementById('invWeight') ? document.getElementById('invWeight').value : '';
const region = document.getElementById('invRegion') ? document.getElementById('invRegion').value : '';
// Приглашение курьера — надстройка пространства (сервис заказов), а не
// приглашение участника в общем сервисе: по ссылке создаётся курьер.
const r = await apiUniversal('couriers', 'createInvite', {
ws: currentWs,
courier: {
name: name,
phone: phone,
mode: mode,
transport: vehicle,
capacity_volume_m3: volume,
capacity_weight_kg: weight,
max_orders: maxOrders,
region: region
}
});
if (r.code === 0 && r.data && r.data.invite_url) { showInvite(r.data.invite_url); }
else { showApiError(r); }
}
function showInvite(url) {
document.getElementById('inviteResultArea').classList.add('on');
document.getElementById('generatedInviteUrl').value = url;
document.getElementById('openCourierTermBtn').href = url;
document.getElementById('btnGenInvite').style.display = 'none';
}
function authStep() {
if (authStepSend) {
authPendingEmail = document.getElementById('authEmailInput').value.trim();
if (!authPendingEmail) { toast(T('js.enterEmail'), 'err'); return; }
apiUniversal('auth', 'sendCode', { email: authPendingEmail, locale: I18N.lang }).then(json => {
if (json.code === 0) {
document.getElementById('authEmailGroup').style.display = 'none';
document.getElementById('authCodeGroup').style.display = 'flex';
document.getElementById('authCodeInput').value = json.data?.debug_code || '123456';
document.getElementById('btnAuthStep').innerText = T('js.signIn');
authStepSend = false;
} else showApiError(json);
});
} else {
const code = document.getElementById('authCodeInput').value.trim();
apiUniversal('auth', 'verifyCode', { email: authPendingEmail, code, locale: I18N.lang }).then(json => {
if (json.code === 0 && json.data) {
const u = json.data.user;
localStorage.setItem('ce_token', json.data.token || '');
localStorage.setItem('ce_refresh', json.data.refresh_token || '');
localStorage.setItem('ce_user_id', u._id);
localStorage.removeItem('ce_demo');
sessionExpired = false;
document.getElementById('navAuth').innerText = '👤 ' + (u.name || u.email);
apiUniversal('auth', 'getProfile', { user_id: u._id }).then(p => {
const prof = p.data || {};
const list = prof.workspaces || (prof.ws_list || []).map(id => ({ ws: id, name: id }));
const select = document.getElementById('wsSelector');
select.innerHTML = list.map(w => '<option value="' + w.ws + '">' + w.name + '</option>').join('');
if (list.length && !currentWs) changeWorkspace(list[0].ws);
closeModal('authModal');
authStepSend = true;
document.getElementById('btnAuthStep').innerText = T('gate.getCode');
document.getElementById('authEmailGroup').style.display = 'flex';
document.getElementById('authCodeGroup').style.display = 'none';
const next = qs.get('next') || localStorage.getItem('pendingInvite');
if (next) { localStorage.removeItem('pendingInvite'); window.location.href = next; }
});
} else showApiError(json);
});
}
}
function fillGroupSelects(groups) {
['mGroups', 'invGroups', 'jfGroups', 'ieGroups'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.innerHTML = groups.map(function (g) { return '<option value="' + g._id + '">' + g.name + '</option>'; }).join('');
});
}
async function loadMembers() {
if (!currentWs) return;
var mj = await apiUniversal('members', 'list', {
ws: currentWs, limit: LIST_PAGE, skip: listSkip('members'),
}, { notifyErrors: true });
var gj = await apiUniversal('groups', 'list', { ws: currentWs, limit: 200 }, { notifyErrors: true });
var items = (mj.data && mj.data.items) || [];
renderPager('members', items.length, Number((mj.data && mj.data.total) || items.length) || 0);
membersList = items;
var groups = (gj.data && gj.data.items) || [];
var gmap = {};
groups.forEach(function (g) { gmap[g._id] = g.name; });
membersGroupNames = gmap;
fillGroupSelects(groups);
listRender('members', items);
}
function openMemberCreate() {
openEntity('member');
}
function openMemberEdit(id) {
openEntity('member', id);
}
async function removeMember(el) {
if (!(await dsConfirm(T('js.removeMemberConfirm')))) return;
await apiUniversal('members', 'remove', { ws: currentWs, id: el.getAttribute('data-id') });
loadMembers();
}
var groupsList = [];
var membersList = [];
// Роли переводятся через roleText: слова живут в словаре (js -> role).
var invitesList = [];
var joinFormsList = [];
var integrationsList = [];
var accessCatalog = null;
async function loadGroups() {
if (!currentWs) return;
var j = await apiUniversal('groups', 'list', {
ws: currentWs, limit: LIST_PAGE, skip: listSkip('groups'),
}, { notifyErrors: true });
var items = (j.data && j.data.items) || [];
renderPager('groups', items.length, Number((j.data && j.data.total) || items.length) || 0);
groupsList = items;
fillGroupSelects(items);
listRender('groups', items);
}
/**
* Страницы справочников: по 20 записей, серверная навигация.
* Список перечитывается целиком — своё состояние данных в браузере не держим.
*/
/**
* Списки — тоже один компонент: конфигурация говорит, какие колонки, что
* рисовать в строке и карточке, чем открывать запись и какая команда
* отдаёт данные. Оболочка (шапка, таблица, карточки, пустое состояние,
* пагинация) у всех одна.
*
* Конфигурация списка:
* { object, title, lead, addLabel, addAction, empty, columns:[{key,label,render}],
* card:{ title, rows:[{label, render}] }, actions:[{icon,title,onclick,show}] }
*/
var LISTS = {};
/**
* Как рисовать строку и карточку — часть конфигурации списка. Ячейки,
* действия и карточка задаются здесь, оболочка и пагинация — общие.
*/
/** Название события вебхука: из словаря, по коду. */
function eventLabel(code) {
if (code === '*') return T('integrationModal.allEvents');
var name = String(code).split('.').join('_');
var value = I18N.t && I18N.t.events ? I18N.t.events[name] : null;
return value || ('events.' + name);
return value || T(key);
}
/** Статус приглашения: активно или уже использовано. */
function inviteStatusChip(i) {
return i.closed
? '<span class="chip cancel">' + notifEsc(T('js.inviteUsed')) + '</span>'
: '<span class="chip open">' + notifEsc(T('js.inviteActive')) + '</span>';
}
/** Имя курьера маршрута: из списка, а если его нет — последние символы id. */
var routesCourierNames = {};
function routeCourierName(r) {
if (!r.courier_id) return '—';
return routesCourierNames[r.courier_id] || String(r.courier_id).slice(-6);
}
/** Транспорт и режим курьера словами — для строки и карточки. */
function courierTransportText(c) {
var vmap = { foot: T('js.transportFoot'), bicycle: T('js.transportBicycle'), car: T('js.transportCar'), van: T('js.transportVan'), truck: T('js.transportTruck') };
var cap = (c.capacity_weight_kg != null ? ' ' + String(T('js.capacityWeight')).replace('{value}', c.capacity_weight_kg) : '')
+ (c.capacity_volume_m3 != null ? ' ' + String(T('js.capacityVolume')).replace('{value}', c.capacity_volume_m3) : '')
+ (c.max_orders != null ? ' ' + String(T('js.capacityOrders')).replace('{value}', c.max_orders) : '');
return (vmap[c.vehicle_type || c.transport] || c.transport || '') + cap;
}
function courierModeText(mode) {
return mode === 'route' ? '🚚 ' + T('couriers.modeRoute')
: (mode === 'ondemand' ? '⚡ ' + T('couriers.modeOnDemand') : '🚚⚡ ' + T('couriers.modeBoth'));
}
/** Ссылка-приглашение из строки курьера. */
function courierInviteLink(el) {
var id = el.getAttribute('data-id');
var courier = (couriersList || []).find(function (c) { return c._id === id; }) || {};
createCourierInviteFor(id, courier.name || '');
}
var LIST_VIEWS = {
members: {
empty: 'js.membersEmpty',
openKind: 'member',
cells: [
function (m) { return '<span class="strong">' + notifEsc(m.name || '') + '</span>'; },
function (m) { return notifEsc(m.email || '—'); },
function (m) {
var roles = (m.roles || []).map(roleText);
var groups = (m.groups || []).map(function (id) { return membersGroupNames[id] || id; });
return notifEsc([].concat(roles, groups).join(', ') || '—');
},
],
actions: [
{ icon: '✏️', title: 'common.edit', onclick: 'openMemberEdit' },
{ icon: ICON_TRASH, cls: 'icon-btn danger', title: 'common.delete', onclick: 'removeMember' },
],
card: {
title: function (m) { return notifEsc(m.name || ''); },
rows: [
{ label: 'memberModal.email', render: function (m) { return notifEsc(m.email || '—'); } },
{ label: 'js.rolesLabel', render: function (m) {
var roles = (m.roles || []).map(roleText);
var groups = (m.groups || []).map(function (id) { return membersGroupNames[id] || id; });
return notifEsc([].concat(roles, groups).join(', ') || '—');
} },
],
},
},
payroll: {
empty: 'payroll.emptyBalances',
openKind: 'settlement',
cells: [
function (row) { return '<span class="strong">' + notifEsc(row.name || '—') + '</span><div class="muted">' + notifEsc(row.phone || '') + '</div>'; },
function (row) { return notifEsc(money(row.accrued || 0) + ' ₽'); },
function (row) { return notifEsc(money(row.paid || 0) + ' ₽'); },
function (row) {
var due = Number(row.due || 0);
return due > 0
? '<span class="strong" style="color:var(--warning);">' + notifEsc(money(due) + ' ₽') + '</span>'
: '<span class="chip delivered">' + notifEsc(T('payroll.settled')) + '</span>';
},
function (row) { return String(row.documents || 0); },
],
actions: [
{ icon: T('payroll.pay'), cls: 'btn btn-primary btn-sm', title: 'payroll.pay', onclick: 'payrollCreateFor', show: function (row) { return Number(row.due || 0) > 0; } },
{ icon: '👁', title: 'payroll.openCard', onclick: 'payrollOpenFor', always: true },
],
card: {
title: function (row) { return notifEsc(row.name || '—'); },
rows: [
{ label: 'payroll.thAccrued', render: function (row) { return notifEsc(money(row.accrued || 0) + ' ₽'); } },
{ label: 'payroll.thPaid', render: function (row) { return notifEsc(money(row.paid || 0) + ' ₽'); } },
{ label: 'payroll.thDue', render: function (row) { return notifEsc(money(row.due || 0) + ' ₽'); } },
],
},
},
payouts: {
empty: 'payroll.emptyDocuments',
openKind: 'payout',
cells: [
function (p) { return '<span class="mono strong">' + notifEsc(p.number || '') + '</span>'; },
function (p) { return notifEsc(fmtDate(p.date)); },
function (p) { return notifEsc(p.courier_name || ''); },
function (p) { return notifEsc(money(p.amount || 0) + ' ₽'); },
function (p) { return notifEsc(methodText(p.method)); },
],
actions: [
{ icon: '👁', title: 'payroll.openDoc', onclick: 'payoutViewFor', always: true },
{ icon: ICON_TRASH, cls: 'icon-btn danger', title: 'payroll.cancelDoc', onclick: 'payoutRemoveFor', always: true },
],
card: {
title: function (p) { return notifEsc(p.number || ''); },
rows: [
{ label: 'payroll.thCourier', render: function (p) { return notifEsc(p.courier_name || ''); } },
{ label: 'payroll.thAmount', render: function (p) { return notifEsc(money(p.amount || 0) + ' ₽'); } },
],
},
},
orders: {
openKind: 'order',
cells: [
function (o) { return '<span class="strong mono">' + notifEsc(o.order_number || o._id.slice(-6)) + '</span>'; },
function (o) { return modeChip(o); },
function (o) {
return o.pickup
? '<div class="strong">' + notifEsc(o.pickup.name || '') + '</div><div class="muted">' + notifEsc(o.pickup.address || '') + '</div>'
: '<span class="dim">' + notifEsc(T('js.fromWarehouse')) + '</span>';
},
function (o) {
var rec = o.recipient || {};
return '<div class="strong">' + notifEsc(rec.name || '—') + '</div><div class="muted">' + notifEsc(rec.address || '') + '</div>';
},
function (o) { return notifEsc(money(o.total_amount || 0) + ' ₽'); },
function (o) {
if (!o.courier_id) return '<span class="dim">' + notifEsc(T('js.waiting')) + '</span>';
var name = couriersIndex.map[o.courier_id];
return name ? notifEsc(name) : '<span class="mono">' + notifEsc(String(o.courier_id).slice(-8)) + '</span>';
},
function (o) {
return o.courier_payout && o.courier_payout.total_earned
? '<span class="strong" style="color:var(--success);">+' + notifEsc(money(o.courier_payout.total_earned) + ' ₽') + '</span>'
: '<span class="dim">—</span>';
},
function (o) {
// День доставки: перенос меняет его, и диспетчер должен видеть дату,
// а не догадываться, почему заказ не попал в сегодняшний план.
if (!o.delivery_date) return '<span class="dim">—</span>';
return notifEsc(String(o.delivery_date).split('-').reverse().join('.'));
},
function (o) {
// ETA считает планировщик при пересчёте и подтверждении маршрута.
if (!o.eta_time) return '<span class="dim">—</span>';
var late = Number(o.eta_delay_min) || 0;
return notifEsc(o.eta_time) + (late > 0
? ' <span style="color:var(--danger);font-size:12px;">+' + late + ' ' + notifEsc(T('js.etaMin')) + '</span>'
: '');
},
function (o) { return statusChip(o.status); },
],
actions: [
{ icon: '👁', title: 'js.open', onclick: 'openOrderViewById', always: true },
{ icon: '👤', title: 'js.assignCourier', onclick: 'openOrderAssignFor', show: function (o) { return !['delivered', 'partially_delivered', 'cancelled'].includes(o.status); } },
{ icon: '✖️', cls: 'icon-btn danger', title: 'js.cancelOrder', onclick: 'cancelOrderFor', show: function (o) { return !['delivered', 'partially_delivered', 'cancelled'].includes(o.status); } },
],
rowClass: function (o) { return (o.in_work || ['claimed', 'assigned', 'picked_up'].includes(o.status)) ? 'inwork' : ''; },
card: {
title: function (o) { return notifEsc(o.order_number || o._id.slice(-6)); },
chip: function (o) { return statusChip(o.status); },
rows: [
{ label: 'orders.thRecipient', render: function (o) { return notifEsc((o.recipient && o.recipient.name) || '—'); } },
{ label: 'orders.thSum', render: function (o) { return notifEsc(money(o.total_amount || 0) + ' ₽'); } },
],
},
},
routes: {
empty: 'js.routesEmpty',
openKind: 'route',
cells: [
function (r) { return notifEsc(fmtDate(r.date)); },
function (r) { return notifEsc(routeCourierName(r)); },
function (r) { return '<span class="strong">' + ((r.stops || []).length) + '</span>'; },
function (r) { return dayStateChip(r.plan_state || r.status); },
],
actions: [
{ icon: '▶️', cls: 'icon-btn ok', title: 'js.routeStart', onclick: 'startRoute', show: function (r) { return r.status !== 'done'; } },
{ icon: '🏁', title: 'js.routeComplete', onclick: 'completeRoute', show: function (r) { return r.status !== 'done'; } },
{ icon: '👁', title: 'js.routeComposition', onclick: 'viewRoute', always: true },
{ icon: ICON_TRASH, cls: 'icon-btn danger', title: 'common.delete', onclick: 'removeRouteById', always: true },
],
card: {
title: function (r) { return notifEsc(fmtDate(r.date)); },
chip: function (r) { return dayStateChip(r.plan_state || r.status); },
rows: [
{ label: 'routes.thCourier', render: function (r) { return notifEsc(routeCourierName(r)); } },
{ label: 'routes.thStops', render: function (r) { return String((r.stops || []).length); } },
],
},
},
couriers: {
empty: 'js.couriersEmpty',
openKind: 'courier',
cells: [
function (c) { return '<span class="strong">' + notifEsc(c.name || '') + '</span>'; },
function (c) { return notifEsc(c.phone || '—'); },
function (c) { return notifEsc(courierTransportText(c)); },
function (c) { return notifEsc(courierModeText(c.mode)); },
function (c) {
return c.online
? '<span class="chip open">' + notifEsc(T('js.online')) + '</span>'
: '<span class="chip cancel">' + notifEsc(T('js.offline')) + '</span>';
},
],
actions: [
{ icon: '✏️', title: 'common.edit', onclick: 'openCourierEdit' },
{ icon: '🔗', title: 'js.getLink', onclick: 'courierInviteLink' },
],
card: {
title: function (c) { return notifEsc(c.name || ''); },
rows: [
{ label: 'js.phoneTransport', render: function (c) { return notifEsc((c.phone || '') + ' · ' + courierTransportText(c)); } },
{ label: 'js.modeLabel', render: function (c) { return notifEsc(courierModeText(c.mode)); } },
],
chip: function (c) {
return c.online
? '<span class="chip open">' + notifEsc(T('js.online')) + '</span>'
: '<span class="chip cancel">' + notifEsc(T('js.offline')) + '</span>';
},
},
},
integrations: {
empty: 'js.integrationsEmpty',
openKind: 'integration',
cells: [
function (it) {
var own = it.own_orders_only === true ? ' <span class="chip pool">' + notifEsc(T('integrationModal.ownOnlyShort')) + '</span>' : '';
return '<span class="strong">' + notifEsc(it.name || '') + '</span>' + own;
},
function (it) { return '<span class="mono">' + notifEsc(it.url || '') + '</span>'; },
function (it) {
var events = it.events || [];
if (events.indexOf('*') !== -1) return notifEsc(T('integrationModal.allEvents'));
return notifEsc(events.length + ' ' + plural(events.length, T('plural.events')));
},
function (it) {
return it.enabled !== false
? '<span class="chip open">' + notifEsc(T('js.enabledOn')) + '</span>'
: '<span class="chip cancel">' + notifEsc(T('js.enabledOff')) + '</span>';
},
],
actions: [
{ icon: '⚙', title: 'integrationModal.openCard', onclick: 'openIntegrationCardById' },
{ icon: '🧪', title: 'js.test', onclick: 'testIntegration' },
{ icon: '⏻', title: 'js.enable', onclick: 'toggleIntegration' },
],
card: {
title: function (it) { return notifEsc(it.name || ''); },
rows: [
{ label: 'integrations.thUrl', render: function (it) { return '<span class="mono">' + notifEsc(it.url || '') + '</span>'; } },
],
chip: function (it) {
return it.enabled !== false
? '<span class="chip open">' + notifEsc(T('js.enabledOn')) + '</span>'
: '<span class="chip cancel">' + notifEsc(T('js.enabledOff')) + '</span>';
},
},
},
warehouses: {
empty: 'settings.warehousesEmpty',
openKind: 'warehouse',
cells: [
function (w) { return '<span class="strong">' + notifEsc(w.name || '') + '</span>'; },
function (w) { return notifEsc(w.address || '—'); },
function (w) {
var geo = w.geo ? (Number(w.geo.lat).toFixed(4) + ', ' + Number(w.geo.lng).toFixed(4)) : '—';
return '<span class="mono">' + notifEsc(geo) + '</span>';
},
],
actions: [
{ icon: '✏️', title: 'common.edit', onclick: 'openWarehouseEdit' },
{ icon: ICON_TRASH, cls: 'icon-btn danger', title: 'common.delete', onclick: 'removeWarehouse' },
],
card: {
title: function (w) { return notifEsc(w.name || ''); },
rows: [
{ label: 'settings.warehouseAddress', render: function (w) { return notifEsc(w.address || '—'); } },
],
},
},
invites: {
empty: 'js.invitesEmpty',
openKind: 'invite',
cells: [
function (i) { return '<span class="strong">' + notifEsc(i.name || '') + '</span>'; },
function (i) {
var token = i.token || i.code || '';
return '<span class="mono">' + notifEsc(token ? '…' + String(token).slice(-8) : '—') + '</span>';
},
function (i) { return inviteStatusChip(i); },
],
actions: [
{ icon: '✏️', title: 'common.edit', onclick: 'openInviteEdit' },
{ icon: '👁', title: 'js.open', onclick: 'openInviteView' },
{ icon: ICON_TRASH, cls: 'icon-btn danger', title: 'common.delete', onclick: 'removeInviteById', always: true },
],
card: {
title: function (i) { return notifEsc(i.name || ''); },
rows: [
{ label: 'invites.thStatus', render: function (i) { return inviteStatusChip(i); } },
],
},
},
groups: {
empty: 'js.groupsEmpty',
openKind: 'group',
cells: [
function (g) { return '<span class="strong">' + notifEsc(g.name || '') + '</span>'; },
function (g) { return g.admin ? notifEsc(T('common.yes')) : '—'; },
function (g) { return accessSummary(g.access); },
],
actions: [
{ icon: '✏️', title: 'common.edit', onclick: 'openGroupEdit' },
{ icon: ICON_TRASH, cls: 'icon-btn danger', title: 'common.delete', onclick: 'removeGroup' },
],
card: {
title: function (g) { return notifEsc(g.name || ''); },
rows: [
{ label: 'groups.thAdmin', render: function (g) { return g.admin ? notifEsc(T('common.yes')) : '—'; } },
{ label: 'groups.thRights', render: function (g) { return accessSummary(g.access); } },
],
},
},
};
/** Оболочка списка: шапка с действием, таблица, карточки и пагинация. */
function listShell(kind) {
var cfg = LISTS[kind];
if (!cfg) return '';
var head = (cfg.columns || []).map(function (c) { return '<th>' + t(c.label) + '</th>'; }).join('');
var action = cfg.addAction
? '<div class="actions"><button class="btn btn-primary" onclick="' + cfg.addAction + '">' + t(cfg.addLabel) + '</button></div>'
: '';
return '<div class="panel">'
+ '<div class="toolbar" style="justify-content:space-between;align-items:center;gap:14px;">'
+ '<div><h1 style="font-size:20px;font-weight:800;">' + t(cfg.title) + '</h1>'
+ '<p style="color:var(--muted);font-size:13px;margin-top:2px;">' + t(cfg.lead) + '</p></div>'
+ action + '</div>'
+ (kind === 'orders'
? '<div class="bulk-bar" id="bulkBar">'
+ '<span class="bulk-count" id="bulkCount"></span>'
+ '<button class="btn btn-ghost btn-sm" onclick="bulkSelectPage()">' + t('bulk.allPage') + '</button>'
+ '<button class="btn btn-primary btn-sm" onclick="bulkAssignOpen()">' + t('bulk.assign') + '</button>'
+ '<button class="btn btn-ghost btn-sm" onclick="bulkMoveOpen()">' + t('bulk.move') + '</button>'
+ '<button class="btn btn-danger btn-sm" onclick="bulkCancel()">' + t('bulk.cancel') + '</button>'
+ '<button class="btn btn-ghost btn-sm" onclick="bulkClear()">' + t('bulk.clear') + '</button>'
+ '</div>'
: '')
+ '<div class="tbl-wrap"><table><thead><tr>' + head + '<th></th></tr></thead><tbody id="' + kind + 'Body"></tbody></table></div>'
+ '<div class="cards" id="' + kind + 'Cards"></div>'
+ '<div class="pager" id="pager-' + kind + '">'
+ '<span class="pager-info" id="pager-info-' + kind + '"></span>'
+ '<span class="pager-pages" id="pager-pages-' + kind + '"></span>'
+ '<span class="pager-actions">'
+ '<button class="btn btn-ghost btn-sm" data-kind="' + kind + '" data-step="-1" onclick="listStep(this)">‹ ' + t('common.prev') + '</button>'
+ '<button class="btn btn-ghost btn-sm" data-kind="' + kind + '" data-step="1" onclick="listStep(this)">' + t('common.nextStep') + ' ›</button>'
+ '</span></div>'
+ '</div>';
}
/** Строки и карточки одного списка: ячейки и действия — из конфигурации. */
function listRender(kind, items) {
var cfg = LIST_VIEWS[kind];
if (!cfg) return;
var body = document.getElementById(kind + 'Body');
var cards = document.getElementById(kind + 'Cards');
var empty = '<div class="muted" style="padding:26px;text-align:center;">' + notifEsc(T(cfg.empty || 'common.empty')) + '</div>';
if (!body) return;
if (!items || !items.length) {
body.innerHTML = '<tr><td colspan="' + ((cfg.cells || []).length + 1) + '">' + empty + '</td></tr>';
if (cards) cards.innerHTML = empty;
return;
}
body.innerHTML = items.map(function (item) {
var itemId = item._id || item.courier_id || '';
var cells = (cfg.cells || []).map(function (fn) { return '<td>' + fn(item) + '</td>'; }).join('');
var actions = (cfg.actions || []).filter(function (a) { return !a.show || a.show(item); }).map(function (a) {
return '<button class="' + (a.cls || 'icon-btn') + '" data-id="' + itemId + '" title="' + notifEsc(T(a.title)) + '" onclick="event.stopPropagation(); ' + a.onclick + '(this)">' + a.icon + '</button>';
}).join('');
var row = '<tr class="row-click' + (cfg.rowClass ? ' ' + cfg.rowClass(item) : '') + '" data-kind="' + (cfg.openKind || kind) + '" data-id="' + itemId + '" ondblclick="rowOpen(event, this)">';
// Выделение строки для массовых действий — только у заказов.
if (kind === 'orders') {
var check = '<input type="checkbox" class="row-check" data-order-id="' + itemId + '"'
+ (orderSelection.has(itemId) ? ' checked' : '')
+ ' onclick="event.stopPropagation()" onchange="orderToggleSelect(this)">';
cells = cells.replace(/^(<td[^>]*>)/, '$1' + check);
}
return row + cells + '<td class="row-actions">' + actions + '</td></tr>';
}).join('');
if (cards) {
cards.innerHTML = items.map(function (item) {
var itemId = item._id || item.courier_id || '';
var card = cfg.card || {};
var rows = (card.rows || []).filter(function (r) { return !r.show || r.show(item); }).map(function (r) {
return '<div class="kv"><span class="k">' + notifEsc(T(r.label)) + '</span><span class="v' + (r.cls ? ' ' + r.cls : '') + '">' + (r.render ? r.render(item) : notifEsc(item[r.key] || '')) + '</span></div>';
}).join('');
var actions = (cfg.actions || []).filter(function (a) { return !a.show || a.show(item); }).map(function (a) {
return '<button class="' + (a.cls || 'icon-btn') + '" data-id="' + itemId + '" onclick="' + a.onclick + '(this)">' + a.icon + '</button>';
}).join('');
return '<div class="mcard row-click" data-kind="' + (cfg.openKind || kind) + '" data-id="' + itemId + '" ondblclick="rowOpen(event, this)"><div class="h"><span class="strong">' + (card.title ? card.title(item) : notifEsc(item.name || '')) + '</span>' + (card.chip ? card.chip(item) : '') + '</div>'
+ rows + '<div class="row-actions">' + actions + '</div></div>';
}).join('');
}
}
/**
* Пагинация: один компонент на все списки. По 20 записей, страницы
* считает служба, номер страницы живёт в адресе страницы.
*/
var LIST_PAGE = 20;
var listState = {};
function listPage(kind) {
if (!listState[kind]) listState[kind] = { page: 0 };
return listState[kind].page;
}
function listSkip(kind) { return listPage(kind) * LIST_PAGE; }
/** Шаг назад или вперёд: кнопка знает свой список и направление. */
function listStep(button) {
var kind = button.getAttribute('data-kind');
var step = Number(button.getAttribute('data-step')) || 0;
listGo(kind, listPage(kind) + step);
}
/** Переход по номеру страницы: кнопка знает список и номер (с единицы). */
function listPageGo(button) {
var kind = button.getAttribute('data-kind');
var page = Number(button.getAttribute('data-page')) || 1;
listGo(kind, page - 1);
}
function listGo(kind, page) {
listState[kind] = { page: Math.max(0, page) };
syncPageInUrl(kind);
reloadList(kind);
}
function listReset(kind) { if (listState[kind]) listState[kind].page = 0; }
function reloadList(kind) {
if (kind === 'orders') loadOrders();
else if (kind === 'couriers') loadCouriers();
else if (kind === 'payroll') loadPayroll();
else if (kind === 'members') loadMembers();
else if (kind === 'groups') loadGroups();
else if (kind === 'invites') loadInvites();
else if (kind === 'join') loadJoin();
else if (kind === 'routes') loadRoutes();
else if (kind === 'integrations') loadIntegrations();
else if (kind === 'warehouses') loadWarehouses();
}
/** Номер страницы — в адресе: ссылку можно передать, история работает. */
function syncPageInUrl(kind) {
try {
var url = new URL(window.location.href);
var page = listPage(kind);
if (page > 0) url.searchParams.set('page', String(page + 1));
else url.searchParams.delete('page');
window.history.replaceState(null, '', url.pathname + url.search);
} catch (e) { /* адрес не трогаем, если браузер не дал */ }
}
/**
* Номера страниц для подвала списка: все, если страниц немного, иначе
* первая, последняя и соседние с текущей, а разрыв — многоточием.
* Возвращает числа и строку '…'.
*/
function pagerNumbers(current, pages) {
if (pages <= 7) {
var all = [];
for (var i = 1; i <= pages; i++) all.push(i);
return all;
}
var near = [1, pages, current - 1, current, current + 1];
var sorted = [];
near.forEach(function (n) {
if (n >= 1 && n <= pages && sorted.indexOf(n) === -1) sorted.push(n);
});
sorted.sort(function (a, b) { return a - b; });
var out = [];
sorted.forEach(function (n, i) {
if (i > 0 && n - sorted[i - 1] > 1) out.push('…');
out.push(n);
});
return out;
}
/**
* Массовые действия над заказами: выделение строк, панель «выбрано N» и
* один запрос на пачку. Результат — по каждому заказу: сделано и отказы,
* чтобы диспетчер видел, что именно не прошло.
*/
var orderSelection = new Set();
function orderToggleSelect(input) {
var id = input.getAttribute('data-order-id');
if (input.checked) orderSelection.add(id); else orderSelection.delete(id);
bulkRender();
}
function bulkRender() {
var bar = document.getElementById('bulkBar');
var count = document.getElementById('bulkCount');
if (bar) bar.style.display = orderSelection.size ? 'flex' : 'none';
if (count) count.innerText = String(T('bulk.selected')).replace('{count}', String(orderSelection.size));
}
/** Выбрать всё, что на текущей странице; страницы считает служба. */
function bulkSelectPage() {
(loadedOrders || []).forEach(function (o) { orderSelection.add(o._id); });
listRender('orders', loadedOrders);
bulkRender();
}
function bulkClear() {
orderSelection.clear();
listRender('orders', loadedOrders);
bulkRender();
}
async function bulkAssignOpen() {
if (!orderSelection.size) return;
var js = await apiUniversal('couriers', 'list', { ws: currentWs }, { notifyErrors: true });
if (js.code !== 0) return;
var list = (js.data && js.data.items) || [];
if (!list.length) { toast(T('js.addCourierFirst'), 'err'); return; }
document.getElementById('bulkCourier').innerHTML = list.map(function (c) {
return '<option value="' + c._id + '">' + c.name + (c.phone ? ' · ' + c.phone : '') + '</option>';
}).join('');
openModal('bulkAssignModal');
}
function bulkMoveOpen() {
if (!orderSelection.size) return;
var el = document.getElementById('bulkDate');
if (el && !el.value) el.value = new Date().toISOString().slice(0, 10);
openModal('bulkMoveModal');
}
async function bulkCancel() {
if (!orderSelection.size) return;
var text = String(T('bulk.confirmCancel')).replace('{count}', String(orderSelection.size));
if (!(await dsConfirm(text))) return;
bulkRun('cancel');
}
async function bulkRun(action) {
var ids = Array.from(orderSelection);
if (!ids.length) return;
var extra = {};
if (action === 'assign') {
extra.courier_id = document.getElementById('bulkCourier').value;
if (!extra.courier_id) { toast(T('js.chooseCourier'), 'err'); return; }
}
if (action === 'move') {
extra.date = document.getElementById('bulkDate').value;
if (!extra.date) return;
}
var params = Object.assign({ ws: currentWs, action: action, ids: ids }, extra);
var r = await apiUniversal('orders', 'bulk', params);
if (r.code !== 0) { showApiError(r); return; }
var data = r.data || {};
var done = (data.done || []).length;
var failed = (data.failed || []).length;
closeModal('bulkAssignModal');
closeModal('bulkMoveModal');
orderSelection.clear();
loadOrders();
bulkRender();
toast(String(T('bulk.result')).replace('{done}', done).replace('{failed}', failed), failed ? 'err' : 'ok');
}
/** Подвал списка: счётчик, номера страниц и доступность шагов. Скрыт, если всё влезает. */
function renderPager(kind, shown, total) {
var box = document.getElementById('pager-' + kind);
var info = document.getElementById('pager-info-' + kind);
if (!box || !info) return;
var count = Number(total) || 0;
if (!count || count <= LIST_PAGE) { box.classList.remove('on'); return; }
box.classList.add('on');
var from = listPage(kind) * LIST_PAGE + 1;
var to = Math.min(count, from + Math.max(0, shown) - 1);
var pages = Math.max(1, Math.ceil(count / LIST_PAGE));
info.innerText = T('common.rangeOf')
.replace('{from}', String(from)).replace('{to}', String(to)).replace('{total}', String(count))
+ ' · ' + T('common.pageOf').replace('{page}', String(listPage(kind) + 1)).replace('{pages}', String(pages));
// Номера страниц: клик переводит список, активная не нажимается.
var pagesBox = document.getElementById('pager-pages-' + kind);
if (pagesBox) {
var current = listPage(kind) + 1;
pagesBox.innerHTML = pagerNumbers(current, pages).map(function (n) {
if (n === '…') return '<span class="pager-gap">…</span>';
if (n === current) return '<button class="btn btn-primary btn-sm" disabled>' + n + '</button>';
return '<button class="btn btn-ghost btn-sm" data-kind="' + kind + '" data-page="' + n + '" onclick="listPageGo(this)">' + n + '</button>';
}).join('');
}
var buttons = box.querySelectorAll('button[data-step]');
buttons.forEach(function (b) {
var step = Number(b.getAttribute('data-step'));
b.disabled = step < 0 ? listPage(kind) === 0 : to >= count;
});
}
/**
* Единый диалог записи — как в дизайн-системе: заголовок, поля, кнопки и
* закрытие одинаковы для всех объектов, а различаются только описанием
* полей и командами службы.
*
* Описание: { title, addTitle, lead, fields, commands:{create,update,remove},
* list, fromRecord, toPayload, afterSave, deleteTitle }
*/
var warehousesList = [];
var membersGroupNames = {};
var entityState = { kind: null, id: null, record: null };
var ENTITIES = {
/** Курьер: контакты, режим, вместимость. */
courier: {
title: 'couriers.title',
addTitle: 'couriers.addTitle',
editTitle: 'courierEdit.title',
list: { object: 'couriers' },
findById: function (id) { return couriersList.find(function (x) { return x._id === id; }); },
fields: [
{ key: 'name', label: 'courierEdit.name', required: true },
{ key: 'phone', label: 'courierEdit.phone', required: true },
{ key: 'mode', type: 'select', label: 'courierEdit.mode', options: [
{ value: 'route', label: 'couriers.modeRoute' },
{ value: 'ondemand', label: 'couriers.modeOnDemand' },
{ value: 'both', label: 'couriers.modeBoth' },
] },
{ key: 'vehicle_type', type: 'select', label: 'courierEdit.transport', options: [
{ value: 'car', label: 'js.transportCar' },
{ value: 'bike', label: 'js.transportBicycle' },
{ value: 'foot', label: 'js.transportFoot' },
{ value: 'truck', label: 'js.transportTruck' },
] },
{ key: 'max_orders', type: 'number', label: 'courierEdit.maxOrders' },
{ key: 'capacity_volume_m3', type: 'number', label: 'courierEdit.volume' },
{ key: 'capacity_weight_kg', type: 'number', label: 'courierEdit.weight' },
{ key: 'region', label: 'courierEdit.region' },
],
commands: { update: 'edit' },
params: function (values, record, id) {
return { ws: currentWs, courier_id: id, data: values };
},
afterSave: function () { loadCouriers(); },
},
/** Группа и её права. */
group: {
title: 'groupModal.createTitle',
addTitle: 'groupModal.createTitle',
editTitle: 'groupModal.editTitle',
list: { object: 'groups' },
findById: function (id) { return groupsList.find(function (x) { return x._id === id; }); },
fields: [
{ key: 'name', label: 'groupModal.name', required: true, placeholder: 'groupModal.namePlaceholder' },
{ key: 'admin', type: 'checkbox', label: 'groupModal.admin' },
{ key: 'access', type: 'access', label: 'groupModal.rights' },
],
fromRecord: function (r) { return { name: r.name || '', admin: !!r.admin, access: r.access || {} }; },
prepare: async function (record) {
await loadAccessCatalog();
renderAccessMatrix((record && record.access) || {});
},
params: function (values, record, id) {
var body = { name: values.name, admin: values.admin, access: values.access };
return id ? { ws: currentWs, id: id, body: body } : { ws: currentWs, name: body.name, admin: body.admin, access: body.access };
},
commands: { update: 'edit', create: 'create', remove: 'remove' },
afterSave: function () { accessCatalog = null; loadGroups(); },
},
/** Заказ: номер, тип, получатель и сумма; состав правится в карточке. */
order: {
title: 'orderModal.title',
addTitle: 'orderModal.create',
list: { object: 'orders' },
fields: [
{ key: 'order_number', label: 'orderModal.number', required: true },
{ key: 'delivery_mode', type: 'select', label: 'orderModal.type', options: [
{ value: 'pool', label: 'orderModal.pool' },
{ value: 'route', label: 'orderModal.scheduled' },
{ value: 'offer', label: 'orderModal.offer' },
] },
{ key: 'name', label: 'orderModal.recipientName', required: true },
{ key: 'phone', label: 'courierEdit.phone' },
{ key: 'address', label: 'orderModal.recipientAddress', required: true },
{ key: 'lat', type: 'number', label: 'orderModal.geoLat' },
{ key: 'lng', type: 'number', label: 'orderModal.geoLng' },
{ key: 'total_amount', type: 'number', label: 'orders.thSum' },
{ key: 'comment', label: 'js.fieldCourierInfo' },
{ key: 'items', type: 'rows', label: 'orderModal.item', columns: [
{ key: 'name', label: 'orderModal.item', type: 'text' },
{ key: 'qty', label: 'day.invoiceQty', type: 'number' },
{ key: 'price', label: 'orderModal.price', type: 'number' },
] },
],
params: function (values) {
return {
ws: currentWs,
data: {
order_number: values.order_number,
delivery_mode: values.delivery_mode,
recipient: {
name: values.name,
phone: values.phone,
address: values.address,
geo: { lat: Number(values.lat) || 0, lng: Number(values.lng) || 0 },
},
total_amount: Number(values.total_amount) || 0,
courier_info: { comment: values.comment || '' },
items: (values.items || []).map(function (row) {
return { name: row.name || '', quantity: Number(row.qty) || 1, price: Number(row.price) || 0 };
}).filter(function (row) { return row.name; }),
},
};
},
commands: { create: 'create' },
afterSave: function () { loadOrders(); },
},
/** Маршрут: дата, курьер и заказы, которые в него входят. */
route: {
title: 'routeModal.title',
addTitle: 'routeModal.title',
list: { object: 'routes' },
fields: [
{ key: 'date', type: 'date', label: 'routeModal.date', required: true },
{ key: 'courier_id', type: 'select', label: 'routeModal.courier', options: [], required: true },
{ key: 'order_ids', type: 'multiSelect', label: 'routeModal.orders', options: [], size: 8 },
],
prepare: async function () {
var cj = await apiUniversal('couriers', 'list', { ws: currentWs, limit: 200 });
var couriers = ((cj.data && cj.data.items) || []);
var select = document.getElementById('ef_courier_id');
if (select) {
select.innerHTML = couriers.map(function (c) { return '<option value="' + c._id + '">' + notifEsc(c.name) + '</option>'; }).join('');
}
var oj = await apiUniversal('orders', 'list', { ws: currentWs, limit: 200 });
var orders = ((oj.data && oj.data.items) || []).filter(function (o) {
return ['delivered', 'partially_delivered', 'cancelled'].indexOf(o.status) === -1;
});
var box = document.getElementById('ef_order_ids');
if (box) {
box.innerHTML = orders.map(function (o) {
return '<option value="' + o._id + '">' + notifEsc((o.order_number || '') + ' · ' + ((o.recipient && o.recipient.address) || '')) + '</option>';
}).join('');
}
var dateEl = document.getElementById('ef_date');
if (dateEl && !dateEl.value) dateEl.value = new Date().toISOString().slice(0, 10);
},
params: function (values) {
return { ws: currentWs, courier_id: values.courier_id, date: values.date, order_ids: values.order_ids };
},
commands: { create: 'create', remove: 'remove' },
afterSave: function () {
if (typeof activeSection !== 'undefined' && activeSection === 'day') loadDay();
else loadRoutes();
},
},
/** Внешняя система: адрес, секреты, подписка и флаг «только свои». */
integration: {
title: 'integrationModal.addTitle',
addTitle: 'integrationModal.addTitle',
editTitle: 'integrationModal.cardTitle',
list: { object: 'external_systems' },
fields: [
{ key: 'name', label: 'integrationModal.name', required: true, placeholder: 'integrationModal.namePlaceholder' },
{ key: 'url', label: 'integrationModal.url', hint: 'integrationModal.urlHint', required: true, placeholder: 'integrationModal.urlPlaceholder' },
{ key: 'secret', type: 'password', label: 'integrationModal.secret', hint: 'integrationModal.secretHint', placeholder: 'integrationModal.secretPlaceholder' },
{ key: 'own_orders_only', type: 'checkbox', label: 'integrationModal.ownOnly', hint: 'integrationModal.ownOnlyHint' },
{ key: 'allowed_ips', label: 'integrationModal.ips', hint: 'integrationModal.ipsHint' },
{ key: 'events', type: 'checkboxGroup', label: 'integrationModal.events', hint: 'integrationModal.eventsHint', options: [] },
],
fromRecord: function (r) {
return {
name: r.name || '',
url: r.url || '',
secret: '',
own_orders_only: r.own_orders_only === true,
allowed_ips: (r.allowed_ips || []).join(', '),
events: r.events || ['*'],
};
},
prepare: async function (record) {
if (!integrationEvents.length) {
var list = await apiUniversal('external_systems', 'list', { ws: currentWs, limit: 1 });
integrationEvents = (list.data && list.data.available_events) || [];
}
var picked = (record && record.events) || [];
var box = document.getElementById('ef_events');
if (box) {
var codes = ['*'].concat(integrationEvents);
box.innerHTML = codes.map(function (ev) {
var on = picked.indexOf(ev) !== -1 || (ev === '*' && !picked.length);
return '<label style="display:flex;gap:6px;align-items:center;font-size:13px;">'
+ '<input type="checkbox" data-group="ef_events" value="' + notifEsc(ev) + '"' + (on ? ' checked' : '') + '> '
+ notifEsc(eventLabel(ev)) + '</label>';
}).join('');
}
},
params: function (values, record, id) {
var body = {
name: values.name,
url: values.url,
events: values.events,
allowed_ips: String(values.allowed_ips || '').split(/[,s]+/).filter(Boolean),
own_orders_only: values.own_orders_only,
};
if (values.secret) body.secret = values.secret;
return id ? { ws: currentWs, id: id, body: body } : Object.assign({ ws: currentWs }, body);
},
commands: { create: 'create', update: 'edit' },
afterSave: async function (data, id) {
await loadIntegrations();
if (!id && data) showIssuedKey(data.api_key || '', data.secret || '');
},
},
/** Выплата: сумма, способ, комментарий; остаток подсказан в шапке. */
payout: {
title: 'payroll.payTitle',
addTitle: 'payroll.payTitle',
lead: 'payroll.payTitle',
list: { object: 'payouts' },
fields: [
{ key: 'amount', type: 'number', label: 'payroll.thAmount', required: true },
{ key: 'method', type: 'select', label: 'payroll.thMethod', options: [
{ value: 'cash', label: 'payroll.methodCash' },
{ value: 'bank', label: 'payroll.methodBank' },
{ value: 'card', label: 'payroll.methodCard' },
] },
{ key: 'comment', label: 'payroll.thComment' },
],
params: function (values) {
return {
ws: currentWs,
courier_id: entityState.courierId,
amount: Number(values.amount),
method: values.method,
comment: values.comment,
};
},
commands: { create: 'create' },
afterSave: async function (data) {
toast(T('payroll.paidDone').replace('{number}', (data && data.number) || ''), 'ok');
await loadPayroll();
if (document.getElementById('courierSettlementModal').classList.contains('open')) {
openCourierSettlement(entityState.courierId);
}
},
},
/** Приглашение: имя, группы и, по желанию, курьерские параметры. */
invite: {
title: 'inviteCreate.title',
addTitle: 'inviteCreate.title',
list: { object: 'invites' },
fields: [
{ key: 'name', label: 'inviteCreate.name', placeholder: 'inviteCreate.namePlaceholder' },
{ key: 'groups', type: 'multiSelect', label: 'inviteCreate.groups', options: [] },
{ key: 'courier', type: 'checkbox', label: 'inviteCreate.createCourier' },
{ key: 'vehicle_type', type: 'select', label: 'courierEdit.transport', options: [
{ value: 'car', label: 'js.transportCar' },
{ value: 'bicycle', label: 'js.transportBicycle' },
{ value: 'foot', label: 'js.transportFoot' },
{ value: 'truck', label: 'js.transportTruck' },
] },
{ key: 'max_orders', type: 'number', label: 'courierEdit.maxOrders' },
],
prepare: async function () {
var gj = await apiUniversal('groups', 'list', { ws: currentWs, limit: 200 });
var groups = ((gj.data && gj.data.items) || []);
var select = document.getElementById('ef_groups');
if (select) {
select.innerHTML = groups.map(function (g) { return '<option value="' + g._id + '">' + notifEsc(g.name) + '</option>'; }).join('');
}
},
params: function (values) {
var body = { ws: currentWs, name: values.name, groups: values.groups };
if (values.courier) {
body.courier = { vehicle_type: values.vehicle_type, max_orders: values.max_orders };
}
return body;
},
commands: { create: 'create' },
afterSave: async function (data) {
if (data && data.url) {
var box = document.getElementById('invResult');
if (box) {
box.style.display = 'flex';
box.innerHTML = '<span class="ico">🔗</span><span>' + notifEsc(T('js.linkLabel')) + ' <a href="' + data.url + '" target="_blank">' + notifEsc(data.url) + '</a></span>';
}
}
loadInvites();
},
},
/** Склад: точка выезда курьеров. */
warehouse: {
title: 'settings.addWarehouse',
addTitle: 'settings.addWarehouse',
editTitle: 'settings.editWarehouse',
list: { object: 'warehouses' },
findById: function (id) { return warehousesList.find(function (x) { return x._id === id; }); },
fields: [
{ key: 'name', label: 'settings.warehouseName', required: true },
{ key: 'address', label: 'settings.warehouseAddress' },
{ key: 'lat', type: 'number', label: 'settings.warehouseLat' },
{ key: 'lng', type: 'number', label: 'settings.warehouseLng' },
],
fromRecord: function (r) {
return { name: r.name || '', address: r.address || '', lat: r.geo ? r.geo.lat : '', lng: r.geo ? r.geo.lng : '' };
},
params: function (values, record, id) {
var body = { name: values.name, address: values.address, geo: { lat: Number(values.lat), lng: Number(values.lng) } };
return id ? { ws: currentWs, id: id, data: body } : { ws: currentWs, data: body };
},
commands: { create: 'create', update: 'update', remove: 'remove' },
afterSave: function () { loadWarehouses(); },
},
/** Участник пространства. */
member: {
title: 'members.title',
addTitle: 'memberModal.addTitle',
editTitle: 'memberModal.editTitle',
list: { object: 'members' },
findById: function (id) { return membersList.find(function (x) { return x._id === id; }); },
fields: [
{ key: 'name', label: 'memberModal.name', required: true },
{ key: 'email', label: 'memberModal.email' },
{ key: 'roles', type: 'checkboxGroup', label: 'memberModal.roles', options: [
{ value: 'admin', label: 'role.admin' },
{ value: 'dispatcher', label: 'role.dispatcher' },
{ value: 'courier', label: 'role.courier' },
{ value: 'warehouse', label: 'role.warehouse' },
{ value: 'finance', label: 'role.finance' },
{ value: 'viewer', label: 'role.viewer' },
{ value: 'integrator', label: 'role.integrator' },
] },
],
fromRecord: function (r) { return { name: r.name || '', email: r.email || '', roles: r.roles || [], groups: r.groups || [] }; },
params: function (values, record, id) {
var body = { name: values.name, email: values.email, roles: values.roles, groups: (record && record.groups) || [] };
return id ? { ws: currentWs, id: id, body: body } : { ws: currentWs, name: body.name, email: body.email, roles: body.roles, groups: body.groups };
},
commands: { create: 'create', update: 'edit', remove: 'remove' },
afterSave: function () { loadMembers(); },
},
};
function entityFieldHtml(field, value) {
var id = 'ef_' + field.key;
var label = '<label>' + notifEsc(T(field.label)) + (field.hint ? ' <span class="tip">' + notifEsc(T(field.hint)) + '</span>' : '') + '</label>';
if (field.type === 'select') {
var options = (field.options || []).map(function (o) {
return '<option value="' + notifEsc(o.value) + '"' + (String(value) === String(o.value) ? ' selected' : '') + '>' + notifEsc(T(o.label)) + '</option>';
}).join('');
return '<div class="fld">' + label + '<select class="select" id="' + id + '">' + options + '</select></div>';
}
if (field.type === 'checkbox') {
return '<label style="display:flex;gap:8px;align-items:center;font-size:14px;">'
+ '<input type="checkbox" id="' + id + '"' + (value ? ' checked' : '') + '> ' + notifEsc(T(field.label))
+ (field.hint ? ' <span class="tip">' + notifEsc(T(field.hint)) + '</span>' : '') + '</label>';
}
if (field.type === 'checkboxGroup') {
var boxes = (field.options || []).map(function (o) {
var on = Array.isArray(value) && value.indexOf(o.value) !== -1;
return '<label style="display:flex;gap:6px;align-items:center;font-size:13px;"><input type="checkbox" data-group="' + id + '" value="' + notifEsc(o.value) + '"' + (on ? ' checked' : '') + '> ' + notifEsc(T(o.label)) + '</label>';
}).join('');
return '<div class="fld">' + label + '<div id="' + id + '" style="display:flex;flex-wrap:wrap;gap:8px 16px;">' + boxes + '</div></div>';
}
if (field.type === 'rows') {
return '<div class="fld">' + label
+ '<div class="tbl-wrap"><table><thead><tr>'
+ (field.columns || []).map(function (c) { return '<th>' + notifEsc(T(c.label)) + '</th>'; }).join('')
+ '<th></th></tr></thead><tbody id="' + id + '"></tbody></table></div>'
+ '<div style="margin-top:6px;"><button class="btn btn-ghost btn-sm" type="button" data-field="' + field.key + '" onclick="entityAddRow(this)">' + notifEsc(T('common.addRow')) + '</button></div>'
+ '</div>';
}
if (field.type === 'multiSelect') {
var opts = (field.options || []).map(function (o) {
var on = Array.isArray(value) && value.indexOf(o.value) !== -1;
return '<option value="' + notifEsc(o.value) + '"' + (on ? ' selected' : '') + '>' + notifEsc(o.label ? T(o.label) : o.value) + '</option>';
}).join('');
return '<div class="fld">' + label + '<select class="select" id="' + id + '" multiple size="' + (field.size || 4) + '">' + opts + '</select></div>';
}
if (field.type === 'access') {
return '<div class="fld">' + label + '<div id="' + id + '" class="access-matrix"></div></div>';
}
var type = field.type === 'number' ? 'number'
: (field.type === 'password' ? 'password' : (field.type === 'date' ? 'date' : 'text'));
return '<div class="fld">' + label + '<input class="input" id="' + id + '" type="' + type + '" value="' + notifEsc(value == null ? '' : String(value)) + '" placeholder="' + notifEsc(field.placeholder ? T(field.placeholder) : '') + '"></div>';
}
/** Табличная часть: строки с полями, добавление и удаление кнопками. */
function entityRowsField(key) {
var def = ENTITIES[entityState.kind];
return ((def && def.fields) || []).find(function (f) { return f.key === key && f.type === 'rows'; });
}
function entityRowHtml(field, index, row) {
var cells = (field.columns || []).map(function (c) {
var value = row && row[c.key] != null ? row[c.key] : '';
return '<td><input class="input" data-row="' + field.key + '" data-index="' + index + '" data-col="' + c.key + '" type="' + (c.type === 'number' ? 'number' : 'text') + '" value="' + notifEsc(String(value)) + '"></td>';
}).join('');
return '<tr>' + cells + '<td class="row-actions"><button class="icon-btn danger" type="button" data-field="' + field.key + '" data-index="' + index + '" onclick="entityRemoveRow(this)">' + ICON_TRASH + '</button></td></tr>';
}
/** Перерисовать строки табличной части (индексы всегда по порядку). */
function entityRenderRows(key) {
var field = entityRowsField(key);
var body = document.getElementById('ef_' + key);
if (!field || !body) return;
var rows = field.value || [];
body.innerHTML = rows.map(function (row, i) { return entityRowHtml(field, i, row); }).join('');
// снимаем значения из полей в модель, чтобы не потерять при перерисовке
entitySyncRows(key);
}
function entitySyncRows(key) {
var field = entityRowsField(key);
if (!field) return;
var rows = [];
document.querySelectorAll('input[data-row="' + key + '"]').forEach(function (input) {
var index = Number(input.getAttribute('data-index'));
var col = input.getAttribute('data-col');
if (!rows[index]) rows[index] = {};
rows[index][col] = input.value;
});
field.value = rows.filter(function (r) { return r && Object.keys(r).length; });
}
function entityAddRow(el) {
var key = el.getAttribute('data-field');
var field = entityRowsField(key);
if (!field) return;
entitySyncRows(key);
field.value = (field.value || []).concat([{}]);
var body = document.getElementById('ef_' + key);
if (body) body.innerHTML = field.value.map(function (row, i) { return entityRowHtml(field, i, row); }).join('');
}
function entityRemoveRow(el) {
var key = el.getAttribute('data-field');
var index = Number(el.getAttribute('data-index'));
var field = entityRowsField(key);
if (!field) return;
entitySyncRows(key);
field.value = (field.value || []).filter(function (r, i) { return i !== index; });
var body = document.getElementById('ef_' + key);
if (body) body.innerHTML = field.value.map(function (row, i) { return entityRowHtml(field, i, row); }).join('');
}
/** Прочитать табличную часть в массив объектов. */
function entityReadRows(field) {
var rows = [];
document.querySelectorAll('input[data-row="' + field.key + '"]').forEach(function (input) {
var index = Number(input.getAttribute('data-index'));
var col = input.getAttribute('data-col');
if (!rows[index]) rows[index] = {};
rows[index][col] = input.value;
});
return rows.filter(function (r) { return r && Object.keys(r).length; });
}
function entityReadValues(def) {
var values = {};
(def.fields || []).forEach(function (field) {
var el = document.getElementById('ef_' + field.key);
if (field.type === 'checkbox') values[field.key] = !!(el && el.checked);
else if (field.type === 'rows') {
values[field.key] = entityReadRows(field);
} else if (field.type === 'multiSelect') {
values[field.key] = el ? [...el.selectedOptions].map(function (o) { return o.value; }) : [];
} else if (field.type === 'checkboxGroup') {
values[field.key] = [...document.querySelectorAll('input[data-group="ef_' + field.key + '"]:checked')].map(function (b) { return b.value; });
} else if (field.type === 'access') {
values[field.key] = accessFromMatrix();
} else values[field.key] = el ? el.value : '';
});
return values;
}
async function openEntity(kind, id) {
var def = ENTITIES[kind];
if (!def) return;
entityState = { kind: kind, id: id || null, record: null };
var record = {};
if (id) {
if (def.findById) record = def.findById(id) || {};
else if (def.commands.view) {
var r = await apiUniversal(def.list.object, def.commands.view, { ws: currentWs, id: id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
record = r.data || {};
}
}
entityState.record = record;
document.getElementById('entityTitle').innerText = T(id ? (def.editTitle || def.title) : (def.addTitle || def.title));
document.getElementById('entityLead').style.display = def.lead ? 'block' : 'none';
if (def.lead) document.getElementById('entityLead').innerText = T(def.lead);
var values = def.fromRecord ? def.fromRecord(record) : record;
document.getElementById('entityFields').innerHTML = (def.fields || []).map(function (field) {
return entityFieldHtml(field, values[field.key]);
}).join('');
(def.fields || []).forEach(function (field) {
if (field.type === 'rows') {
field.value = Array.isArray(values[field.key]) ? values[field.key].map(function (r) { return Object.assign({}, r); }) : [];
var body = document.getElementById('ef_' + field.key);
if (body) body.innerHTML = field.value.map(function (row, i) { return entityRowHtml(field, i, row); }).join('');
}
});
if (def.prepare) await def.prepare(entityState.record);
var del = document.getElementById('entityDelete');
del.style.display = (id && def.commands.remove) ? 'inline-flex' : 'none';
document.getElementById('entityError').style.display = 'none';
document.getElementById('entitySave').disabled = false;
document.getElementById('entitySave').innerText = T('common.save');
openModal('entityModal');
}
function entityFail(text) {
var box = document.getElementById('entityError');
box.style.display = 'block';
box.innerText = text;
}
async function saveEntity() {
var def = ENTITIES[entityState.kind];
if (!def) return;
var values = entityReadValues(def);
var problem = null;
(def.fields || []).forEach(function (field) {
if (!problem && field.required && !String(values[field.key] == null ? '' : values[field.key]).trim()) {
problem = T(field.label) + ': ' + T('common.fieldRequired');
}
});
if (problem) { entityFail(problem); return; }
var payload = def.toPayload ? def.toPayload(values, entityState.record, entityState.id) : values;
var params = def.params ? def.params(values, entityState.record, entityState.id) : Object.assign({ ws: currentWs }, payload);
if (entityState.id && params.id === undefined) params.id = entityState.id;
var button = document.getElementById('entitySave');
button.disabled = true;
button.innerText = T('common.saving');
var result = await apiUniversal(def.list.object, entityState.id ? def.commands.update : def.commands.create, params);
button.disabled = false;
button.innerText = T('common.save');
if (result.code !== 0) {
// Отказ службы — словами службы, диалог остаётся открытым.
entityFail(result.message || T('common.errorWord'));
return;
}
closeModal('entityModal');
if (def.afterSave) await def.afterSave(result.data, entityState.id);
}
async function removeEntity() {
var def = ENTITIES[entityState.kind];
if (!def || !entityState.id || !def.commands.remove) return;
var ok = await dsConfirm(def.deleteTitle ? T(def.deleteTitle) : T('common.deleteConfirm'), T('common.delete'));
if (!ok) return;
var patch = { ws: currentWs, id: entityState.id };
if (def.removeParams) Object.assign(patch, def.removeParams(entityState.record));
var r = await apiUniversal(def.list.object, def.commands.remove, patch);
if (r.code !== 0) { entityFail(r.message || T('common.errorWord')); return; }
closeModal('entityModal');
if (def.afterSave) await def.afterSave(null, entityState.id);
}
/** Права группы словами: сколько объектов и команд, без JSON в списке. */
function accessSummary(access) {
var objects = Object.keys(access || {}).filter(function (k) { return Array.isArray(access[k]) && access[k].length; });
var commands = 0;
objects.forEach(function (k) { commands += access[k].length; });
if (!objects.length) return '<span class="dim">' + notifEsc(T('groups.noRights')) + '</span>';
return notifEsc(T('groups.rightsSummary').replace('{objects}', objects.length).replace('{commands}', commands));
}
async function loadAccessCatalog() {
if (accessCatalog) return accessCatalog;
var r = await apiUniversal('groups', 'catalog', { ws: currentWs, scope: 'workspace' });
if (r.code !== 0) { showApiError(r); accessCatalog = []; return accessCatalog; }
accessCatalog = (r.data && r.data.services) || [];
return accessCatalog;
}
/** Матрица прав: рисуется в контейнер ef_access единого диалога. */
function renderAccessMatrix(selected, containerId) {
var box = document.getElementById(containerId || 'ef_access');
if (!box) return;
var catalog = accessCatalog || [];
var html = '';
catalog.forEach(function (svc) {
(svc.objects || []).forEach(function (obj) {
if (obj.id === 'system_command') return;
html += '<div style="margin-bottom:10px;"><div class="strong" style="font-size:13px;margin-bottom:4px;">' + obj.id + '</div><div style="display:flex;flex-wrap:wrap;gap:6px 14px;">';
(obj.commands || []).forEach(function (cmd) {
var checked = selected && Array.isArray(selected[obj.id]) && selected[obj.id].indexOf(cmd.id) !== -1;
html += '<label style="display:flex;gap:6px;align-items:center;font-size:12.5px;"><input type="checkbox" class="gAccessCmd" data-object="' + obj.id + '" data-command="' + cmd.id + '"' + (checked ? ' checked' : '') + '> ' + cmd.id + '</label>';
});
html += '</div></div>';
});
});
box.innerHTML = html || '<div class="muted">' + notifEsc(T('js.catalogEmpty')) + '</div>';
}
/** Собранные права группы из матрицы единого диалога. */
function accessFromMatrix() {
var access = {};
document.querySelectorAll('#ef_access .gAccessCmd').forEach(function (el) {
if (!el.checked) return;
var obj = el.getAttribute('data-object');
if (!access[obj]) access[obj] = [];
access[obj].push(el.getAttribute('data-command'));
});
return access;
}
async function openGroupCreate() {
openEntity('group');
}
async function openGroupEdit(id) {
openEntity('group', id);
}
async function removeGroup(el) {
if (!(await dsConfirm(T('js.removeGroupConfirm')))) return;
await apiUniversal('groups', 'remove', { ws: currentWs, id: el.getAttribute('data-id') });
loadGroups();
}
async function loadInvites() {
if (!currentWs) return;
var gj = await apiUniversal('groups', 'list', { ws: currentWs }, { notifyErrors: true });
fillGroupSelects((gj.data && gj.data.items) || []);
var j = await apiUniversal('invites', 'list', {
ws: currentWs, limit: LIST_PAGE, skip: listSkip('invites'),
}, { notifyErrors: true });
var items = (j.data && j.data.items) || [];
renderPager('invites', items.length, Number((j.data && j.data.total) || items.length) || 0);
invitesList = items;
listRender('invites', items);
}
function openInviteEdit(id) {
var i = invitesList.find(function (x) { return x._id === id; });
if (!i) return;
document.getElementById('ieId').value = id;
document.getElementById('ieName').value = i.name || '';
var sel = document.getElementById('ieGroups');
Array.from(sel.options).forEach(function (o) { o.selected = (i.groups || []).indexOf(o.value) !== -1; });
openModal('inviteEditModal');
}
async function saveInviteEdit() {
var name = document.getElementById('ieName').value.trim();
if (!name) { toast(T('js.inviteEnterName'), 'err'); return; }
var sel = document.getElementById('ieGroups');
var groups = Array.from(sel.selectedOptions).map(function (o) { return o.value; });
var id = document.getElementById('ieId').value;
var r = await apiUniversal('invites', 'edit', { ws: currentWs, id: id, body: { name: name, groups: groups } });
if (r.code !== 0) { showApiError(r); return; }
closeModal('inviteEditModal');
loadInvites();
}
async function loadJoin() {
if (!currentWs) return;
var gj = await apiUniversal('groups', 'list', { ws: currentWs }, { notifyErrors: true });
fillGroupSelects((gj.data && gj.data.items) || []);
var fj = await apiUniversal('join_forms', 'listForms', { ws: currentWs }, { notifyErrors: true });
var rj = await apiUniversal('join_requests', 'listRequests', { ws: currentWs }, { notifyErrors: true });
var forms = (fj.data && fj.data.items) || [];
joinFormsList = forms;
var reqs = (rj.data && rj.data.items) || [];
var rows = [];
var cards = [];
forms.forEach(function (f) {
rows.push('<tr><td class="strong">' + notifEsc(String(T('js.formPrefix')).replace('{title}', f.title)) + '</td><td>' + notifEsc(f.mode) + '</td>' +
'<td class="mono"><a href="/join/' + f.token + '" target="_blank">/join/' + f.token.slice(0, 10) + '…</a></td><td class="row-actions"><button class="icon-btn" title="' + notifEsc(T('common.edit')) + '" data-id="' + f._id + '" onclick="openFormEdit(this.dataset.id)">✏️</button></td></tr>');
cards.push('<div class="mcard"><div class="h"><span class="strong">' + notifEsc(String(T('js.formPrefix')).replace('{title}', f.title)) + '</span><span style="display:flex;gap:6px;align-items:center;"><span class="chip route">' + notifEsc(f.mode) + '</span><button class="icon-btn" title="' + notifEsc(T('common.edit')) + '" data-id="' + f._id + '" onclick="openFormEdit(this.dataset.id)">✏️</button></span></div>' +
'<div class="kv"><span class="k">' + notifEsc(T('js.inviteLinkLabel')) + '</span><span class="v mono"><a href="/join/' + f.token + '" target="_blank">/join/' + f.token.slice(0, 10) + '…</a></span></div></div>');
});
reqs.forEach(function (r) {
var who = (r.data && r.data.name) || T('js.noName');
var phone = (r.data && r.data.phone) || '';
var link = r.invite_url ? ('<a href="' + r.invite_url + '" target="_blank">' + notifEsc(T('js.linkWord')) + '</a>') : '';
var act = r.status === 'new'
? ('<button class="icon-btn ok" title="' + notifEsc(T('js.approve')) + '" data-id="' + r._id + '" onclick="approveReq(this)">✔️</button> ' +
'<button class="icon-btn danger" title="' + notifEsc(T('js.reject')) + '" data-id="' + r._id + '" onclick="rejectReq(this)">✖️</button>')
: '';
rows.push('<tr><td>' + notifEsc(String(T('js.requestPrefix')).replace('{name}', who + ' ' + phone)) + '</td><td>' + notifEsc(r.status) + '</td><td>' + link + '</td><td class="row-actions">' + act + '</td></tr>');
cards.push('<div class="mcard"><div class="h"><span class="strong">' + notifEsc(String(T('js.requestPrefix')).replace('{name}', who + ' ' + phone)) + '</span><span class="chip route">' + notifEsc(r.status) + '</span></div>' +
'<div class="kv"><span class="k">' + notifEsc(T('js.inviteLinkLabel')) + '</span><span class="v">' + (link || '—') + '</span></div>' +
(act ? '<div style="display:flex;justify-content:flex-end;gap:6px;">' + act + '</div>' : '') + '</div>');
});
document.getElementById('joinBody').innerHTML = rows.join('') || '<tr><td colspan="4" class="muted">' + notifEsc(T('js.joinEmpty')) + '</td></tr>';
document.getElementById('joinCards').innerHTML = cards.join('') || '<div class="muted" style="padding:16px;">' + notifEsc(T('js.joinEmpty')) + '</div>';
}
function openFormCreate() {
document.getElementById('jfEditId').value = '';
document.getElementById('jfTitle').value = '';
document.getElementById('jfMode').value = 'auto';
Array.from(document.getElementById('jfGroups').options).forEach(function (o) { o.selected = false; });
document.getElementById('formModalTitle').innerText = T('formModal.createTitle');
document.getElementById('formModalSubmit').innerText = T('formModal.create');
openModal('formModal');
}
function openFormEdit(id) {
var f = joinFormsList.find(function (x) { return x._id === id; });
if (!f) return;
document.getElementById('jfEditId').value = id;
document.getElementById('jfTitle').value = f.title || '';
document.getElementById('jfMode').value = f.mode || 'auto';
var sel = document.getElementById('jfGroups');
Array.from(sel.options).forEach(function (o) { o.selected = (f.groups || []).indexOf(o.value) !== -1; });
document.getElementById('formModalTitle').innerText = T('formModal.editTitle');
document.getElementById('formModalSubmit').innerText = T('formModal.save');
openModal('formModal');
}
async function saveForm() {
var title = document.getElementById('jfTitle').value.trim();
if (!title) { toast(T('js.enterFormTitle'), 'err'); return; }
var mode = document.getElementById('jfMode').value;
var sel = document.getElementById('jfGroups');
var groups = sel ? Array.from(sel.selectedOptions).map(function (o) { return o.value; }) : [];
var id = document.getElementById('jfEditId').value;
var r = id
? await apiUniversal('join_forms', 'editForm', { ws: currentWs, id: id, body: { title: title, mode: mode, groups: groups } })
: await apiUniversal('join_forms', 'createForm', { ws: currentWs, title: title, mode: mode, groups: groups });
if (r.code !== 0) { showApiError(r); return; }
if (!id && r.data && r.data.url) {
var area = document.getElementById('jfResult');
area.style.display = 'flex';
area.innerHTML = '<span class="ico">🧩</span><span>' + T('js.widgetLabel') + ' <a href="' + r.data.url + '" target="_blank">' + r.data.url + '</a></span>';
}
closeModal('formModal');
loadJoin();
}
async function approveReq(el) {
var r = await apiUniversal('join_requests', 'approveRequest', { ws: currentWs, id: el.getAttribute('data-id') });
if (r.code === 0 && r.data && r.data.invite_url) dsShowValue(T('js.inviteTitle'), r.data.invite_url);
else if (r.code !== 0) showApiError(r);
loadJoin();
}
async function rejectReq(el) {
await apiUniversal('join_requests', 'rejectRequest', { ws: currentWs, id: el.getAttribute('data-id') });
loadJoin();
}
async function loadRoutes() {
if (!currentWs) return;
var cj = await apiUniversal('couriers', 'list', { ws: currentWs, limit: 200 }, { notifyErrors: true });
var couriers = (cj.data && cj.data.items) || [];
var rj = await apiUniversal('routes', 'list', {
ws: currentWs, limit: LIST_PAGE, skip: listSkip('routes'),
}, { notifyErrors: true });
var routes = (rj.data && rj.data.items) || [];
renderPager('routes', routes.length, Number((rj.data && rj.data.total) || routes.length) || 0);
couriers.forEach(function (c) { routesCourierNames[c._id] = c.name; });
listRender('routes', routes);
}
async function startRoute(el) {
await apiUniversal('routes', 'start', { ws: currentWs, id: el.getAttribute('data-id') });
loadRoutes();
}
async function completeRoute(el) {
await apiUniversal('routes', 'complete', { ws: currentWs, id: el.getAttribute('data-id') });
loadRoutes();
}
async function viewRoute(el) {
var r = await apiUniversal('routes', 'view', { ws: currentWs, id: el.getAttribute('data-id') });
if (r.code !== 0) { showApiError(r); return; }
var lines = (r.data.stops || []).map(function (s) {
var o = s.order || {};
return s.seq + '. ' + (o.order_number || s.order_id) + ' — ' + ((o.recipient && o.recipient.address) || '');
}).join('\n');
dsAlert(fmtDate(r.data.date) + ' (' + r.data.status + '):\n' + lines, T('js.routeTitle'));
}
// ---------- Рабочий день диспетчера ----------
// Экран собирает три независимых ответа сервера: агрегат дня, маршруты и
// ленту. Фильтры — только серверные: клиент ничего не отбирает сам.
var dayState = { plan: '', q: '', routes: [], current: null, orderIds: [], tz: '', feed: [], feedBefore: null };
function dayDateValue() {
var el = document.getElementById('dayDate');
if (el && el.value) return el.value;
var iso = new Date().toISOString().slice(0, 10);
if (el) el.value = iso;
return iso;
}
function dayShift(delta) {
var el = document.getElementById('dayDate');
var base = el.value ? new Date(el.value + 'T00:00:00') : new Date();
base.setDate(base.getDate() + delta);
el.value = base.toISOString().slice(0, 10);
loadDay();
}
async function loadDay() {
if (!currentWs) return;
renderDayChips();
var date = dayDateValue();
// Три независимых ответа: сбой одного не должен оставлять экран пустым.
try {
var params = { ws: currentWs, date: date };
// Первый запрос идёт без зоны: сервер применит настройку компании и
// вернёт её. Дальше передаём ту же зону, чтобы подпись и границы дня
// не расходились, если настройку поменяли в другой вкладке.
if (dayState.tz) params.timezone = dayState.tz;
var r = await apiUniversal('dispatcher', 'day', params, { notifyErrors: true });
if (r && r.code === 0 && r.data) {
if (r.data.timezone) {
dayState.tz = r.data.timezone;
var tzLabel = document.getElementById('dayTz');
if (tzLabel) tzLabel.textContent = r.data.timezone;
}
renderDayKpis(r.data);
}
} catch (e) { console.warn('[day] aggregate failed', e); }
try { await loadDayCouriers(); } catch (e) { console.warn('[day] couriers failed', e); }
try { await loadDayRoutes(); } catch (e) { console.warn('[day] routes failed', e); }
try { await loadDayFeed(); } catch (e) { console.warn('[day] feed failed', e); }
try { initDayMap(); } catch (e) { console.warn('[day] map failed', e); }
try { await loadDayBoard(); } catch (e) { console.warn('[day] board failed', e); }
}
async function loadDayCouriers() {
var cj = await apiUniversal('couriers', 'list', { ws: currentWs }, { notifyErrors: true });
var list = (cj.data && cj.data.items) || [];
couriersList = list;
var filter = document.getElementById('dayCourier');
if (filter) {
var keep = filter.value;
filter.innerHTML = '<option value="">' + notifEsc(T('day.courierAll')) + '</option>' +
list.map(function (c) { return '<option value="' + c._id + '">' + notifEsc(c.name) + '</option>'; }).join('');
filter.value = keep;
}
var pick = document.getElementById('dayRouteCourier');
if (pick) {
var keepPick = pick.value;
pick.innerHTML = '<option value="">' + notifEsc(T('day.unassign')) + '</option>' +
list.map(function (c) { return '<option value="' + c._id + '">' + notifEsc(c.name) + '</option>'; }).join('');
pick.value = keepPick;
}
}
function dayKpi(icon, caption, value, hint, cls) {
return '<div class="kpi"><div class="cap">' + icon + ' ' + notifEsc(caption) + '</div>' +
'<div class="val' + (cls ? ' ' + cls : '') + '">' + value + '</div>' +
'<div class="sub">' + notifEsc(hint) + '</div></div>';
}
function renderDayKpis(d) {
var box = document.getElementById('dayKpis');
if (!box) return;
var o = d.orders || {};
var c = d.couriers || {};
var r = d.routes || {};
var m = d.money || {};
box.innerHTML =
dayKpi('📦', T('day.resActive'), o.active || 0, T('day.resActiveHint')) +
dayKpi('⚡', T('day.resPool'), o.pool || 0, T('day.resPoolHint')) +
dayKpi('🧑✈️', T('day.resCouriers'), (c.on_shift || 0) + ' / ' + (c.total || 0), T('day.resCouriersHint')) +
dayKpi('🟢', T('day.resFree'), c.free || 0, T('day.resFreeHint')) +
dayKpi('📍', T('day.resNoGeo'), o.no_geo || 0, T('day.resNoGeoHint'), (o.no_geo || 0) > 0 ? '' : 'green') +
dayKpi('⚠️', T('day.resViolations'), r.with_violations || 0, T('day.resViolationsHint')) +
dayKpi('💰', T('day.resMoney'), money(m.payout) + ' ₽', T('day.resMoneyHint'));
// Заказы из разных городов: показываем, в каких зонах они лежат, чтобы
// диспетчер не планировал чужой день вслепую.
var notice = document.getElementById('dayTzNotice');
if (notice) {
if (d.mixed_timezones && (d.timezones || []).length) {
var parts = d.timezones.map(function (z) { return z.timezone + ' — ' + z.orders; });
notice.innerHTML = '<span class="ico">🌍</span><span>' + notifEsc(T('day.tzNotice')) + ' ' + notifEsc(parts.join(' · ')) + '</span>';
notice.style.display = 'flex';
} else {
notice.style.display = 'none';
notice.innerHTML = '';
}
}
}
var DAY_CHIPS = [
{ key: '', label: 'day.chipAll' },
{ key: 'draft', label: 'day.chipDraft' },
{ key: 'planned', label: 'day.chipPlanned' },
{ key: 'confirmed', label: 'day.chipConfirmed' },
{ key: 'violations', label: 'day.chipViolations' },
];
function renderDayChips() {
var box = document.getElementById('dayChips');
if (!box) return;
box.innerHTML = DAY_CHIPS.map(function (chip) {
var active = dayState.plan === chip.key;
return '<button class="btn ' + (active ? 'btn-primary' : 'btn-ghost') + '" onclick="setDayChip(\'' + chip.key + '\')">' +
notifEsc(T(chip.label)) + '</button>';
}).join('');
}
function setDayChip(key) {
dayState.plan = key;
renderDayChips();
loadDayRoutes();
}
var daySearchTimer = null;
function daySearchChanged() {
clearTimeout(daySearchTimer);
daySearchTimer = setTimeout(function () {
var el = document.getElementById('daySearch');
dayState.q = el ? el.value.trim() : '';
loadDayRoutes();
}, 300);
}
async function loadDayRoutes() {
if (!currentWs) return;
var params = { ws: currentWs, date: dayDateValue(), limit: 200 };
if (dayState.plan === 'violations') params.has_violations = true;
else if (dayState.plan) params.plan_state = dayState.plan;
if (dayState.q) params.q = dayState.q;
var courierEl = document.getElementById('dayCourier');
if (courierEl && courierEl.value) params.courier_id = courierEl.value;
var r = await apiUniversal('routes', 'list', params, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
var items = (r.data && r.data.items) || [];
dayState.routes = items;
renderDayRoutes(items);
updateDayBulk();
}
function dayStateChip(state) {
var map = {
draft: ['chip cancel', T('day.stateDraft')],
planned: ['chip pool', T('day.statePlanned')],
confirmed: ['chip route', T('day.stateConfirmed')],
locked: ['chip open', T('day.stateLocked')],
};
var item = map[state] || ['chip route', state];
return '<span class="' + item[0] + '">' + notifEsc(item[1]) + '</span>';
}
function dayViolationsCell(r) {
var n = r.violations_count || 0;
if (!n) return '<span class="dim">—</span>';
return '<span class="chip cancel">⚠ ' + n + '</span>';
}
function dayRouteActions(r) {
var act = '';
act += '<button class="icon-btn" title="' + notifEsc(T('day.openRoute')) + '" data-id="' + r._id + '" onclick="openDayRoute(this.getAttribute(\'data-id\'))">👁</button>';
if (r.plan_state !== 'locked') {
act += '<button class="icon-btn ok" title="' + notifEsc(T('day.confirm')) + '" data-id="' + r._id + '" onclick="dayQuickConfirm(this.getAttribute(\'data-id\'))">✅</button>';
}
act += '<button class="icon-btn" title="' + notifEsc(T('day.print')) + '" data-id="' + r._id + '" onclick="dayPrintList(this.getAttribute(\'data-id\'))">🖨</button>';
act += '<button class="icon-btn danger" title="' + notifEsc(T('day.deleteDraft')) + '" data-id="' + r._id + '" onclick="dayQuickDelete(this.getAttribute(\'data-id\'))">🗑</button>';
return act;
}
function renderDayRoutes(items) {
var body = document.getElementById('dayRoutesBody');
var cards = document.getElementById('dayRoutesCards');
if (!body) return;
if (!items.length) {
var empty = '<td colspan="7" class="muted">' + notifEsc(dayState.plan || dayState.q ? T('day.emptyFiltered') : T('day.emptyRoutes')) + '</td>';
body.innerHTML = '<tr>' + empty + '</tr>';
cards.innerHTML = '<div class="muted" style="padding:16px;">' + notifEsc(dayState.plan || dayState.q ? T('day.emptyFiltered') : T('day.emptyRoutes')) + '</div>';
return;
}
body.innerHTML = items.map(function (r) {
return '<tr class="row-click" data-id="' + r._id + '" ondblclick="rowOpen(event, this)" data-kind="dayRoute" title="' + notifEsc(T('day.openRoute')) + '">' +
'<td><input type="checkbox" class="day-pick" value="' + r._id + '" onchange="updateDayBulk()"></td>' +
'<td class="mono">' + notifEsc(r.code || '—') + '</td>' +
'<td>' + (r.courier_name ? notifEsc(r.courier_name) : '<span class="dim">' + notifEsc(T('day.noCourier')) + '</span>') + '</td>' +
'<td class="strong">' + (r.stops_count || 0) + '</td>' +
'<td>' + dayStateChip(r.plan_state) + '</td>' +
'<td>' + dayViolationsCell(r) + '</td>' +
'<td class="row-actions">' + dayRouteActions(r) + '</td>' +
'</tr>';
}).join('');
cards.innerHTML = items.map(function (r) {
return '<div class="mcard">' +
'<div class="h"><span class="strong mono">' + notifEsc(r.code || '—') + '</span>' + dayStateChip(r.plan_state) + '</div>' +
'<div class="kv"><span class="k">' + notifEsc(T('day.thCourier')) + '</span><span class="v">' + (r.courier_name ? notifEsc(r.courier_name) : notifEsc(T('day.noCourier'))) + '</span></div>' +
'<div class="kv"><span class="k">' + notifEsc(T('day.thStops')) + '</span><span class="v strong">' + (r.stops_count || 0) + '</span></div>' +
'<div class="kv"><span class="k">' + notifEsc(T('day.thViolations')) + '</span><span class="v">' + dayViolationsCell(r) + '</span></div>' +
'<div style="display:flex;justify-content:flex-end;gap:6px;">' + dayRouteActions(r) + '</div>' +
'</div>';
}).join('');
}
function dayPicked() {
return Array.from(document.querySelectorAll('.day-pick:checked')).map(function (el) { return el.value; });
}
function updateDayBulk() {
var picked = dayPicked();
var box = document.getElementById('dayBulk');
var count = document.getElementById('dayBulkCount');
if (count) count.textContent = String(picked.length);
if (box) box.style.display = picked.length ? 'flex' : 'none';
}
function dayToggleAll(checked) {
Array.from(document.querySelectorAll('.day-pick')).forEach(function (el) { el.checked = !!checked; });
updateDayBulk();
}
async function openDayRoute(id) {
var params = { ws: currentWs, id: id };
if (dayState.tz) params.timezone = dayState.tz;
var r = await apiUniversal('routes', 'view', params, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
dayState.current = r.data;
dayState.orderIds = (r.data.stops || []).map(function (s) { return s.order_id; });
renderDayRouteModal();
openModal('dayRouteModal');
}
function dayViolationLabel(v) {
var key = 'violations.' + (v && v.code ? v.code : '');
var text = T(key);
if (text === key) text = (v && v.code) || T('events.unknown');
var extra = '';
if (v && v.code === 'slot_missed' && v.arrival) {
extra = ' (' + v.arrival + (v.slot_to ? ' / ' + v.slot_to : '') + ')';
} else if (v && v.value != null && v.limit != null) {
extra = ' (' + money(v.value) + ' / ' + money(v.limit) + ')';
} else if (v && v.value != null) {
extra = ' (' + money(v.value) + ')';
}
return text + extra;
}
function renderDayRouteModal() {
var r = dayState.current;
if (!r) return;
var title = document.getElementById('dayRouteTitle');
var meta = document.getElementById('dayRouteMeta');
var stopsBox = document.getElementById('dayRouteStops');
var vioBox = document.getElementById('dayRouteViolations');
var pick = document.getElementById('dayRouteCourier');
var courierName = r.courier ? r.courier.name : T('day.noCourier');
if (title) title.textContent = (r.code || T('day.openRoute')) + ' · ' + fmtDate(r.date);
if (meta) {
var metrics = '';
if (r.distance_m) {
var km = Math.round(r.distance_m / 100) / 10;
var mins = Math.round((r.duration_s || 0) / 60);
metrics = ' · ' + notifEsc(T('day.distance')) + ': <b>' + km + ' ' + notifEsc(T('day.km')) + '</b>' +
' · ' + notifEsc(T('day.duration')) + ': <b>' + mins + ' ' + notifEsc(T('day.min')) + '</b>' +
(r.distance_estimated ? ' <span class="dim">(' + notifEsc(T('day.estimated')) + ')</span>' : '');
}
meta.innerHTML = notifEsc(T('day.thCourier')) + ': <b>' + notifEsc(courierName) + '</b> · ' +
notifEsc(T('day.thStops')) + ': <b>' + (r.stops || []).length + '</b> · ' + dayStateChip(r.plan_state) + metrics;
}
if (pick) pick.value = r.courier_id || '';
if (stopsBox) {
var byId = {};
(r.stops || []).forEach(function (s) { byId[s.order_id] = s; });
var ordered = dayState.orderIds.map(function (oid) { return byId[oid]; }).filter(Boolean);
stopsBox.innerHTML = ordered.map(function (s, i) {
var o = s.order || {};
var rec = o.recipient || {};
var hasGeo = rec.geo ? '📍' : '⚠️';
return '<div class="day-stop" draggable="true" data-order="' + s.order_id + '"' +
' ondragstart="dayDragStart(event)" ondragover="dayDragOver(event)" ondrop="dayDrop(event)"' +
' style="display:flex;gap:10px;align-items:center;padding:8px 10px;border:1px solid var(--divider);border-radius:8px;background:var(--paper);cursor:grab;">' +
'<span class="mono strong" style="width:22px;">' + (i + 1) + '</span>' +
'<span>' + hasGeo + '</span>' +
'<span style="flex:1;min-width:0;"><b>' + notifEsc(o.order_number || T('day.orderMissing')) + '</b>' +
' · ' + notifEsc(rec.name || '') + ' · ' + notifEsc(rec.address || '') +
(o.timezone && dayState.tz && o.timezone !== dayState.tz
? '<span class="chip pool" style="margin-left:6px;">🌍 ' + notifEsc(o.timezone) + '</span>'
: '') +
(o.delivery_slot ? '<div class="muted" style="font-size:12px;">' + notifEsc(T('day.sheetSlot')) + ': ' + notifEsc(o.delivery_slot) + '</div>' : '') +
(dayRouteLeg(s.order_id) ? '<div class="muted" style="font-size:12px;">' + notifEsc(T('day.arrival')) + ': ' + notifEsc(dayRouteLeg(s.order_id)) + '</div>' : '') +
'</span></div>';
}).join('') || '<div class="muted">' + notifEsc(T('js.routesEmpty')) + '</div>';
}
if (vioBox) {
var vio = r.violations || [];
if (!vio.length) {
vioBox.innerHTML = '<div class="chip delivered">✅ ' + notifEsc(T('day.noViolations')) + '</div>';
} else {
vioBox.innerHTML = vio.map(function (v) {
return '<div class="chip cancel" style="margin:2px 4px 2px 0;">⚠ ' + notifEsc(dayViolationLabel(v)) + '</div>';
}).join('');
}
}
}
var dayDragFrom = null;
function dayDragStart(ev) {
dayDragFrom = ev.currentTarget.getAttribute('data-order');
try { ev.dataTransfer.setData('text/plain', dayDragFrom); } catch (e) { console.warn('[day] drag', e); }
ev.currentTarget.style.opacity = '0.5';
}
function dayDragOver(ev) { ev.preventDefault(); }
async function dayDrop(ev) {
ev.preventDefault();
var target = ev.currentTarget.getAttribute('data-order');
var from = dayDragFrom;
dayDragFrom = null;
if (!from || !target || from === target) { renderDayRouteModal(); return; }
var ids = dayState.orderIds.slice();
var i = ids.indexOf(from);
var j = ids.indexOf(target);
if (i === -1 || j === -1) return;
ids.splice(i, 1);
ids.splice(j, 0, from);
dayState.orderIds = ids;
var r = await apiUniversal('routes', 'reorder', { ws: currentWs, id: dayState.current._id, order_ids: ids }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); }
await openDayRoute(dayState.current._id);
loadDayRoutes();
}
async function dayAssignCourier() {
if (!dayState.current) return;
var pick = document.getElementById('dayRouteCourier');
var r = await apiUniversal('routes', 'assignCourier', {
ws: currentWs, id: dayState.current._id, courier_id: pick ? pick.value : '',
}, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
toast(T('js.dayAssignDone'), 'ok');
await openDayRoute(dayState.current._id);
loadDayRoutes();
}
async function dayConfirmRoute() {
if (!dayState.current) return;
var params = { ws: currentWs, id: dayState.current._id };
if (dayState.tz) params.timezone = dayState.tz;
var r = await apiUniversal('routes', 'confirm', params, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
toast(T('js.dayConfirmDone'), 'ok');
await openDayRoute(dayState.current._id);
loadDay();
}
async function dayLockRoute() {
if (!dayState.current) return;
var r = await apiUniversal('routes', 'lock', { ws: currentWs, id: dayState.current._id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
toast(T('js.dayLockDone'), 'ok');
await openDayRoute(dayState.current._id);
loadDay();
}
async function dayDeleteRoute() {
if (!dayState.current) return;
var ok = await dsConfirm(T('js.dayDeleteConfirm'), T('day.deleteDraft'));
if (!ok) return;
var r = await apiUniversal('routes', 'remove', { ws: currentWs, id: dayState.current._id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
closeModal('dayRouteModal');
dayState.current = null;
toast(T('js.dayDeleted'), 'ok');
loadDay();
}
async function dayQuickConfirm(id) {
var params = { ws: currentWs, id: id };
if (dayState.tz) params.timezone = dayState.tz;
var r = await apiUniversal('routes', 'confirm', params, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
toast(T('js.dayConfirmDone'), 'ok');
loadDay();
}
async function dayQuickDelete(id) {
var ok = await dsConfirm(T('js.dayDeleteConfirm'), T('day.deleteDraft'));
if (!ok) return;
var r = await apiUniversal('routes', 'remove', { ws: currentWs, id: id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
toast(T('js.dayDeleted'), 'ok');
loadDay();
}
async function dayBulkConfirm() {
var ids = dayPicked();
if (!ids.length) { toast(T('js.dayNothingSelected'), 'err'); return; }
for (var i = 0; i < ids.length; i++) {
var r = await apiUniversal('routes', 'confirm', { ws: currentWs, id: ids[i] });
if (r.code !== 0) { showApiError(r); return; }
}
toast(T('js.dayBulkConfirmDone'), 'ok');
loadDay();
}
async function dayBulkDelete() {
var ids = dayPicked();
if (!ids.length) { toast(T('js.dayNothingSelected'), 'err'); return; }
var ok = await dsConfirm(T('js.dayBulkDeleteConfirm'), T('day.deleteSelected'));
if (!ok) return;
var failed = null;
for (var i = 0; i < ids.length; i++) {
var r = await apiUniversal('routes', 'remove', { ws: currentWs, id: ids[i] });
if (r.code !== 0) failed = r;
}
if (failed) showApiError(failed); else toast(T('js.dayDeleted'), 'ok');
loadDay();
}
/** Маршрутный лист: печатается отдельным окном, без интерфейса кабинета. */
function daySheetHtml(route) {
var stops = (route.stops || []).map(function (s, i) {
var o = s.order || {};
var rec = o.recipient || {};
return '<tr><td>' + (i + 1) + '</td><td>' + notifEsc(o.order_number || '') + '</td>' +
'<td>' + notifEsc(rec.name || '') + '</td>' +
'<td>' + notifEsc(rec.address || '') + '</td>' +
'<td>' + notifEsc(rec.phone || '') + '</td>' +
'<td>' + notifEsc(o.delivery_slot || '') + '</td></tr>';
}).join('');
return '<!doctype html><html lang="' + (I18N.lang || 'ru') + '"><head><meta charset="utf-8">' +
'<title>' + notifEsc(T('day.routeSheet')) + ' ' + notifEsc(route.code || '') + '</title>' +
'<style>body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;padding:24px;color:var(--ink-900)}' +
'h1{font-size:20px;margin:0 0 4px}p{margin:2px 0;font-size:13px;color:var(--ink-soft)}' +
'table{width:100%;border-collapse:collapse;margin-top:16px;font-size:13px}' +
'th,td{border:1px solid var(--line);padding:7px 9px;text-align:left}th{background:var(--bg)}</style></head><body>' +
'<h1>' + notifEsc(T('day.routeSheet')) + ' ' + notifEsc(route.code || '') + '</h1>' +
'<p>' + notifEsc(T('day.sheetDate')) + ': ' + notifEsc(fmtDate(route.date)) + '</p>' +
'<p>' + notifEsc(T('day.sheetCourier')) + ': ' + notifEsc(route.courier ? route.courier.name : '—') + '</p>' +
'<p>' + notifEsc(T('day.sheetTotal')) + ': ' + (route.stops || []).length + '</p>' +
'<table><thead><tr><th>#</th><th>' + notifEsc(T('orders.thNumber')) + '</th>' +
'<th>' + notifEsc(T('day.sheetRecipient')) + '</th><th>' + notifEsc(T('day.sheetAddress')) + '</th>' +
'<th>' + notifEsc(T('day.sheetPhone')) + '</th><th>' + notifEsc(T('day.sheetSlot')) + '</th></tr></thead>' +
'<tbody>' + stops + '</tbody></table>' +
'<p style="margin-top:18px;">' + notifEsc(T('day.sheetPrintHint')) + '</p>' +
'</body></html>';
}
/** Накладная по одному заказу: состав, суммы, подпись получателя. */
function dayInvoicePage(order, route) {
var o = order || {};
var items = (o.items || []).map(function (it) {
var price = Number(it.price || 0);
var qty = Number(it.qty || it.quantity || 0);
return '<tr><td>' + notifEsc(it.name || it.item_id || '') + '</td>' +
'<td style="text-align:center;">' + qty + '</td>' +
'<td style="text-align:right;">' + money(price) + '</td>' +
'<td style="text-align:right;">' + money(price * qty) + '</td></tr>';
}).join('');
var payment = o.payment_info || {};
var payText = payment.type === 'card' ? T('report.card') : (payment.type === 'cash' ? T('report.cash') : '—');
return '<section style="page-break-after:always;padding:20px;font-size:13px;">' +
'<h2 style="margin:0 0 4px;font-size:17px;">' + notifEsc(T('day.invoiceTitle')) + ' ' + notifEsc(o.order_number || '') + '</h2>' +
'<p style="margin:2px 0;color:var(--muted);">' + notifEsc(route && route.code ? route.code : '') + ' · ' + notifEsc(fmtDate(route && route.date ? route.date : reportDateValue())) + '</p>' +
'<table style="width:100%;border-collapse:collapse;margin:10px 0;font-size:13px;">' +
'<tr><td style="width:150px;color:var(--muted);">' + notifEsc(T('day.sheetRecipient')) + '</td><td>' + notifEsc((o.recipient && o.recipient.name) || '') + '</td></tr>' +
'<tr><td style="color:var(--muted);">' + notifEsc(T('day.sheetPhone')) + '</td><td>' + notifEsc((o.recipient && o.recipient.phone) || '') + '</td></tr>' +
'<tr><td style="color:var(--muted);">' + notifEsc(T('day.sheetAddress')) + '</td><td>' + notifEsc((o.recipient && o.recipient.address) || '') + '</td></tr>' +
'<tr><td style="color:var(--muted);">' + notifEsc(T('day.sheetSlot')) + '</td><td>' + notifEsc(o.delivery_slot || '') + '</td></tr>' +
'</table>' +
'<table style="width:100%;border-collapse:collapse;font-size:13px;">' +
'<thead><tr><th>' + notifEsc(T('day.invoiceItem')) + '</th><th>' + notifEsc(T('day.invoiceQty')) + '</th>' +
'<th>' + notifEsc(T('day.invoicePrice')) + '</th><th>' + notifEsc(T('day.invoiceSum')) + '</th></tr></thead>' +
'<tbody>' + (items || '<tr><td colspan="4" style="color:var(--muted);">—</td></tr>') + '</tbody></table>' +
'<p style="margin-top:10px;"><b>' + notifEsc(T('day.invoiceTotal')) + ': ' + money(o.total_amount) + ' ₽</b>' +
(o.final_amount != null ? ' · ' + notifEsc(T('day.invoicePaid')) + ': ' + money(o.final_amount) + ' ₽' : '') +
' · ' + notifEsc(T('day.invoicePayment')) + ': ' + notifEsc(payText) + '</p>' +
'<p style="margin-top:26px;">' + notifEsc(T('day.invoiceSign')) + ' ______________________</p>' +
'</section>';
}
async function dayPrintInvoices() {
if (!dayState.current) return;
var r = await apiUniversal('routes', 'view', { ws: currentWs, id: dayState.current._id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
var route = r.data || {};
var pages = (route.stops || []).map(function (s) { return dayInvoicePage(s.order || {}, route); }).join('');
var win = window.open('', '_blank');
if (!win) { toast(T('js.dayPrintBlocked'), 'err'); return; }
win.document.write('<!doctype html><html lang="' + (I18N.lang || 'ru') + '"><head><meta charset="utf-8">' +
'<title>' + notifEsc(T('day.printInvoices')) + '</title>' +
'<style>@media print{section{page-break-after:always}}' +
'body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:var(--ink-900);margin:0}' +
'th,td{border:1px solid var(--line);padding:5px 7px;text-align:left}th{background:var(--bg)}</style></head><body>' +
pages + '</body></html>');
win.document.close();
win.focus();
setTimeout(function () { try { win.print(); } catch (e) { console.warn('[day] print invoices', e); } }, 250);
}
async function dayPrintList(id) {
var r = await apiUniversal('routes', 'view', { ws: currentWs, id: id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
var win = window.open('', '_blank');
if (!win) { toast(T('js.dayPrintBlocked'), 'err'); return; }
win.document.write(daySheetHtml(r.data));
win.document.close();
win.focus();
setTimeout(function () { try { win.print(); } catch (e) { console.warn('[day] print', e); } }, 250);
}
function dayPrintRoute() {
if (dayState.current) dayPrintList(dayState.current._id);
}
var DAY_FEED_PAGE = 15;
function dayFeedRow(ev) {
var key = 'events.' + String(ev.type || '').replace(/\./g, '_');
var label = T(key);
if (label === key) label = T('events.unknown') + ': ' + (ev.type || '');
var p = ev.payload || {};
var detail = [];
if (p.order_number) detail.push(p.order_number);
if (p.route_code) detail.push(p.route_code);
if (p.courier_name) detail.push(p.courier_name);
if (p.stops != null) detail.push(p.stops + ' ' + plural(p.stops, T('plural.stops')));
return '<div style="display:flex;gap:10px;align-items:baseline;padding:7px 10px;border:1px solid var(--divider);border-radius:8px;">' +
'<span class="mono dim" style="font-size:11.5px;white-space:nowrap;">' + notifEsc(fmtDateTime(ev.at)) + '</span>' +
'<span class="strong">' + notifEsc(label) + '</span>' +
'<span class="muted" style="font-size:12.5px;">' + notifEsc(detail.join(' · ')) + '</span>' +
'</div>';
}
/**
* Лента дня: растёт по странице, а не прокручивается в маленьком окошке.
* События догружаются порциями по времени последнего показанного.
* Параметр reset заново начинает с самых свежих — при смене даты или фильтра.
*/
async function loadDayFeed(reset) {
if (!currentWs) return;
if (reset !== false) { dayState.feed = []; dayState.feedBefore = null; }
var box = document.getElementById('dayFeed');
var more = document.getElementById('dayFeedMore');
var typeEl = document.getElementById('dayFeedType');
var params = { ws: currentWs, date: dayDateValue(), limit: DAY_FEED_PAGE };
if (dayState.tz) params.timezone = dayState.tz;
if (typeEl && typeEl.value) params.type = typeEl.value;
if (dayState.feedBefore) params.before = dayState.feedBefore;
var r = await apiUniversal('dispatcher', 'feed', params, { notifyErrors: true });
if (r.code !== 0) { if (box) box.innerHTML = '<div class="muted">' + notifEsc(T('js.dayFeedLoadFailed')) + '</div>'; return; }
var items = (r.data && r.data.items) || [];
dayState.feed = dayState.feed.concat(items);
if (items.length) dayState.feedBefore = items[items.length - 1].at;
if (!box) return;
if (!dayState.feed.length) {
box.innerHTML = '<div class="muted">' + notifEsc(T('day.feedEmpty')) + '</div>';
if (more) more.style.display = 'none';
return;
}
box.innerHTML = dayState.feed.map(dayFeedRow).join('');
// Кнопка «показать ещё» — только если порция пришла целиком.
if (more) more.style.display = items.length >= DAY_FEED_PAGE ? 'block' : 'none';
}
// ---------- Карта дня ----------
// Листовка подключается локально, подложка — из window.__MAP__ (env сервера).
// Тайлы не грузятся с чужих серверов: если подложка не настроена, карта
// показывает только свои слои — точки, курьеров и зоны.
function initDayMap() {
var conf = window.__MAP__ || {};
var el = document.getElementById('dayMap');
if (!el || !window.L) return;
if (!dayState.map) {
var center = (conf.center && conf.center.length === 2) ? conf.center : [55.7558, 37.6173];
// Свой контрол подписи: в стандартном префиксе Leaflet рисует флаг,
// которого на нашей карте быть не должно. Ссылка на библиотеку и
// подпись источника остаются.
dayState.map = L.map(el, { zoomControl: true, attributionControl: false });
dayState.map.setView(center, conf.zoom || 11);
if (conf.tilesUrl) {
L.tileLayer(conf.tilesUrl, { maxZoom: 18, attribution: conf.attribution || '' }).addTo(dayState.map);
L.control.attribution({
prefix: '<a href="https://leafletjs.com" title="Leaflet">Leaflet</a>',
position: 'bottomright',
}).addTo(dayState.map);
}
dayState.mapLayers = {
orders: L.layerGroup().addTo(dayState.map),
routes: L.layerGroup().addTo(dayState.map),
couriers: L.layerGroup().addTo(dayState.map),
zones: L.layerGroup().addTo(dayState.map),
warehouses: L.layerGroup().addTo(dayState.map),
};
dayState.map.on('moveend', scheduleMapReload);
dayState.map.on('zoomend', scheduleMapReload);
} else {
// Раздел открыли повторно: контейнер снова видим, размер пересчитать.
dayState.map.invalidateSize();
}
// Кнопки рисования всегда в исходном состоянии: видна только «Добавить зону».
mapZoneButtons(!!dayState.drawZone);
loadMapData();
}
var mapReloadTimer = null;
function scheduleMapReload() {
clearTimeout(mapReloadTimer);
mapReloadTimer = setTimeout(loadMapData, 350);
}
function mapHintText(text) {
var el = document.getElementById('dayMapHint');
if (el) el.textContent = text;
}
async function loadMapData() {
if (!dayState.map || !currentWs) return;
var b = dayState.map.getBounds();
var r = await apiUniversal('dispatcher', 'map', {
ws: currentWs,
// Рамка видимой области: сервер отбирает точки, клиент не фильтрует.
bbox: { minLat: b.getSouth(), maxLat: b.getNorth(), minLng: b.getWest(), maxLng: b.getEast() },
// Маршруты рисуются за выбранный день, а не «сегодня по UTC».
date: dayDateValue(),
timezone: dayState.tz || undefined,
limit: 800,
});
if (r.code !== 0) { mapHintText(T('day.mapError') + ': ' + reasonText(reasonOf(r))); return; }
drawMap(r.data || {});
}
/**
* Кнопка «Открыть» для попапа карты: объект открывается той же карточкой,
* что и двойной клик в списке. Без строковых аргументов в onclick — только
* data-атрибуты, иначе разметка ломает скрипт кавычками.
*/
function mapOpenButton(objectKind, objectId) {
if (!objectId) return '';
return '<button class="btn btn-ghost btn-sm" data-map-kind="' + objectKind
+ '" data-map-id="' + notifEsc(objectId) + '" onclick="mapOpen(this)">'
+ T('js.open') + '</button>';
}
function mapOpen(button) {
var objectKind = button.getAttribute('data-map-kind');
var objectId = button.getAttribute('data-map-id');
if (objectKind === 'order') openOrderView(objectId);
else if (objectKind === 'courier') openCourierEdit(objectId);
else if (objectKind === 'warehouse') openEntity('warehouse', objectId);
else if (objectKind === 'dayRoute') openDayRoute(objectId);
}
function drawMap(data) {
var m = dayState.mapLayers;
if (!m) return;
m.orders.clearLayers();
m.routes.clearLayers();
m.couriers.clearLayers();
m.zones.clearLayers();
m.warehouses.clearLayers();
(data.warehouses || []).forEach(function (w) {
if (!w.geo) return;
L.marker([Number(w.geo.lat), Number(w.geo.lng)], {
icon: L.divIcon({ className: '', html: '<span class="map-warehouse-icon">🏭</span>', iconSize: [26, 26], iconAnchor: [13, 13] }),
// Склад — подложка дня: он не должен перехватывать клик у курьера,
// который стоит на его же точке.
zIndexOffset: -200,
})
.bindPopup('<b>' + notifEsc(w.name || '') + '</b><br>' + mapOpenButton('warehouse', w._id))
.addTo(m.warehouses);
});
(data.zones || []).forEach(function (z) {
var ring = (z.polygon && z.polygon.coordinates && z.polygon.coordinates[0]) || [];
var latlngs = ring.map(function (p) { return [Number(p[1]), Number(p[0])]; });
if (latlngs.length < 3) return;
L.polygon(latlngs, { color: z.color || themeColor('--primary'), weight: 2, fillOpacity: 0.14 })
.bindTooltip(z.name || '')
.addTo(m.zones);
});
// Маршруты — линией по порядку остановок: диспетчер видит рейс, а не
// набор точек. Цвет — состояние плана, не оформление.
(data.routes || []).forEach(function (r) {
var latlngs = (r.points || []).filter(function (p) { return p && p.geo; })
.map(function (p) { return [Number(p.geo.lat), Number(p.geo.lng)]; });
if (latlngs.length < 2) return;
L.polyline(latlngs, {
color: r.plan_state === 'confirmed' || r.plan_state === 'locked'
? themeColor('--success')
: (r.plan_state === 'planned' ? themeColor('--primary') : themeColor('--muted')),
weight: 3,
opacity: 0.75,
})
.bindPopup('<b>' + notifEsc(r.code || '') + '</b><br>' + mapOpenButton('dayRoute', r._id))
.addTo(m.routes);
});
(data.orders || []).forEach(function (o) {
if (!o.geo) return;
var body = notifEsc(o.address || '');
L.marker([Number(o.geo.lat), Number(o.geo.lng)], {
icon: L.divIcon({ className: 'map-dot', html: '<span></span>', iconSize: [14, 14], iconAnchor: [7, 7] }),
})
.bindPopup('<b>' + notifEsc(o.order_number || '') + '</b>' + (body ? '<br>' + body : '') + '<br>' + mapOpenButton('order', o._id))
.addTo(m.orders);
});
(data.couriers || []).forEach(function (c) {
L.marker([Number(c.geo.lat), Number(c.geo.lng)], {
icon: L.divIcon({ className: '', html: '<span class="map-courier-icon">🚗</span>', iconSize: [26, 26], iconAnchor: [13, 13] }),
// Курьер выше склада: на общей точке клик должен открывать курьера.
zIndexOffset: 200,
})
.bindPopup('<b>' + notifEsc(c.name || '') + '</b><br>' + mapOpenButton('courier', c._id))
.addTo(m.couriers);
});
mapToggleLayers();
fitMapTo(data);
var parts = [
T('day.mapOrders') + ': ' + (data.orders || []).length,
T('day.mapRoutes') + ': ' + (data.routes || []).length,
T('day.mapCouriers') + ': ' + (data.couriers || []).length,
];
if ((data.warehouses || []).length) parts.push(T('day.mapWarehouses') + ': ' + data.warehouses.length);
if (data.limited) parts.push(T('day.mapLimited'));
if (!window.__MAP__ || !window.__MAP__.tilesUrl) parts.push(T('day.mapNoTiles'));
mapHintText(parts.join(' · '));
}
// Первое появление точек: вписываем карту в заказы и курьеров, чтобы не
// искать город руками. Дальше рамку не трогаем — диспетчер двигает сам.
function fitMapTo(data) {
if (dayState.mapFitted || !dayState.map) return;
var pts = [];
(data.orders || []).forEach(function (o) { if (o.geo) pts.push([o.geo.lat, o.geo.lng]); });
(data.couriers || []).forEach(function (c) { if (c.geo) pts.push([c.geo.lat, c.geo.lng]); });
if (pts.length < 1) return;
dayState.mapFitted = true;
if (pts.length === 1) dayState.map.setView(pts[0], 14);
else dayState.map.fitBounds(pts, { padding: [30, 30], maxZoom: 15 });
}
// ---------- Рисование зоны ----------
function mapZoneButtons(drawing) {
var start = document.getElementById('mapZoneStart');
var done = document.getElementById('mapZoneDone');
var cancel = document.getElementById('mapZoneCancel');
if (start) start.style.display = drawing ? 'none' : 'inline-flex';
if (done) done.style.display = drawing ? 'inline-flex' : 'none';
if (cancel) cancel.style.display = drawing ? 'inline-flex' : 'none';
}
function mapStartZone() {
if (!dayState.map) return;
if (dayState.drawZone) { mapCancelZone(); return; }
dayState.drawZone = { points: [], markers: [], shape: null };
dayState.map.getContainer().style.cursor = 'crosshair';
dayState.map.on('click', mapZoneClick);
mapZoneButtons(true);
mapHintText(T('day.mapDrawHint'));
}
function mapZoneRedraw() {
var dz = dayState.drawZone;
if (!dz || !dayState.map) return;
if (dz.shape) { dayState.map.removeLayer(dz.shape); dz.shape = null; }
if (dz.points.length >= 2) {
dz.shape = L.polygon(dz.points, { color: themeColor('--success'), weight: 2, dashArray: '5,5', fillOpacity: 0.05 }).addTo(dayState.map);
}
}
function mapZoneClick(e) {
var dz = dayState.drawZone;
if (!dz) return;
dz.points.push([e.latlng.lat, e.latlng.lng]);
dz.markers.push(L.circleMarker(e.latlng, { radius: 5, color: themeColor('--success'), fillOpacity: 1 }).addTo(dayState.map));
mapZoneRedraw();
mapHintText(T('day.mapDrawPoints') + ': ' + dz.points.length);
}
async function mapFinishZone() {
var dz = dayState.drawZone;
if (!dz || dz.points.length < 3) { toast(T('day.mapZoneFew'), 'err'); return; }
var name = await dsPrompt(T('day.mapZoneName'), '');
if (!name) return;
var ring = dz.points.map(function (p) { return { lat: p[0], lng: p[1] }; });
var r = await apiUniversal('zones', 'create', { ws: currentWs, name: name, polygon: { ring: ring } });
if (r.code !== 0) { showApiError(r); return; }
toast(T('common.daySaved'), 'ok');
mapCancelZone();
loadMapData();
}
function mapCancelZone() {
var dz = dayState.drawZone;
if (!dz) return;
if (dayState.map) {
dayState.map.off('click', mapZoneClick);
dayState.map.getContainer().style.cursor = '';
dz.markers.forEach(function (m) { dayState.map.removeLayer(m); });
if (dz.shape) dayState.map.removeLayer(dz.shape);
}
dayState.drawZone = null;
mapZoneButtons(false);
mapHintText('');
}
function setMapLayer(group, visible) {
if (!dayState.map || !group) return;
var has = dayState.map.hasLayer(group);
if (visible && !has) group.addTo(dayState.map);
if (!visible && has) dayState.map.removeLayer(group);
}
function mapToggleLayers() {
var m = dayState.mapLayers;
if (!m) return;
var checked = function (id) { var el = document.getElementById(id); return !el || el.checked; };
setMapLayer(m.orders, checked('mapShowOrders'));
setMapLayer(m.routes, checked('mapShowRoutes'));
setMapLayer(m.couriers, checked('mapShowCouriers'));
setMapLayer(m.zones, checked('mapShowZones'));
setMapLayer(m.warehouses, checked('mapShowWarehouses'));
}
/** Доска курьеров: смена, машина, загрузка, свободен ли. */
async function loadDayBoard() {
if (!currentWs) return;
var params = { ws: currentWs, date: dayDateValue() };
if (dayState.tz) params.timezone = dayState.tz;
var r = await apiUniversal('dispatcher', 'board', params, { notifyErrors: true });
if (r.code !== 0) return;
var couriers = (r.data && r.data.couriers) || [];
var body = document.getElementById('dayBoardBody');
var cards = document.getElementById('dayBoardCards');
if (!body) return;
function stateChip(c) {
if (!c.on_shift) return '<span class="chip cancel">' + notifEsc(T('day.boardOffShift')) + '</span>';
if (c.route) return '<span class="chip route">' + notifEsc(T('day.boardBusy')) + '</span>';
return '<span class="chip open">' + notifEsc(T('day.boardFree')) + '</span>';
}
function loadText(c) {
if (!c.load || !c.load.stops) return '<span class="dim">—</span>';
var parts = [c.load.stops + ' ' + plural(c.load.stops, T('plural.stops'))];
if (c.load.weight_kg) parts.push(c.load.weight_kg + ' ' + T('day.kg'));
return notifEsc(parts.join(' · '));
}
function actions(c) {
var act = '';
if (!c.on_shift) act += '<button class="icon-btn ok" title="' + notifEsc(T('day.shiftOpen')) + '" data-id="' + c._id + '" onclick="openShiftFor(this.getAttribute(\'data-id\'))">▶️</button>';
return act;
}
body.innerHTML = couriers.map(function (c) {
return '<tr>' +
'<td class="strong">' + notifEsc(c.name) + '</td>' +
'<td>' + (c.vehicle ? notifEsc(c.vehicle.number) + ' <span class="dim">(' + notifEsc(T('couriers.transport' + (c.vehicle.type || 'car').charAt(0).toUpperCase() + (c.vehicle.type || 'car').slice(1))) + ')</span>' : '<span class="dim">—</span>') + '</td>' +
'<td>' + (c.on_shift ? '<span class="chip open">' + notifEsc(T('day.shiftIsOpen')) + '</span>' : '<span class="dim">' + notifEsc(T('day.shiftClosed')) + '</span>') + '</td>' +
'<td class="strong">' + (c.load ? c.load.stops : 0) + '</td>' +
'<td>' + loadText(c) + '</td>' +
'<td>' + stateChip(c) + '</td>' +
'<td class="row-actions">' + actions(c) + '</td>' +
'</tr>';
}).join('') || '<tr><td colspan="7" class="muted">' + notifEsc(T('couriers.empty')) + '</td></tr>';
cards.innerHTML = couriers.map(function (c) {
return '<div class="mcard"><div class="h"><span class="strong">' + notifEsc(c.name) + '</span>' + stateChip(c) + '</div>' +
'<div class="kv"><span class="k">' + notifEsc(T('day.boardVehicle')) + '</span><span class="v">' + (c.vehicle ? notifEsc(c.vehicle.number) : '—') + '</span></div>' +
'<div class="kv"><span class="k">' + notifEsc(T('day.boardLoad')) + '</span><span class="v">' + loadText(c) + '</span></div>' +
'<div style="display:flex;justify-content:flex-end;gap:6px;">' + actions(c) + '</div>' +
'</div>';
}).join('');
}
async function openShiftFor(courierId) {
var r = await apiUniversal('shifts', 'open', { ws: currentWs, courier_id: courierId }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
loadDayBoard();
loadDay();
}
async function openVehicleModal() {
var couriersList2 = await apiUniversal('couriers', 'list', { ws: currentWs }, { notifyErrors: true });
var sel = document.getElementById('vehCourier');
if (sel) {
sel.innerHTML = '<option value="">' + notifEsc(T('day.noCourier')) + '</option>' +
((couriersList2.data && couriersList2.data.items) || []).map(function (c) {
return '<option value="' + c._id + '">' + notifEsc(c.name) + '</option>';
}).join('');
}
openModal('vehicleModal');
}
async function createVehicle() {
var data = {
number: (document.getElementById('vehNumber') || {}).value || '',
type: (document.getElementById('vehType') || {}).value || 'car',
capacity_weight_kg: Number((document.getElementById('vehWeight') || {}).value || 0),
capacity_volume_m3: Number((document.getElementById('vehVolume') || {}).value || 0),
courier_id: (document.getElementById('vehCourier') || {}).value || null,
};
var r = await apiUniversal('vehicles', 'create', { ws: currentWs, data: data });
if (r.code !== 0) { showApiError(r); return; }
closeModal('vehicleModal');
toast(T('common.daySaved'), 'ok');
loadDayBoard();
loadDay();
}
/** Время прибытия остановки из расчёта маршрута. */
function dayRouteLeg(orderId) {
var r = dayState.current;
if (!r || !Array.isArray(r.legs)) return null;
for (var i = 0; i < r.legs.length; i++) {
if (r.legs[i].order_id === orderId) return r.legs[i].arrival || null;
}
return null;
}
/**
* Автоматическое планирование дня: свободный пул распределяется по
* курьерам на смене, порядок остановок считается на сервере.
*/
async function planDay() {
if (!currentWs) return;
var ok = await dsConfirm(T('day.autoPlanConfirm'), T('day.autoPlan'));
if (!ok) return;
var r = await apiUniversal('routes', 'plan', { ws: currentWs, date: dayDateValue() });
if (r.code !== 0) { showApiError(r); return; }
var d = r.data || {};
var lines = [
T('day.autoPlanAssigned') + ': ' + (d.assigned || 0),
T('day.autoPlanRoutes') + ': ' + (d.routes || 0),
];
if (d.skipped_no_geo) lines.push(T('day.autoPlanNoGeo') + ': ' + d.skipped_no_geo);
var rejected = d.rejected || [];
if (rejected.length) {
var reasons = {};
rejected.forEach(function (x) { reasons[x.reason] = (reasons[x.reason] || 0) + 1; });
lines.push(T('day.autoPlanRejected') + ': ' + Object.keys(reasons).map(function (k) {
return T('day.reason_' + k) + ' — ' + reasons[k];
}).join(', '));
}
dsAlert(lines.join('\n'), T('day.autoPlan'));
loadDay();
}
async function dayRecalcRoute() {
if (!dayState.current) return;
var r = await apiUniversal('routes', 'recalculate', { ws: currentWs, id: dayState.current._id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
toast(T('common.daySaved'), 'ok');
await openDayRoute(dayState.current._id);
loadDayRoutes();
}
function openRouteModal() {
openEntity('route');
}
var currentInviteId = null;
async function openInviteView(el) {
var id = el.getAttribute('data-id');
var r = await apiUniversal('invites', 'view', { ws: currentWs, id: id });
if (r.code !== 0 || !r.data) { showApiError(r); return; }
var d = r.data;
currentInviteId = id;
var gmap = {};
(d.available_groups || []).forEach(function (g) { gmap[g._id] = g.name; });
var names = (d.groups || []).map(function (gid) { return gmap[gid] || gid; }).join(', ') || '—';
var joined = d.accepted_member ? String(T('js.inviteJoined')).replace('{name}', (d.accepted_member.name || d.accepted_member.email || '—')) : T('js.inviteNobody');
var used = d.closed ? '<span class="chip cancel">' + notifEsc(T('js.inviteUsed')) + '</span>' : '<span class="chip open">' + notifEsc(T('js.inviteActive')) + '</span>';
document.getElementById('inviteViewTitle').innerText = T('js.inviteTitle') + ': ' + (d.name || '');
document.getElementById('inviteViewBody').innerHTML =
'<div style="display:flex;gap:8px;align-items:center;">' + used + '</div>' +
'<div><div class="muted" style="font-size:12px;">' + notifEsc(T('js.inviteLinkLabel')) + '</div><div class="mono" style="word-break:break-all;">' + (d.url || '') + '</div></div>' +
'<div><div class="muted" style="font-size:12px;">' + notifEsc(T('js.groupsLabel')) + '</div><div>' + names + '</div></div>' +
'<div><div class="muted" style="font-size:12px;">' + notifEsc(T('js.memberLabel')) + '</div><div>' + joined + '</div></div>';
document.getElementById('inviteViewDelete').style.display = d.accepted_by ? 'none' : 'inline-flex';
openModal('inviteViewModal');
}
async function removeInviteFromView() {
if (!currentInviteId) return;
if (!(await dsConfirm(T('js.removeInviteConfirm')))) return;
var r = await apiUniversal('invites', 'remove', { ws: currentWs, id: currentInviteId });
if (r.code === 0) { closeModal('inviteViewModal'); toast(T('js.inviteRemoved'), 'ok'); loadInvites(); }
else showApiError(r);
}
async function loadIntegrations() {
if (!currentWs) return;
var j = await apiUniversal('external_systems', 'list', {
ws: currentWs,
limit: LIST_PAGE,
skip: listSkip('integrations'),
}, { notifyErrors: true });
var items = (j.data && j.data.items) || [];
integrationsList = items;
integrationEvents = (j.data && j.data.available_events) || integrationEvents;
renderPager('integrations', items.length, (j.data && j.data.total) || items.length);
listRender('integrations', items);
var lj = await apiUniversal('external_systems', 'logs', { ws: currentWs, limit: 25 }, { notifyErrors: true });
var logs = (lj.data && lj.data.items) || [];
var box = document.getElementById('webhookLogs');
box.innerHTML = logs.length ? logs.map(function (l) {
var okChip = l.ok ? '<span class="chip delivered">OK</span>' : '<span class="chip cancel">' + (l.status || T('js.errorWord')) + '</span>';
var t = (l.created_at || '').slice(11, 19);
var attempt = l.attempt && l.attempt > 1 ? ' <span class="dim">#' + l.attempt + '</span>' : '';
return '<div style="display:flex;gap:10px;align-items:center;font-size:12.5px;">' + okChip +
'<span class="mono">' + (l.event || '') + '</span>' + attempt + '<span class="muted">' + t + '</span>' +
'<span class="mono" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;">' + (l.system_name || '') + '</span>' +
(l.ok ? '' : '<button class="icon-btn" title="' + notifEsc(T('integrationModal.retry')) + '" data-id="' + l._id + '" onclick="retryWebhook(this.dataset.id)">↻</button>') +
'</div>';
}).join('') : '<div class="muted">' + notifEsc(T('js.logsEmpty')) + '</div>';
}
var integrationEvents = [];
/** Чекбоксы событий: подписка на статусы заказа. */
async function renderIntegrationEvents(selected) {
var box = document.getElementById('esEvents');
if (!box) return;
if (!integrationEvents.length) {
var r = await apiUniversal('external_systems', 'list', { ws: currentWs });
integrationEvents = (r.data && r.data.available_events) || [];
}
var picked = selected && selected.length ? selected : ['*'];
var all = picked.indexOf('*') !== -1;
box.innerHTML = '<label style="font-size:12.5px;"><input type="checkbox" id="esEventAll"' + (all ? ' checked' : '') + ' onchange="toggleAllEvents(this)"> ' + notifEsc(T('integrationModal.allEvents')) + '</label>' +
integrationEvents.map(function (code) {
return '<label style="font-size:12.5px;"><input type="checkbox" class="es-event" value="' + notifEsc(code) + '"' + (!all && picked.indexOf(code) !== -1 ? ' checked' : '') + ' onchange="syncAllEvents()"> ' + notifEsc(T('events.' + code.replace(/\./g, '_'))) + '</label>';
}).join('');
syncAllEvents();
}
function syncAllEvents() {
var all = document.getElementById('esEventAll');
var boxes = [].slice.call(document.querySelectorAll('.es-event'));
if (!all) return;
// «Все события» — отдельный режим: пока он включён, галочки событий
// не перебивают его состояние.
if (all.checked) return;
var checked = boxes.filter(function (b) { return b.checked; });
all.checked = checked.length === boxes.length;
}
function toggleAllEvents(el) {
[].slice.call(document.querySelectorAll('.es-event')).forEach(function (b) { b.checked = el.checked; });
}
function selectedEvents() {
var all = document.getElementById('esEventAll');
if (all && all.checked) return ['*'];
var picked = [].slice.call(document.querySelectorAll('.es-event'))
.filter(function (b) { return b.checked; })
.map(function (b) { return b.value; });
return picked.length ? picked : ['*'];
}
/** Кодовая фраза: 32 байта случайности в hex — её знают обе стороны. */
function generateIntegrationSecret() {
var bytes = new Uint8Array(24);
(window.crypto || window.msCrypto).getRandomValues(bytes);
var hex = [].slice.call(bytes).map(function (b) { return ('0' + b.toString(16)).slice(-2); }).join('');
document.getElementById('esSecret').value = hex;
}
/** Ручной повтор недоставленного уведомления из журнала. */
async function retryWebhook(logId) {
var r = await apiUniversal('external_systems', 'retryLog', { ws: currentWs, log_id: logId }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
if (r.data && r.data.ok) toast(T('integrationModal.retryOk'), 'ok');
else toast(T('integrationModal.retryFailed'), 'err');
loadIntegrations();
}
/** Карточка системы: одно место для адреса, токена, фразы и настроек. */
/** Кнопка в строке списка передаёт элемент — берём из него id. */
function openIntegrationCardById(el) {
openIntegrationCard(el.getAttribute('data-id'));
}
async function openIntegrationCard(id) {
if (integrationEvents.length === 0) {
var r = await apiUniversal('external_systems', 'list', { ws: currentWs, limit: 1 });
integrationEvents = (r.data && r.data.available_events) || [];
}
await openEntity('integration', id);
}
/** Пример отправки: тело, заголовки и проверка подписи. */
function renderIntegrationExample(system) {
var data = system || {};
var box = document.getElementById('esExample');
if (!box) return;
// Перевод строки через fromCharCode: шаблон сервера съедает экранирование.
var NL = String.fromCharCode(10);
var body = '{"event":"order.handed_over","timestamp":"...","order_number":"A-1042","status":"delivered"}';
box.textContent = [
'# ' + T('integrationModal.exampleHeaders'),
'POST ' + (data.url || 'https://example.ru/hook'),
'x-event: order.handed_over',
'x-system-id: ' + (data._id || '...'),
'x-signature: sha256=<hmac_sha256(body, secret)>',
'',
body,
'',
'# ' + T('integrationModal.exampleCheck'),
"const crypto = require('crypto');",
"const expected = 'sha256=' + crypto.createHmac('sha256', process.env.SECRET).update(rawBody).digest('hex');",
'if (expected !== req.headers["x-signature"]) return res.status(401).end();',
].join(NL);
}
/** Итог выпуска ключа: ключ и кодовая фраза показываются один раз. */
function showIssuedKey(key, secret) {
var box = document.getElementById('issuedKey');
if (box) box.textContent = key || '';
var secretBox = document.getElementById('issuedSecret');
var secretRow = document.getElementById('issuedSecretRow');
if (secret) {
if (secretBox) secretBox.textContent = secret;
if (secretRow) secretRow.style.display = 'block';
} else if (secretRow) {
secretRow.style.display = 'none';
}
openModal('issuedKeyModal');
}
function copyIssuedKey() {
var value = (document.getElementById('issuedKey') || {}).textContent || '';
if (!value) return;
if (navigator.clipboard) navigator.clipboard.writeText(value).then(function () { toast(T('common.copied'), 'ok'); }).catch(function () {});
}
/** Выпуск ключа: до подтверждения ключа нет, после — показывается один раз. */
async function rotateIntegrationToken() {
var id = document.getElementById('esEditId').value;
if (!id) { toast(T('integrationModal.tokenAfterSave'), 'err'); return; }
var ok = await dsConfirm(T('integrationModal.rotateConfirm'), T('integrationModal.issue'));
if (!ok) return;
var r = await apiUniversal('external_systems', 'rotateToken', { ws: currentWs, id: id }, { notifyErrors: true });
if (r.code !== 0) { showApiError(r); return; }
showIssuedKey((r.data && r.data.api_key) || '');
loadIntegrations();
}
/** Перерисовать пример по текущим полям карточки. */
function refreshIntegrationExample() {
renderIntegrationExample({
_id: document.getElementById('esEditId').value,
url: document.getElementById('esUrl').value.trim(),
});
}
/**
* Двойной клик по строке или карточке открывает запись — правило
* дизайн-системы. Один обработчик на все списки: кнопки внутри строки
* жест не перехватывают, иначе действие кнопки срабатывало бы дважды.
*/
var ROW_OPEN = {
integration: function (row) { openIntegrationCard(row.getAttribute('data-id')); },
order: function (row) { openOrderView(row.getAttribute('data-id')); },
courier: function (row) { openCourierEdit(row.getAttribute('data-id')); },
member: function (row) { openMemberEdit(row.getAttribute('data-id')); },
group: function (row) { openGroupEdit(row.getAttribute('data-id')); },
invite: function (row) { openInviteView(row); },
join: function (row) { openFormEdit(row.getAttribute('data-id')); },
route: function (row) { viewRoute(row); },
dayRoute: function (row) { openDayRoute(row.getAttribute('data-id')); },
warehouse: function (row) { openEntity('warehouse', row.getAttribute('data-id')); },
settlement: function (row) { openCourierSettlement(row.getAttribute('data-id')); },
payout: function (row) { openPayoutView(row.getAttribute('data-id')); },
};
/** Цвет из темы: карта берёт оттенки так же, как интерфейс. */
function themeColor(name) {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || '#1c6dd0';
}
function rowOpen(event, row) {
var target = event.target;
if (target && (target.closest('button') || target.closest('a') || target.closest('input') || target.closest('select') || target.closest('label'))) return;
var open = ROW_OPEN[row.getAttribute('data-kind')];
if (open) open(row);
}
async function testIntegration(el) {
var r = await apiUniversal('external_systems', 'test', { ws: currentWs, id: el.getAttribute('data-id') });
if (r.code === 0 && r.data) {
toast(r.data.ok
? String(T('js.sentWithCode')).replace('{code}', r.data.status)
: String(T('js.failedWith')).replace('{detail}', r.data.error || r.data.status), r.data.ok ? 'ok' : 'err');
} else showApiError(r);
loadIntegrations();
}
async function toggleIntegration(el) {
var enabled = el.getAttribute('data-enabled') === '1';
await apiUniversal('external_systems', 'edit', { ws: currentWs, id: el.getAttribute('data-id'), body: { enabled: !enabled } });
loadIntegrations();
}
async function removeIntegration(el) {
if (!(await dsConfirm(T('js.removeIntegrationConfirm')))) return;
await apiUniversal('external_systems', 'remove', { ws: currentWs, id: el.getAttribute('data-id') });
loadIntegrations();
}
async function loadDashboard() {
if (!currentWs) return;
try {
var q = await apiUniversal('orders', 'list', { ws: currentWs, filter: { mode: 'queue' }, limit: 1 }, { notifyErrors: true });
var p = await apiUniversal('orders', 'list', { ws: currentWs, filter: { mode: 'pool' }, limit: 1 }, { notifyErrors: true });
var d = await apiUniversal('orders', 'list', { ws: currentWs, filter: { status: 'delivered' }, limit: 1 }, { notifyErrors: true });
var c = await apiUniversal('couriers', 'list', { ws: currentWs }, { notifyErrors: true });
var online = ((c.data && c.data.items) || []).filter(function (x) { return x.online; }).length;
document.getElementById('dbQueue').innerText = (q.data && q.data.total) || 0;
document.getElementById('dbPool').innerText = (p.data && p.data.total) || 0;
document.getElementById('dbOnline').innerText = online;
document.getElementById('dbDelivered').innerText = (d.data && d.data.total) || 0;
} catch (e) {
console.warn('[dashboard]', e);
showErrorOnce(T('js.dashboardFailed'));
}
}
async function loadProfile() {
var body = document.getElementById('profileBody');
try {
var r = await apiUniversal('auth', 'getProfile', {}, { notifyErrors: true });
var d = r.data || {};
if (!d.id && !d._id) { body.innerText = T('js.profileUnavailable'); return; }
var ws = d.workspaces || [];
var rows = ws.map(function (w) {
var more = d.ws && d.ws[w.ws];
var admin = more && more.admin ? T('js.profileAdmin') : T('js.profileMember');
return '<li><b>' + (w.name || w.ws) + '</b> — ' + admin + '</li>';
}).join('');
body.innerHTML = '<div class="strong" style="font-size:16px;">' + notifEsc(d.name || T('js.profileUser')) + '</div>' +
'<div class="muted">' + (d.email || '') + '</div>' +
'<div style="margin-top:10px;">' + (rows ? '<ul style="padding-left:18px;">' + rows + '</ul>' : '<span class="muted">' + notifEsc(T('js.profileNoWorkspaces')) + '</span>') + '</div>';
} catch (e) {
console.warn('[profile]', e);
body.innerText = T('js.profileLoadFailed');
}
}
function setVal(id, v) {
var el = document.getElementById(id);
if (el) el.value = (v === undefined || v === null) ? '' : v;
}
function val(id) {
var el = document.getElementById(id);
return el && el.value !== '' ? Number(el.value) : undefined;
}
// ---------- Отчёты ----------
var reportState = { data: null };
function reportDateValue() {
var el = document.getElementById('reportDate');
if (el && el.value) return el.value;
var iso = new Date().toISOString().slice(0, 10);
if (el) el.value = iso;
return iso;
}
function reportDateToValue() {
var el = document.getElementById('reportDateTo');
if (el && el.value) return el.value;
var iso = reportDateValue();
if (el) el.value = iso;
return iso;
}
function reportShift(delta) {
var from = document.getElementById('reportDate');
var to = document.getElementById('reportDateTo');
var base = from.value ? new Date(from.value + 'T00:00:00') : new Date();
var baseTo = to.value ? new Date(to.value + 'T00:00:00') : new Date();
base.setDate(base.getDate() + delta);
baseTo.setDate(baseTo.getDate() + delta);
from.value = base.toISOString().slice(0, 10);
to.value = baseTo.toISOString().slice(0, 10);
loadReport();
}
async function loadReport() {
if (!currentWs) return;
var params = { ws: currentWs, from: reportDateValue(), to: reportDateToValue() };
var r = await apiUniversal('dispatcher', 'report', params, { notifyErrors: true });
if (r.code !== 0) return;
reportState.data = r.data || {};
renderReport(reportState.data);
}
function reportKpi(icon, caption, value, hint, cls) {
return '<div class="kpi"><div class="cap">' + icon + ' ' + notifEsc(caption) + '</div>' +
'<div class="val' + (cls ? ' ' + cls : '') + '">' + value + '</div>' +
'<div class="sub">' + notifEsc(hint) + '</div></div>';
}
function renderReport(d) {
var box = document.getElementById('reportKpis');
if (box) {
var o = d.orders || {};
var m = d.money || {};
var rt = d.routes || {};
box.innerHTML =
reportKpi('✅', T('report.delivered'), o.delivered || 0, T('report.deliveredHint'), 'green') +
reportKpi('📦', T('report.partially'), o.partially_delivered || 0, T('report.partiallyHint')) +
reportKpi('↩️', T('report.cancelled'), o.cancelled || 0, T('report.cancelledHint')) +
reportKpi('🚚', T('report.routes'), (rt.total || 0) + ' / ' + (rt.stops || 0), T('report.routesHint')) +
reportKpi('🛣', T('report.distance'), num(Math.round((rt.distance_m || 0) / 100) / 10) + ' ' + T('day.km'), T('report.distanceHint')) +
reportKpi('💰', T('report.payout'), money(m.payout) + ' ₽', T('report.payoutHint')) +
reportKpi('💵', T('report.cash'), money(m.cash) + ' ₽', T('report.cashHint')) +
reportKpi('💳', T('report.card'), money(m.card) + ' ₽', T('report.cardHint'));
}
var chart = document.getElementById('reportChart');
if (chart) chart.innerHTML = reportChartSvg(d.series);
var rows = d.couriers || [];
var body = document.getElementById('reportBody');
var cards = document.getElementById('reportCards');
if (body) {
body.innerHTML = rows.length ? rows.map(function (c) {
return '<tr>' +
'<td class="strong">' + notifEsc(c.name || T('day.noCourier')) + '</td>' +
'<td>' + (c.delivered || 0) + '</td>' +
'<td>' + (c.partially || 0) + '</td>' +
'<td>' + (c.cancelled || 0) + '</td>' +
'<td>' + (c.stops || 0) + '</td>' +
'<td>' + num(Math.round((c.distance_m || 0) / 100) / 10) + ' ' + notifEsc(T('day.km')) + '</td>' +
'<td class="strong">' + money(c.payout) + ' ₽</td>' +
'</tr>';
}).join('') : '<tr><td colspan="7" class="muted">' + notifEsc(T('report.empty')) + '</td></tr>';
}
if (cards) {
cards.innerHTML = rows.map(function (c) {
return '<div class="mcard"><div class="h"><span class="strong">' + notifEsc(c.name || T('day.noCourier')) + '</span><span class="chip delivered">' + (c.delivered || 0) + '</span></div>' +
'<div class="kv"><span class="k">' + notifEsc(T('report.thPayout')) + '</span><span class="v strong">' + money(c.payout) + ' ₽</span></div>' +
'<div class="kv"><span class="k">' + notifEsc(T('report.thDistance')) + '</span><span class="v">' + num(Math.round((c.distance_m || 0) / 100) / 10) + ' ' + notifEsc(T('day.km')) + '</span></div>' +
'</div>';
}).join('');
}
var vio = document.getElementById('reportViolations');
if (vio) {
var list = d.violations || [];
vio.innerHTML = list.length ? list.map(function (v) {
return '<span class="chip cancel">' + notifEsc(dayViolationLabel(v)) + ': ' + v.count + '</span>';
}).join('') : '<span class="chip delivered">✅ ' + notifEsc(T('report.noViolations')) + '</span>';
}
}
/**
* Динамика по дням: столбики доставок и отказов плюс линия выплат.
* Рисуется плоским SVG без библиотек — печатается и не тянет ничего.
*/
function reportChartSvg(series) {
var rows = series || [];
if (!rows.length) return '<div class="muted" style="padding:14px;">' + notifEsc(T('report.empty')) + '</div>';
var W = Math.max(560, rows.length * 34);
var H = 220;
var padL = 34;
var padB = 26;
var padT = 12;
var innerW = W - padL - 8;
var innerH = H - padB - padT;
var maxD = Math.max(1, Math.max.apply(null, rows.map(function (r) { return Math.max(r.delivered || 0, r.cancelled || 0); })));
var maxP = Math.max(1, Math.max.apply(null, rows.map(function (r) { return r.payout || 0; })));
var step = innerW / rows.length;
var barW = Math.min(14, step * 0.34);
var bars = rows.map(function (r, i) {
var x = padL + step * i + step / 2;
var hd = ((r.delivered || 0) / maxD) * innerH;
var hc = ((r.cancelled || 0) / maxD) * innerH;
var out = '<rect x="' + (x - barW - 1).toFixed(1) + '" y="' + (padT + innerH - hd).toFixed(1) + '" width="' + barW + '" height="' + hd.toFixed(1) + '" rx="2" fill="#2f6fed"></rect>';
if (hc > 0) out += '<rect x="' + (x + 1).toFixed(1) + '" y="' + (padT + innerH - hc).toFixed(1) + '" width="' + barW + '" height="' + hc.toFixed(1) + '" rx="2" fill="#ef4444"></rect>';
return out;
}).join('');
var points = rows.map(function (r, i) {
var x = padL + step * i + step / 2;
var y = padT + innerH - ((r.payout || 0) / maxP) * innerH;
return x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
var line = '<polyline points="' + points + '" fill="none" stroke="' + themeColor('--success') + '" stroke-width="2" stroke-linejoin="round"></polyline>';
var labels = rows.map(function (r, i) {
if (rows.length > 8 && i !== 0 && i !== rows.length - 1 && i % Math.ceil(rows.length / 6) !== 0) return '';
var x = padL + step * i + step / 2;
var text = String(r.date || '').slice(8, 10) + '.' + String(r.date || '').slice(5, 7);
return '<text x="' + x.toFixed(1) + '" y="' + (H - 8) + '" font-size="10" fill="#8a97a8" text-anchor="middle">' + notifEsc(text) + '</text>';
}).join('');
// Подписи оси: при малых значениях дробим, иначе округление даст «1, 1».
var tick = function (k) {
var value = maxD * k;
return maxD < 4 ? String(Math.round(value * 10) / 10).replace('.', ',') : String(Math.round(value));
};
var grid = [0, 0.5, 1].map(function (k) {
var y = padT + innerH - innerH * k;
return '<line x1="' + padL + '" y1="' + y.toFixed(1) + '" x2="' + (W - 8) + '" y2="' + y.toFixed(1) + '" stroke="#eef2f7"></line>' +
'<text x="4" y="' + (y + 3).toFixed(1) + '" font-size="10" fill="#8a97a8">' + tick(k) + '</text>';
}).join('');
return '<svg viewBox="0 0 ' + W + ' ' + H + '" width="' + W + '" height="' + H + '" role="img">' + grid + bars + line + labels + '</svg>';
}
function reportHtml(d) {
var rows = (d.couriers || []).map(function (c) {
return '<tr><td>' + notifEsc(c.name || '') + '</td><td>' + (c.delivered || 0) + '</td><td>' +
(c.partially || 0) + '</td><td>' + (c.cancelled || 0) + '</td><td>' + (c.stops || 0) + '</td><td>' +
num(Math.round((c.distance_m || 0) / 100) / 10) + '</td><td>' + money(c.payout) + '</td></tr>';
}).join('');
var vio = (d.violations || []).map(function (v) {
return notifEsc(dayViolationLabel(v)) + ' — ' + v.count;
}).join('; ') || T('report.noViolations');
return '<!doctype html><html lang="' + (I18N.lang || 'ru') + '"><head><meta charset="utf-8">' +
'<title>' + notifEsc(T('report.title')) + '</title>' +
'<style>body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;padding:24px;color:var(--ink-900)}' +
'h1{font-size:20px;margin:0 0 6px}p{margin:2px 0;font-size:13px;color:var(--ink-mute)}' +
'table{width:100%;border-collapse:collapse;margin-top:14px;font-size:13px}' +
'th,td{border:1px solid var(--line);padding:6px 8px;text-align:left}th{background:var(--bg)}</style></head><body>' +
'<h1>' + notifEsc(T('report.title')) + ' — ' + notifEsc(reportPeriodText(d)) + '</h1>' +
'<p>' + notifEsc(T('report.delivered')) + ': ' + ((d.orders && d.orders.delivered) || 0) +
' · ' + notifEsc(T('report.partially')) + ': ' + ((d.orders && d.orders.partially_delivered) || 0) +
' · ' + notifEsc(T('report.cancelled')) + ': ' + ((d.orders && d.orders.cancelled) || 0) + '</p>' +
'<p>' + notifEsc(T('report.payout')) + ': ' + money(d.money && d.money.payout) + ' ₽ · ' +
notifEsc(T('report.cash')) + ': ' + money(d.money && d.money.cash) + ' ₽ · ' +
notifEsc(T('report.card')) + ': ' + money(d.money && d.money.card) + ' ₽</p>' +
'<table><thead><tr><th>' + notifEsc(T('report.thCourier')) + '</th><th>' + notifEsc(T('report.thDelivered')) + '</th>' +
'<th>' + notifEsc(T('report.thPartially')) + '</th><th>' + notifEsc(T('report.thCancelled')) + '</th>' +
'<th>' + notifEsc(T('report.thStops')) + '</th><th>' + notifEsc(T('report.thDistance')) + '</th>' +
'<th>' + notifEsc(T('report.thPayout')) + '</th></tr></thead><tbody>' + rows + '</tbody></table>' +
'<p style="margin-top:14px"><b>' + notifEsc(T('report.violations')) + ':</b> ' + vio + '</p>' +
'</body></html>';
}
/**
* Выгрузка отчёта в CSV: разделитель «;» и BOM — так Excel открывает файл
* двойным щелчком и не ломает кириллицу. Числа — с запятой в русском.
*/
function exportReportCsv() {
if (!reportState.data) return;
var d = reportState.data;
var dec = I18N.lang === 'ru' ? ',' : '.';
var cell = function (v) {
var text = v == null ? '' : String(v);
text = text.replace(/"/g, '""');
return '"' + text + '"';
};
var number = function (v) {
return String(v == null ? 0 : v).replace('.', dec);
};
var lines = [];
lines.push([T('report.thCourier'), T('report.thDelivered'), T('report.thPartially'), T('report.thCancelled'),
T('report.thStops'), T('report.thDistance'), T('report.thPayout')].map(cell).join(';'));
(d.couriers || []).forEach(function (c) {
lines.push([cell(c.name || ''), cell(c.delivered || 0), cell(c.partially || 0), cell(c.cancelled || 0),
cell(c.stops || 0), cell(number(Math.round((c.distance_m || 0) / 100) / 10)), cell(number(Math.round(c.payout || 0)))].join(';'));
});
lines.push('');
var o = d.orders || {};
var m = d.money || {};
lines.push([cell(T('report.delivered')), cell(o.delivered || 0)].join(';'));
lines.push([cell(T('report.partially')), cell(o.partially_delivered || 0)].join(';'));
lines.push([cell(T('report.cancelled')), cell(o.cancelled || 0)].join(';'));
lines.push([cell(T('report.payout')), cell(number(Math.round(m.payout || 0)))].join(';'));
lines.push([cell(T('report.cash')), cell(number(Math.round(m.cash || 0)))].join(';'));
lines.push([cell(T('report.card')), cell(number(Math.round(m.card || 0)))].join(';'));
var csv = '\ufeff' + lines.join('\r\n');
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'report-' + (d.from || d.date || reportDateValue()) + (d.to && d.to !== d.from ? '_' + d.to : '') + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 3000);
}
/** Период отчёта словами: один день или «с … по …». */
function reportPeriodText(d) {
if (!d) return '';
if (d.from && d.to && d.from !== d.to) return fmtDate(d.from) + ' — ' + fmtDate(d.to);
return fmtDate(d.from || d.date);
}
function printReport() {
if (!reportState.data) return;
var win = window.open('', '_blank');
if (!win) { toast(T('js.dayPrintBlocked'), 'err'); return; }
win.document.write(reportHtml(reportState.data));
win.document.close();
win.focus();
setTimeout(function () { try { win.print(); } catch (e) { } }, 250);
}
/** Склады пространства: точки выезда курьеров. */
async function loadWarehouses() {
if (!currentWs) return;
var r = await apiUniversal('warehouses', 'list', {
ws: currentWs, limit: LIST_PAGE, skip: listSkip('warehouses'),
}, { notifyErrors: true });
var items = (r.data && r.data.items) || [];
warehousesList = items;
renderPager('warehouses', items.length, (r.data && r.data.total) || items.length);
listRender('warehouses', items);
}
/** Правка склада открывает тот же единый диалог. */
function openWarehouseEdit(el) {
openEntity('warehouse', el.getAttribute('data-id'));
}
function openWarehouseModal() {
openEntity('warehouse');
}
async function removeWarehouse(id) {
var ok = await dsConfirm(T('settings.warehouseRemoveConfirm'), T('common.delete'));
if (!ok) return;
var r = await apiUniversal('warehouses', 'remove', { ws: currentWs, id: id });
if (r.code !== 0) { showApiError(r); return; }
loadWarehouses();
loadDay();
}
async function loadSettings() {
if (!currentWs) return;
// Правила доставки живут в сервисе заказов: там же, где сами заказы.
loadWarehouses();
var j = await apiUniversal('settings', 'get', { ws: currentWs }, { notifyErrors: true });
var d = j.data || {};
setVal('setTimezone', d.timezone || 'Europe/Moscow');
document.getElementById('setRequireScan').checked = !!d.require_marking_scan;
document.getElementById('setRequireReturnScan').checked = !!d.require_return_mark_scan;
document.getElementById('setPickupScan').checked = d.require_pickup_scan !== false;
document.getElementById('setSmsEnabled').checked = !!d.sms_confirm_enabled;
document.getElementById('setSmsKey').value = d.sms_ru_api_key || '';
var smsLocale = document.getElementById('setSmsLocale');
if (smsLocale) smsLocale.value = d.sms_locale === 'en' ? 'en' : 'ru';
var invites = document.getElementById('setInvitesMode');
if (invites) invites.value = d.invites_mode || 'link';
var cd = d.courier_defaults || {};
setVal('setCourierTransport', cd.transport || 'car');
setVal('setCourierMode', cd.mode || 'both');
setVal('setCourierVolume', cd.capacity_volume_m3);
setVal('setCourierWeight', cd.capacity_weight_kg);
setVal('setCourierMaxOrders', cd.max_orders);
setVal('setCourierRegion', cd.region || '');
setVal('setInviteTtl', d.invite_ttl_hours);
setVal('setInviteLimit', d.invite_limit);
}
async function saveSettings() {
var body = {
timezone: (document.getElementById('setTimezone') || {}).value.trim() || 'Europe/Moscow',
require_marking_scan: document.getElementById('setRequireScan').checked,
require_return_mark_scan: document.getElementById('setRequireReturnScan').checked,
require_pickup_scan: document.getElementById('setPickupScan').checked,
sms_confirm_enabled: document.getElementById('setSmsEnabled').checked,
sms_ru_api_key: document.getElementById('setSmsKey').value.trim(),
sms_locale: (document.getElementById('setSmsLocale') || {}).value || 'ru',
invites_mode: (document.getElementById('setInvitesMode') || {}).value || 'link',
invite_ttl_hours: val('setInviteTtl'),
invite_limit: val('setInviteLimit'),
courier_defaults: {
transport: (document.getElementById('setCourierTransport') || {}).value || 'car',
mode: (document.getElementById('setCourierMode') || {}).value || 'both',
capacity_volume_m3: val('setCourierVolume'),
capacity_weight_kg: val('setCourierWeight'),
max_orders: val('setCourierMaxOrders'),
region: (document.getElementById('setCourierRegion') || {}).value || ''
}
};
var r = await apiUniversal('settings', 'set', { ws: currentWs, body: body });
if (r.code === 0) {
toast(T('settings.saved'), 'ok');
// Часовой пояс мог измениться: рабочий день пересобираем с новой зоной.
dayState.tz = '';
if (activeSection === 'day') loadDay();
} else {
showApiError(r);
}
}
// Инициализация кабинета.
let gateStep = 1;
let gwStep = 1;
function hideGate() { document.getElementById('gate').style.display = 'none'; }
function showGateAuth() {
document.getElementById('gate').style.display = 'flex';
document.getElementById('gateAuth').style.display = 'block';
document.getElementById('gateWizard').style.display = 'none';
}
function showGateWizard() {
document.getElementById('gate').style.display = 'flex';
document.getElementById('gateAuth').style.display = 'none';
document.getElementById('gateWizard').style.display = 'block';
gwStep = 1;
renderGwStep();
}
function renderGwStep() {
document.querySelectorAll('#gateWizard .gwpanel').forEach(function (p) {
p.style.display = Number(p.getAttribute('data-s')) === gwStep ? 'block' : 'none';
});
document.querySelectorAll('#gateWizard .gwstep').forEach(function (el, i) {
el.className = 'gwstep' + (i + 1 === gwStep ? ' gw-on' : '');
});
document.getElementById('gwBack').style.display = gwStep > 1 ? 'inline-flex' : 'none';
document.getElementById('gwNext').innerText = gwStep === 3 ? T('gate.createSpace') : T('common.next');
}
async function gateAuthStep() {
var err = document.getElementById('gaErr');
err.style.display = 'none';
if (gateStep === 1) {
var email = document.getElementById('gaEmailInput').value.trim();
if (!email) { err.innerText = T('js.enterEmail'); err.style.display = 'block'; return; }
var r = await apiUniversal('auth', 'sendCode', { email: email, locale: I18N.lang });
if (r.code !== 0) { err.innerText = reasonText(reasonOf(r)); err.style.display = 'block'; return; }
window.__gateEmail = email;
document.getElementById('gaCode').style.display = 'flex';
document.getElementById('gaCodeInput').value = '123456';
document.getElementById('gaBtn').innerText = T('js.signIn');
gateStep = 2;
} else {
var code = document.getElementById('gaCodeInput').value.trim();
var j = await apiUniversal('auth', 'verifyCode', { email: window.__gateEmail, code: code, locale: I18N.lang });
if (j.code !== 0 || !j.data) { err.innerText = reasonText(reasonOf(j)); err.style.display = 'block'; return; }
localStorage.setItem('ce_token', j.data.token || '');
localStorage.setItem('ce_refresh', j.data.refresh_token || '');
localStorage.setItem('ce_user_id', j.data.user._id);
localStorage.removeItem('ce_demo');
sessionExpired = false;
document.getElementById('navAuth').innerText = '👤 ' + (j.data.user.name || j.data.user.email);
var next = qs.get('next') || localStorage.getItem('pendingInvite');
if (next) { localStorage.removeItem('pendingInvite'); window.location.href = next; return; }
var p = await apiUniversal('auth', 'getProfile', { user_id: j.data.user._id }, { notifyErrors: true });
if (p.code !== 0) { err.innerText = T('js.profileLoadFailed'); err.style.display = 'block'; return; }
var prof = p.data || {};
var list = prof.workspaces || [];
if (list.length) { fillSelector(list); hideGate(); }
else { showGateWizard(); }
}
}
async function gwNext() {
var err = document.getElementById('gwErr');
err.style.display = 'none';
if (gwStep === 1) {
if (!document.getElementById('gwName').value.trim()) { err.innerText = T('js.gateEnterName'); err.style.display = 'block'; return; }
gwStep = 2; renderGwStep(); return;
}
if (gwStep === 2) { gwStep = 3; renderGwStep(); return; }
var modes = [];
if (document.getElementById('gwRoute').checked) modes.push('route');
if (document.getElementById('gwOndemand').checked) modes.push('ondemand');
var uid = localStorage.getItem('ce_user_id');
var r = await apiUniversal('workspaces', 'create', {
user_id: uid,
name: document.getElementById('gwName').value.trim(),
kind: document.getElementById('gwType').value,
orders_per_day: document.getElementById('gwOrders').value,
modes: modes
});
if (r.code !== 0) { err.innerText = reasonText(reasonOf(r)); err.style.display = 'block'; return; }
fillSelector([{ ws: r.data.ws, name: document.getElementById('gwName').value.trim() }]);
hideGate();
}
function gwBack() { if (gwStep > 1) { gwStep--; renderGwStep(); } }
function fillSelector(list) {
var select = document.getElementById('wsSelector');
select.innerHTML = list.map(function (w) { return '<option value="' + w.ws + '">' + w.name + '</option>'; }).join('');
if (list.length) {
var pick = (currentWs && list.some(function (w) { return w.ws === currentWs; })) ? currentWs : list[0].ws;
select.value = pick;
changeWorkspace(pick);
}
}
async function startDemo() {
var r = await apiUniversal('orders', 'demo', {});
if (!(r.code === 0 && r.data && r.data.demo_token)) {
showErrorOnce(T('js.demoFailed'));
showGateAuth();
return;
}
localStorage.setItem('ce_token', r.data.demo_token);
// Демо-сессия обязана иметь refresh — иначе через полчаса access истечёт,
// и обновить его будет нечем.
if (r.data.refresh_token) localStorage.setItem('ce_refresh', r.data.refresh_token);
else localStorage.removeItem('ce_refresh');
localStorage.setItem('ce_user_id', r.data.demo_user_id || '');
localStorage.setItem('ce_demo', '1');
sessionExpired = false;
var p = await apiUniversal('auth', 'getProfile', { user_id: r.data.demo_user_id }, { notifyErrors: true });
if (p.code !== 0) { showGateAuth(); return; }
var prof = p.data || {};
var list = prof.workspaces || [];
if (list.length) {
fillSelector(list);
hideGate();
// Демо из закладки: если пришли на конкретный раздел — открываем его,
// а не «Обзор» по умолчанию.
if (qs.get('section')) switchSection(qs.get('section'));
} else { showErrorOnce(T('js.demoNoWorkspace')); showGateAuth(); }
}
async function boot() {
if (qs.get('demo') === '1') { await startDemo(); return; }
var uid = localStorage.getItem('ce_user_id');
if (!uid || uid === 'null') { showGateAuth(); return; }
var p = await apiUniversal('auth', 'getProfile', { user_id: uid }, { notifyErrors: true });
if (p.code !== 0) {
// 401 уже разобран apiUniversal: сессия сброшена и показан вход.
// Любая другая ошибка тоже не повод молча предлагать мастер создания.
if (p.code !== 401) showGateAuth();
return;
}
var prof = p.data || {};
var list = prof.workspaces || [];
if (list.length) { fillSelector(list); hideGate(); switchSection(qs.get('section') || 'dashboard'); }
else { showGateWizard(); }
}
boot();
</script>
</body>
</html>
Sitemap
Важность 3 из 3
Кол-во: 0
Sitemap.xml помогает поисковику быстрее находить и индексировать страницы. Особенно важен для крупных сайтов и новых страниц, на которые ещё нет входящих ссылок.
ОшибкаRobots.txt не содержит ссылку на карту сайта. Рекомендуется добавить карту сайта и указать ссылку на нее в robots.txt.
Внутренние ссылки
Важность 3 из 3
Кол-во: 1
Внутренние ссылки распределяют ссылочный вес между страницами и помогают поисковику обходить сайт. Пустые анкоры и ссылки на запрещённые robots.txt страницы — типичные ошибки.
НормаВнутренних ссылок на странице 1 оптимально.
НормаВнутренние ссылки не запрещены к индексации в robots.txt.
| Url | Анкор | Состояние | Анализировать |
|---|---|---|---|
| /?lang=en | English | Норма | Анализировать url |
Внешние ссылки
Важность 2 из 3
Не найдено
Исходящие внешние ссылки передают часть ссылочного веса на чужие сайты. Ссылки на авторитетные ресурсы безопасны; ссылки на мусорные сайты могут навредить репутации страницы. Считаем только открытые: ссылки с rel='nofollow', 'sponsored' или 'ugc' вес не передают, в списке они помечены своим rel.
НормаОткрытых внешних ссылок на странице не найдено.
ИИ-боты
Важность 2 из 3
Открыт
Краулеры ChatGPT, Claude, Gemini, Нейро Яндекса и Perplexity читают сайт отдельно от поисковых роботов, и закрываются они своими строками в robots.txt. Закрытый сайт не попадёт в ответы этих сервисов — ни ссылкой, ни цитатой. На оценку фактор не влияет: закрыться от обучения моделей — законное решение владельца.
НормаСайт открыт для ИИ-краулеров: ChatGPT, Claude, Gemini, Нейро Яндекса и Perplexity могут читать его и ссылаться на него в своих ответах. Если контент нужно защитить от обучения моделей, краулеры закрываются в robots.txt адресно — своей группой User-agent на каждого.
Конкуренты
0%
Конкуренты в Яндексе
Важность 3 из 3
Кол-во: 0
Сайты, чаще всего появляющиеся в ТОПе Яндекса по запросам из семантического ядра этой страницы.
ОшибкаМы не нашли у вас конкурентов в Яндексе. Сайт или очень молодой или плохо продвигается.
НормаКонкурентов в ТОП-10 Яндекса не нашлось.
Конкуренты в Google
Важность 3 из 3
Кол-во: 0
Сайты, чаще всего появляющиеся в ТОПе Google по запросам из семантического ядра этой страницы.
ОшибкаКонкуренты в Google тоже не найдены. Займитесь продвижением сайта!
НормаКонкурентов в ТОП-10 Google не нашлось.
ФЗ-152: персональные данные
40%
РКН
Важность 3 из 3
ОК
НормаСайт не заблокирован
Контакты
Важность 2 из 3
https://cp.courierexpert.ru/contacts/
НормаСтраница с контактами найдена
Политика ПД
Важность 3 из 3
https://cp.courierexpert.ru/policy/
НормаСтраница политики обработки ПД найдена
Согласие
Важность 2 из 3
https://cp.courierexpert.ru/personal/
НормаСтраница согласия на обработку ПД найдена
Чекбокс согласия
Важность 2 из 3
Не найден
ОшибкаЧекбокс согласия на обработку ПД в формах сайта не найден
Куки-баннер
Важность 2 из 3
Не найден
ОшибкаБаннер согласия с использованием cookie не найден
Кнопка отказа
Важность 2 из 3
Не найдена
ОшибкаКнопка отказа от использования cookie не найдена
Счётчики до согласия
Важность 3 из 3
ОК
НормаСчётчики аналитики не собирают данные до согласия пользователя
ЗоЗПП: права потребителей
100%
Нарушения
Важность 1 из 3
Не выявлены
НормаПризнаков продажи товаров или оказания услуг на сайте не обнаружено — требования ЗоЗПП о раскрытии сведений о продавце (исполнителе) к нему не применяются. Нарушений нет.
ФЗ-149: рекомендательные технологии
100%
Нарушения
Важность 1 из 3
Не выявлены
НормаРекомендательные блоки («с этим покупают», «похожие товары» и т.п.) на сайте не обнаружены — требования ст. 10.7 ФЗ-149 к сайту не применяются. Нарушений нет.
ФЗ-38: реклама
100%
Нарушения
Важность 1 из 3
Не выявлены
НормаРекламных тематик с обязательными оговорками (медицина, БАД, кредиты и займы, новостройки) на сайте не обнаружено. Нарушений нет.
ФЗ-436: защита детей
100%
Нарушения
Важность 1 из 3
Не выявлены
НормаПризнаков информационной продукции (новости, видео, книги, игры, курсы) не обнаружено — обязательная возрастная маркировка по ФЗ-436 сайту не требуется. Нарушений нет.
В этой категории проверок нет.
Сервисы CheckSite для вашего сайта
Мониторинг
300 ₽ / мес
Проверяем сайт каждые 3 часа. Пишем, только когда сайт упал, закрылся от индексации или что-то изменилось.
ПодключитьАудит сайта
от 500 ₽ / мес
Раз в неделю обходим до 100 страниц и присылаем отчёт об изменениях. До 1000 страниц — 1000 ₽/мес.
Заказать аудитБаза контактов
15 000 ₽ / мес
Поиск по контактам сайтов зоны .ru без лимита и 10 000 раскрытий в месяц. Бесплатно после регистрации: поиск, полный счётчик и 10 карточек с компанией и ИНН, контакты скрыты.
Попробовать демоБольшой аудит от SeoVision
Когда автоматической проверки мало: подробный аудит сайта от команды SeoVision.
Подробнее на seovision.ru
Поделитесь с друзьями: