Compare commits

...

43 Commits

Author SHA1 Message Date
Дмитрий ec805957f1 chore(deploy): защищённый выкат сборки портала — не даёт затереть чужие фичи
Портал выкатывается полной заменой public/build; сборка из устаревшей ветки
молча сносила чужую работу (инцидент 08.07: Метрика снесла колонки «Сделок»).
deploy-build.sh перед подменой сверяет маркеры критичных фич (по активным
чанкам manifest.json, не по orphan-осколкам) — теряет живую фичу → ОТКАЗ.
Осознанный откат — FORCE=1, только проверка — DRY_RUN=1. Установлен на боевом.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:19:17 +03:00
Дмитрий 995aae4571 fix(portal): карточки вместо таблицы на планшете-портрете (Сделки/Списания)
Широкая таблица (7 колонок) не влезала в узкую колонку планшета рядом с
rail-меню — правые колонки «Комментарий»/«Поставлен» обрезались на 600–1279px.
ResponsiveTable теперь показывает карточки на телефоне И планшете, таблицу —
только на десктопе (>=1280). Тесты табличного слоя зафиксированы на десктоп-ширину.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:57:07 +03:00
Дмитрий 8a4ee4c69b fix(deals): колонка «Конкурент» показывает только бренд до первой запятой
Имя конкурента хранится как «Бренд, описание» — в колонке «Конкурент»
показываем часть до первой запятой, полное имя — в title при наведении.
Применено к десктопной таблице и мобильной карточке. +тест на обрезку.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:37:30 +03:00
Дмитрий abdb6e20c4 feat(metrika): Метрика+Вебвизор в линию портала (чтобы выкаты не теряли)
Метрика была потеряна, т.к. клалась мимо конвейера: выкат = git archive HEAD ветки
+ пересборка, а Метрика жила отдельно → перезатёрлась. Кладём в feat/portal-mobile-adaptive
(линию, из которой режутся deploy/*-ветки) → любой будущий выкат несёт её сам.

Загрузчик metrika.ts (только кабинет layout=app), маскировка ПДн лидов
(ym-hide-content/ym-disable-keys) — десктоп-таблица + МОБИЛЬНЫЕ карточки (телефон+
комментарий+aria-label) + канбан + карточка сделки + диалог. Сборка проходит.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:00:37 +03:00
Дмитрий 567e950758 fix(admin): клиентский диапазон ступеней в тарифной сетке + предпросмотр в редакторе
Cherry-pick b35d3888 поверх боевой ветки feat/portal-mobile-adaptive для
выката ТОЛЬКО правки тарифов на прод. Логика денег не менялась.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:29:40 +03:00
Дмитрий 8d9af40637 feat(billing): приветственный подарок при регистрации 300 ₽ → 1000 ₽
START_BALANCE_RUB в RegistrationService: '300.00' → '1000.00' — новый тенант
получает 1000 ₽ на баланс при подтверждении почты. Обновлён докблок
RegistrationController и тест RegistrationTest (баланс 1000, 13 тестов зелёные).
Автоподборные 300 ₽ (цена «Собрать конкурентов») НЕ тронуты — это другая сумма.
Миграций нет (константа применяется при регистрации). В UI/лендинге сумма
подарка не отображается — фронт не менялся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:35:22 +03:00
Дмитрий 6d6ce28e54 fix(autopodbor): защита route.query в deep-link — чистый прогон тестов
Влитый из автоподбора deep-link «Сделки → Автоподбор» читал route.query
в onMounted без защиты. В приложении роутер всегда есть, но при монтировании
в юнит-тесте без роутера route === undefined → unhandled error (2 ошибки,
vitest exit 1). Добавил route?.query ?? {} — поведение в бою не меняется,
полный прогон снова зелёный (1184 , exit 0). Правка нужна для чистого
CI-гейта выката мобильной ветки.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:44:48 +03:00
Дмитрий dead7e48c5 feat(portal-mobile): новые колонки «Конкурент» и «Источник» в карточке сделки на телефоне
После вливания переделки сделок (колонки Конкурент/Источник со ссылками
в автоподбор) эти поля были только в таблице на компьютере — в карточке
телефона их не было. Добавил в мобильную карточку:
- «Конкурент» — имя конкурента зелёной ссылкой, ведёт в автоподбор
  (/autopodbor?competitor=ID), рядом город;
- «Источник» — отдельной строкой домен/идентификатор ссылкой в автоподбор
  (+ «· +N» если источников несколько) и канал (Сайт/Звонки/СМС).
Для сделок без источника автоподбора карточка выглядит как раньше
(проект · канал · город). @click.stop — ссылки не открывают деталь сделки.

Проверено на 390px: конкурент+источник видны, вбок не едет, переход
по ссылке в автоподбор работает; тесты DealsTable/mapper/ResponsiveTable
зелёные (23).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:26:29 +03:00
Дмитрий a231fe25a7 Merge branch 'worktree-avtopodbor' into feat/portal-mobile-adaptive 2026-07-08 08:16:51 +03:00
Дмитрий 4194d86ff7 docs(сделки): спека + план «Конкурент/Источник» в реестре сделок
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:56:45 +03:00
Дмитрий a4c9200261 feat(сделки): колонки «Конкурент» и «Источник» со ссылками в автоподбор
Раздел «Сделки»: колонка «Источник» разбита на две — «Конкурент» (ссылка на
экран конкурента) и «Источник» (ссылка на «Настройки проекта»). В модалку
настроек добавлена кнопка пуск/стоп (срабатывает не закрывая окно).

- бэк DealController::index: батч-резолв конкурента+источника по
  deal.project_id → autopodbor_sources.created_project_id → competitor (без N+1)
- контракт ApiDeal/MockDeal + маппер: competitor_*/source_* поля
- DealsTable: 1 колонка → 2 (Конкурент/Источник), router-link, fallback без автоподбора
- AutopodborView: deep-link ?competitor=&project= открывает экран конкурента + модалку
- FieldCompetitorScreen: кнопка ⏸/▶ в модалке настроек + авто-открытие по ссылке

Тесты: бэк DealIndexTest +4 (31 ), фронт +новые (66 ). Дизайн не менялся.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:56:30 +03:00
Дмитрий 5ba484e0ed fix(portal-mobile): 4 фикса вёрстки на телефоне (обход всех экранов)
Прошёл все экраны портала на 390px, нашёл и починил переполнения:

1. NewProjectDialog: кнопки дней недели (ПН…ВС) в окне редактирования
   проекта не влезали — СБ и ВС уезжали за правый край и были не видны.
   На телефоне делим ряд поровну между 7 кнопками.

2. DaysBulkDialog (массовое «Дни сбора»): та же беда с двумя рядами дней —
   тот же приём (flex 1/1/0 на телефоне).

3. ApiTab: пары кнопок «Копировать+Перегенерировать» и
   «Сохранить+Тестовый webhook» не помещались в строку — длинная вторая
   кнопка обрезалась. Разрешаем перенос на телефоне (flex-wrap).

4. NotificationsTab: таблица «События × каналы» была шире экрана —
   колонка Email уезжала, а названия событий крошились по одному слову.
   На телефоне ужимаем колонки-галочки и уменьшаем отступы, событию
   достаётся почти вся ширина; заголовки каналов мельче.

Все правки — только в phone-media (<600px), десктоп не затронут.
Проверено замерами DOM + скриншотами на 390px; тесты затронутых
компонентов зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 05:14:15 +03:00
Дмитрий 68cefaec09 fix(portal-mobile): кнопка «Создать проект» не жмётся к заголовку на телефоне
На узком экране заголовок «Проекты» и кнопка «Создать проект» стояли
вплотную в одну строку. На телефоне (<600px) кнопка теперь под заголовком
во всю ширину-строку, слева, с воздухом между ними; заголовок прижат влево
через align-self (перебивает utility .align-center с !important). Десктоп
не затронут — правка только в phone-media.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:27:15 +03:00
Дмитрий 62884559e9 fix(портал-мобилка): «Поле» карточки источников — кнопки не уезжают за край
Карточка источника (.ld-srccard) — flex-ряд: иконка | данные | блок кнопок
(.ld-srcctl, flex-shrink:0, max-width:320) справа. На телефоне кнопки
(Создать проект/Приостановить/Настройки проекта/В источники/Возобновить)
уезжали за край карточки. На телефоне (media <600px) карточка переносит
блок кнопок на строку ниже во всю ширину. Затрагивает обе под-вкладки
(Источники в работе + Предложения). Проверено сканом: 0 заездов. Тесты 71/71.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:04:11 +03:00
Дмитрий 53b7e600c6 fix(портал-мобилка): Счета — строка счёта не уезжает за экран на телефоне
Строка счёта была гридом из 4 колонок (дата | номер | сумма | кнопки).
На телефоне при двух кнопках («Счёт» + «Акт» после оплаты) номер крошился,
а кнопки уезжали за край. На телефоне (media <600px) строка в 3 яруса:
номер+сумма, дата+статус, ниже кнопки «Счёт»/«Акт» во всю ширину. Проверено
на засеянном оплаченном счёте с актом. Десктоп (грид 4 колонки) без изменений.
Тесты 39/39 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:51:14 +03:00
Дмитрий e8e9b31756 fix(портал-мобилка): Канбан — этапы переносятся на строки, все видны
Лента этапов на телефоне скроллилась вбок (overflow-x), из-за чего после
«Просмотрено» остальные этапы (В работе/Сделка/Не реализовано) уходили за
экран и были не видны, третий чип обрезался. Заменил горизонтальную прокрутку
на flex-wrap — все 5 чипсов-этапов переносятся на строки и видны сразу.
Тесты Канбана 23/23 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:38:56 +03:00
Дмитрий 1ac83411c6 fix(портал-мобилка): Биллинг — плитки Кошелёк/Лиды/Запас не схлопываются на телефоне
Предсуществующий баг BalanceCard: на узких экранах (<960px) ряд трёх плиток
становится столбцом, но flex:1 1 0 (flex-basis:0) схлопывал высоту плиток до
~35px, а overflow:hidden обрезал содержимое — цифры (49 050 ₽, 98 лидов, 4 дн.)
и кнопки «Пополнить» пропадали, оставались пустые заголовки. В столбце плитки
теперь flex:0 0 auto (по высоте контента). Затрагивает телефон и планшет;
десктоп-ряд (>=960px) без изменений. Тесты 52/52 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:27:41 +03:00
Дмитрий 933b273c71 fix(портал-мобилка): Биллинг «Дополнительные услуги» — плашки не крошатся на телефоне
Строка услуги (название+описание | «при успешном…» 140px | цена) на телефоне
сдавливала название до одной колонки — текст ломался по слову («Сбор конкуре
нтов»). На телефоне (media <600px) раскладываем: название на всю ширину сверху,
ниже строкой «когда списываем» + цена справа. Десктоп/планшет без изменений.
Тесты 113/113 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:12:58 +03:00
Дмитрий 86cc297ba0 fix(портал-мобилка): «Лимит оптом» — на весь экран на телефоне
Диалог массовой правки лимита теперь на телефоне открывается на весь экран
(:fullscreen), единообразно с «Дни оптом»/«Регионы оптом» из той же панели
множественного выбора. Десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:59:20 +03:00
Дмитрий a92c1b6f7f fix(портал-мобилка): деталь сделки на телефоне — на весь экран
Панель деталей сделки была фикс. ширины (inline 400px в «Сделках», drawer 480px
в Канбане) — на экране 390 она была шире экрана: в «Сделках» давала горизонтальный
скролл страницы и крошила карточки списка, в Канбане левый край уезжал за экран.
На телефоне:
- «Сделки» — inline-панель стала полноэкранным оверлеем (position:fixed inset:0).
- Канбан — ширина drawer = ширине экрана (useDisplay).
Десктоп/планшет — прежний master-detail 400px / drawer 480px, без изменений.
Тесты 103/103 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:56:42 +03:00
Дмитрий 25116a8abb fix(портал-мобилка): диалоги на телефоне — на весь экран (fullscreen)
Узкие центрированные модалки на телефоне резали содержимое (ряды кнопок,
вкладки, дни недели, сетку регионов — контент вылезал за края, текст обрезался
слева и справа). Диалоги с богатым содержимым теперь на телефоне открываются
на весь экран (:fullscreen по useLayoutTier + scrollable):
- Проект (создание/редактирование) — NewProjectDialog
- Новая сделка — NewDealDialog
- Пополнение баланса — TopupDialog
- Дни оптом — DaysBulkDialog (те же кнопки дней)
- Регионы оптом — RegionsBulkDialog
- Импорт: сопоставление статусов — UnknownStatusesDialog
Десктоп/планшет — прежний центрированный модал, без изменений.
Тесты диалогов 59/59 (+2 skip) зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:46:47 +03:00
Дмитрий 21505163b2 fix(портал-мобилка): «Предложения» — кнопки «На актуализацию» не вылазят за карточку
В карточках «На актуализацию» три кнопки (Добавить/Заменить/Отказаться)
были шире контента и заезжали за правый край карточки (на ~18px, «Отказаться»
упиралась в бортик). На телефоне (media <600px) кнопки теперь во всю ширину
карточки поровну (flex:1) — держатся внутри паддинга. Десктоп без изменений.
Проверено сканом всех 39 карточек: заездов за паддинг больше нет.
Тесты 71/71 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:24:31 +03:00
Дмитрий 54772e1651 fix(портал-мобилка): «Поле» — плашка предложений и строка выбора на телефоне
На телефоне (media <600px):
- Плашка «Лидерра подготовила N предложений… Разобрать предложения →»
  раскладывается в столбик: текст переставал крошиться по слову, а ссылка
  «Разобрать предложения →» больше не вылезает за край (была nowrap).
- Строка «Выбрать всех/все предложения» — flex-wrap: подсказка падает на
  новую строку во всю ширину, не жмётся в узкую колонку.
Правки в общем field-shared.css — покрывают и «В поле», и «Предложения».
Тесты «Поля» 71/71 зелёные. Десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:16:48 +03:00
Дмитрий 12b2947919 fix(портал-мобилка): небольшой зазор у «Поиска» в шапке на телефоне
При длинном заголовке («Конкурентное поле») кнопка «Поиск» стояла вплотную
к тексту (зазор 0). Добавлен margin-left 10px на телефоне (media <600px) —
теперь есть воздух. Десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:09:18 +03:00
Дмитрий 769f250af5 fix(биллинг): метка для списаний автоподбора в истории транзакций
Списания type=autopodbor_charge шли без описания и показывались как «—»
и в таблице (десктоп), и в карточках (телефон). Добавлена метка
«Списание за автоподбор» в общий словарь операций TransactionsTable.

Тесты транзакций 9/9 зелёные.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:58:48 +03:00
Дмитрий fc30d4b1d1 fix(портал-мобилка): Настройки/Реквизиты — «Тип лица» в столбик на телефоне
Сегмент-контрол «Тип лица» (Физическое лицо/ИП/Юридическое лицо) на телефоне
не влезал в строку и наезжал; теперь раскладывается в столбик во всю ширину
(media <600px, скруглены верх/низ группы). Остальные поля реквизитов уже
адаптивны. Десктоп/планшет — горизонтальный переключатель, без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:54:11 +03:00
Дмитрий 2aace1ff8e fix(автоподбор): гейт «та же фирма» на разворот 2ГИС — режет иногородних чужаков
Баг AutoMoney: разворот по общему/партнёрскому/иногороднему номеру тащил чужие МФО
из Москвы/Питера (moneyman, finombudsman — фин.омбудсмен!, npmir, razvitie…) в источники
красноярского конкурента. crowdedCap ловил только массовые 8-800.

Гейт (100%-надёжный, проверен на реальных данных прода): карточку 2ГИС из разворота
выкидываем, ТОЛЬКО если город из URL карточки (2gis.ru/<city>/firm) ≠ город конкурента
И домен не наш бренд (с учётом поддоменов-филиалов). Свой город ИЛИ свой бренд — оставляем.
Яндекс НЕ трогаем (город в URL не виден, там же законные филиалы — риск срезать своих).

Проверка на проде: #16 AutoMoney/#64 Ваш Инвестор — все 2ГИС moscow/spb уходят;
#22 (автоломбард krasnoyarsk), #48 Кредит-финанс (Екб/Томск, Яндекс) — остаются.
11/11 тестов expander, вся ветка 220 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:50:38 +03:00
Дмитрий a9b40d12f9 fix(портал-мобилка): Проекты — фильтры в столбик во всю ширину на телефоне
На телефоне фильтры (Тип/Статус/Регион/День/Сортировка/Поиск) перестали
обрезаться («Р…», «Д…») — раскладываются в столбик во всю ширину
(перебит inline max-width через media <600px). Десктоп/планшет без изменений;
карточки проектов уже адаптивны.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:50:11 +03:00
Дмитрий 8341c1758e fix(портал-мобилка): Дашборд — переключатель периода во всю ширину на телефоне
На телефоне v-btn-toggle (Сегодня/7 дней/30 дней/Период) растянут на всю
ширину с равными кнопками — убрана внутренняя горизонтальная прокрутка.
Только media-запрос <600px; десктоп/планшет без изменений.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:46:55 +03:00
Дмитрий b66a6f6e7b feat(портал-мобилка): планшет — узкая колонка-иконки (rail)
На планшете (600–1279px) сайдбар сжимается в rail: только иконки пунктов,
подписи/поиск/бренд-текст скрыты, контент шире. Иконки берутся из mdi,
уже заложенных в данные пунктов; на десктопе иконки скрыты — текстовый
сайдбар остаётся прежним. Отступы топбара и контента разведены по ярусу
(десктоп 232/240px, планшет 64/72px, телефон 0). Гамбургер топбара теперь
показывается только на телефоне (d-sm-none) — на планшете/десктопе он не нужен.

Тесты каркаса 24/24 зелёные. Проверено на 820 и 1440px; десктоп не изменился.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:42:50 +03:00
Дмитрий b803c6478b feat(портал-мобилка): «Канбан» на телефоне — одна колонка + переключатель этапов
На телефоне 5 колонок больше не листаются вбок: сверху горизонтальный
переключатель этапов (чипсы со счётчиками), ниже — выбранная колонка на всю
ширину. Смена этапа сделки на телефоне — тапом по карточке через drawer
(drag вбок на узком экране неудобен). Планшет/десктоп — прежняя доска со всеми
колонками и перетаскиванием, без изменений.

Тесты Канбана 23/23 зелёные. Проверено на 390 и 1440px (+ переключение этапов).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:35:58 +03:00
Дмитрий 133eb9d0f0 feat(автоподбор): текстовые номера с сайта → «на сайте, проверьте» (unverified)
Второй пробел после фикса таймаута рендера: номера, написанные на сайте простым
текстом (не кликабельные tel:/schema, без коллтрекинга), терялись. Пример Центрофинанс:
8 800 200-00-10 («Телефон:» у юр. адреса), +7 931 106-54-50 (подвал) — движок брал
только tel:-ссылки.

- HtmlPhoneScanner: тело сканируем БЕЗ <script>/<style> (меньше шума из конфигов)
- CandidateBuilder: без трекера body-номера (не в code/visible) → kind 'text' «проверьте»
- SourceAggregator + DeepStudyCollector: text-only → phone_kind 'unverified' (не теряем,
  но и за 100% настоящий не выдаём); code/справочник перебивают → 'real'
- фронт: бейдж «⚠ на сайте — проверьте» (amber), сортировка ниже настоящих, легенда
- 7 тестов (scanner/builder/aggregator/DeepStudy); вся ветка 493/493, build ок

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:23:37 +03:00
Дмитрий 6b0550ff89 feat(портал-мобилка): «Биллинг» — карточки транзакций на телефоне
История транзакций обёрнута в ResponsiveTable: на десктопе/планшете —
v-data-table-server как прежде, на телефоне — карточки (операция, сумма
цветом, дата, #ID) + компактный пейджер по 20. Серверная пагинация сохранена.

Тесты биллинга 40/40 зелёные. Проверено на 390 и 1440px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:17:17 +03:00
Дмитрий d3764cb8f2 feat(портал-мобилка): ResponsiveTable + карточки «Сделок» на телефоне
Обёртка ResponsiveTable (таблица на десктопе/планшете, карточки на телефоне
по ярусу useLayoutTier). Применена к реестру лидов «Сделки»: на телефоне —
карточки лида (чекбокс выбора, телефон, статус-плашка, источник·город,
комментарий, дата), тап открывает карточку сделки; на десктопе прежняя
v-data-table без изменений.

Тесты: ResponsiveTable.spec (2), «Сделки» 58/58 зелёные. Проверено на 390 и 1440px.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 19:17:01 +03:00
Дмитрий aca52d130d fix(автоподбор): рендер сайта больше не теряет номера при таймауте Playwright
Шаг 2: CurlPlaywrightFetcher.site() curl'ом успешно тянул страницу с tel:-номерами,
но render() (node render-page.cjs) висел на waitUntil networkidle и падал по таймауту.
Symfony Process::run() при таймауте бросает ProcessTimedOutException — оно вылетало из
site(), SiteOpener обнулял весь сбор. Итог: 0 номеров с сайта (боевой кейс Центрофинанс,
tenant info@lkomega.ru, прогон 07.07 14:07, только сайт+карточка, ни одного code-номера).

- render()/renderFirm() ловят \Throwable -> null: падение/вис рендера НЕ фатально,
  curl-HTML с номерами 'в коде сайта' сохраняется
- render-page.cjs: networkidle -> domcontentloaded + пауза; рендер 30с+вис -> 3.7с (боевая проверка)
- сиды curlRunner/nodeRunner для офлайн-теста; регресс-тест воспроизводит потерю номеров

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 18:16:42 +03:00
Дмитрий a0a219add4 feat(портал-мобилка): каркас — сайдбар прячется на телефоне, снизу панель-вкладки и «Ещё»
AppLayout: ярус-отступ (десктоп/планшет 232px, телефон 0 + низ под панель), рендер
AppBottomNav/AppMoreDrawer на телефоне, гамбургер открывает «Ещё». AppSidebar:
проп tier + скрытие на телефоне. Десктоп/планшет без изменений.
2026-07-07 17:59:04 +03:00
Дмитрий ce1094e160 chore(autopodbor): AITUNNEL_AGGREGATOR_MODEL=gpt-4o в .env.example — слабая mini резала живые федеральные МФО как агрегаторы
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:54:31 +03:00
Дмитрий 461bcccf4d feat(портал-мобилка): AppMoreDrawer — выезжающее «Ещё» на телефоне
Разделы вне 5 нижних вкладок: Дашборд, Отчёты, Настройки (реквизиты), Импорт, Помощь.
Ни один экран не выкидываем.
2026-07-07 17:54:00 +03:00
Дмитрий 3957f3bd39 feat(портал-мобилка): AppBottomNav — нижнее меню-вкладки на телефоне 2026-07-07 17:50:36 +03:00
Дмитрий 63eb0a6b84 feat(портал-мобилка): composable useLayoutTier — ярус телефон/планшет/десктоп 2026-07-07 17:45:37 +03:00
Дмитрий de10c633e6 docs(handoff): исправлен устаревший статус — deep-study/render-cache/EXA уже на проде
Хендоф называл три фичи «невыкаченными, за тумблерами» — по факту (проверено
командой на боевом) все три на проде и флаги ON: AUTOPODBOR_DEEP_STUDY,
AUTOPODBOR_RENDER_CACHE, EXA_PROXY (тоннель отдаёт зарубежный IP). Уехали 06.07.
Следствие: баг «шаг 2 AutoMoney» — в уже-живом deep-study. Добавлена сегодняшняя
сортировка регионов (REGIONS_ALPHA), gitea-ссылка обновлена на 94cfc7ce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:15:20 +03:00
Дмитрий 94cfc7ced6 fix(projects): регионы в 4 выпадашках раздела «Проекты» — сортировка по сути названия
Те же выпадашки региона (новый проект, фильтр списка, массовое
add/remove регионов, карточка проекта) переведены на REGIONS_ALPHA —
единый порядок с автоподбором, республики по своему имени, а не кучей.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:30:42 +03:00
Дмитрий 4451231c1c fix(autopodbor): регионы в выпадашках — сортировка по сути названия
Регионы выводились в конституционном порядке (республики → края →
области), из-за чего список выглядел кашей (Дагестан → Донецкая
Народная → Ингушетия → Кабардино-Балкарская). Добавлен REGIONS_ALPHA:
сортировка по «настоящему» имени, слова-типы (Республика/область/край/
автономный округ) отбрасываются — Татарстан на «Т», Адыгея на «А».
Подставлен во все 7 выпадашек автоподбора. Тест regionsAlpha.spec.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:27:44 +03:00
86 changed files with 3409 additions and 136 deletions
+6
View File
@@ -99,3 +99,9 @@ VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
# Автоподбор шаг2: обход антибота справочников (2ГИС/Яндекс). Ключ — в .env, не в гите.
XFETCH_API_KEY=
# Автоподбор шаг1: модель ИИ-фильтра «поставщик или площадка-агрегатор».
# ОБЯЗАТЕЛЬНО gpt-4o (сильная): слабая gpt-4o-mini наугад штампует живые
# федеральные МФО (Екапуста/Webbankir/СрочноДеньги…) как «агрегаторы» и режет
# их из подбора. Пусто → фолбэк на gpt-4o-mini (баг «режет федералов», 07.07.2026).
AITUNNEL_AGGREGATOR_MODEL=gpt-4o
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\AutopodborSource;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
@@ -104,7 +105,7 @@ class DealController extends Controller
$query = Deal::query()
->select('deals.*')
->where('tenant_id', $tenantId)
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders,is_active', 'manager:id,email,first_name,last_name']);
if ($onlyDeleted) {
$query->withTrashed()->whereNotNull('deleted_at');
@@ -206,6 +207,25 @@ class DealController extends Controller
});
}
// Автоподбор: по каждой сделке — конкурент + источник (связь
// deal.project_id → autopodbor_sources.created_project_id → competitor).
// Батч-запрос по project_id всей страницы (без N+1). Своя транзакция с
// app.current_tenant_id — иначе RLS на autopodbor_* вернёт 0 строк.
$sourcesByProject = collect();
$projectIds = $deals->pluck('project_id')->filter()->unique()->values()->all();
if ($projectIds !== []) {
$sourcesByProject = DB::transaction(function () use ($tenantId, $projectIds) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return AutopodborSource::query()
->where('tenant_id', $tenantId)
->whereIn('created_project_id', $projectIds)
->with('competitor:id,name')
->get()
->groupBy('created_project_id');
});
}
$payload = [
'deals' => $deals->map(fn (Deal $d) => [
'id' => $d->id,
@@ -230,6 +250,21 @@ class DealController extends Controller
'project_sms_keyword' => $d->project?->sms_keyword,
'project_sms_senders' => $d->project?->sms_senders,
'cost_kopecks' => $costByDeal[$d->id] ?? null,
// Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок».
...(function () use ($d, $sourcesByProject) {
$group = $sourcesByProject[$d->project_id] ?? collect();
$repr = $group->firstWhere('box', 'field') ?? $group->first();
return [
'competitor_id' => $repr?->competitor_id,
'competitor_name' => $repr?->competitor?->name,
'source_identifier' => $repr?->identifier,
'source_signal_type' => $repr?->signal_type,
'source_project_id' => $repr ? $d->project_id : null,
'source_is_active' => $repr ? (bool) $d->project?->is_active : null,
'source_extra_count' => max(0, $group->count() - 1),
];
})(),
]),
'limit' => $limit,
'next_cursor' => $nextCursor,
@@ -18,7 +18,7 @@ use Illuminate\Support\Facades\Auth;
/**
* Самозапись клиента (G1/SP1): register confirm-email (вход).
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
* pending_email_confirm, активируется и получает 300 при подтверждении.
* pending_email_confirm, активируется и получает 1000 при подтверждении.
*/
class RegistrationController extends Controller
{
@@ -35,7 +35,7 @@ class RegistrationService
private const MAX_FAILED_ATTEMPTS = 5;
private const START_BALANCE_RUB = '300.00';
private const START_BALANCE_RUB = '1000.00';
public function __construct(private readonly CaptchaVerifier $captcha) {}
@@ -83,6 +83,21 @@ final class CandidateBuilder
];
}
}
} else {
// без коллтрекинга: номера, написанные на сайте ПРОСТЫМ ТЕКСТОМ (не tel:/schema,
// не отрендеренные) — не теряем, а отдаём клиенту с пометкой «проверьте». На
// верифицированном сайте фирмы это чаще её линии, но без tel:-ссылки за 100%
// настоящие не выдаём. (Число вхождений не важно — номер уже в видимом тексте.)
foreach ($scan['body'] as $number => $count) {
$number = (string) $number;
if (in_array($number, $codeNumbers, true) || in_array($number, $visible, true)) {
continue;
}
$out[] = [
'number' => $number, 'kind' => 'text', 'label' => 'в тексте сайта — проверьте',
'url' => $site->url, 'office' => null, 'tracker' => false,
];
}
}
// номера филиалов со страницы «Контакты» (рендер)
@@ -65,8 +65,12 @@ final class HtmlPhoneScanner
// 'fragment' (обрезок полного номера) и 'skip' (не телефон) — игнорируем
}
// Номера в теле берём из ВИДИМОГО текста: вырезаем <script>/<style>, чтобы не тянуть
// технический мусор из конфигов/аналитики. Реальные номера из JSON-LD уже пойманы выше (schema).
$bodyHtml = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', ' ', $html) ?? $html;
$body = [];
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $html, $m)) {
if (preg_match_all('/(?:\+7|8)[\s(\-]*\d{3}[\s)\-]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}/', $bodyHtml, $m)) {
foreach ($m[0] as $x) {
$n = $this->normalizeMaybe($x);
if ($n !== null) {
@@ -38,15 +38,21 @@ final class SourceAggregator
$kinds = $info['kinds'];
$hasTrusted = (bool) array_intersect(self::TRUSTED, $kinds);
$hasDisplayed = in_array('displayed', $kinds, true);
$hasText = in_array('text', $kinds, true);
// короткий локальный номер без определимого кода города — «требует проверки»
$isUncertain = ! $hasTrusted && ! $hasDisplayed && in_array('uncertain', $kinds, true);
$onlyHidden = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain; // только pool-свалка
$isUncertain = ! $hasTrusted && ! $hasDisplayed && ! $hasText && in_array('uncertain', $kinds, true);
// номер только из текста сайта (не подтверждён tel:/справочником) — показываем «проверьте»
$isTextOnly = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain && $hasText;
$onlyHidden = ! $hasTrusted && ! $hasDisplayed && ! $isUncertain && ! $isTextOnly; // только pool-свалка
if ($onlyHidden) {
continue; // пул-свалка — не выводим
}
if ($isUncertain) {
$phoneKind = 'uncertain';
$phoneType = null; // тип не выдумываем — номер ещё не достроен
} elseif ($isTextOnly) {
$phoneKind = 'unverified'; // «на сайте — проверьте»
$phoneType = PhoneType::of($n); // номер полный — тип знаем
} else {
$phoneKind = $hasTrusted ? 'real' : 'substitute';
$phoneType = PhoneType::of($n);
@@ -16,6 +16,10 @@ final class CurlPlaywrightFetcher implements Fetcher
private string $firmScript = '', // путь к render-firm.cjs (карточка справочника с кликом)
private int $maxBranches = 25, // предел обхода филиалов за один сбор
private DirectoryParser $dirParser = new DirectoryParser,
// Сиды (seams) для тестов: подмена curl и запуска node-рендера, без реальной сети/подпроцесса.
// null → боевое поведение. fn(string $url): string и fn(string $script, string $url): ?array.
private ?\Closure $curlRunner = null,
private ?\Closure $nodeRunner = null,
) {
if ($this->renderScript === '') {
$this->renderScript = base_path('scripts/render-page.cjs');
@@ -94,6 +98,10 @@ final class CurlPlaywrightFetcher implements Fetcher
private function curl(string $url): string
{
if ($this->curlRunner !== null) {
return (string) ($this->curlRunner)($url);
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
@@ -141,21 +149,40 @@ final class CurlPlaywrightFetcher implements Fetcher
/** @return array{html:string,visiblePhones:list<string>}|null */
private function render(string $url): ?array
{
$p = new Process([$this->nodeBin, $this->renderScript, $url]);
$p->setTimeout($this->timeout);
$p->run();
if (! $p->isSuccessful()) {
// Рендер — НЕ фатальный: если node висит/падает по таймауту (Playwright ждёт networkidle
// на «шумном» сайте), Symfony Process бросает ProcessTimedOutException. Гасим его здесь,
// чтобы уже добытый curl-HTML (с номерами «в коде сайта») не потерялся. Возврат null =
// «видимых/рендер-номеров нет», обычные номера из curl остаются.
try {
return $this->runNode($this->renderScript, $url);
} catch (\Throwable) {
return null;
}
$json = json_decode($p->getOutput(), true);
return is_array($json) ? $json : null;
}
/** @return array{html:string}|null */
private function renderFirm(string $url): ?array
{
$p = new Process([$this->nodeBin, $this->firmScript, $url]);
try {
return $this->runNode($this->firmScript, $url);
} catch (\Throwable) {
return null;
}
}
/**
* Запуск node-скрипта рендера. Реальный Process при висе (Playwright ждёт networkidle на
* «шумном» сайте) бросает ProcessTimedOutException.
*
* @return array<string,mixed>|null
*/
private function runNode(string $script, string $url): ?array
{
if ($this->nodeRunner !== null) {
return ($this->nodeRunner)($script, $url);
}
$p = new Process([$this->nodeBin, $script, $url]);
$p->setTimeout($this->timeout);
$p->run();
if (! $p->isSuccessful()) {
@@ -22,9 +22,10 @@ use App\Services\Autopodbor\AutopodborNormalizer;
final class DeepStudyCollector implements SourceCollector
{
private const SRC_CHIP = ['code' => 'в коде сайта', 'displayed' => 'коллтрекинг',
'pool' => 'коллтрекинг (пул)', 'contacts' => 'страница «Контакты»', 'uncertain' => 'на сайте (код города?)'];
'pool' => 'коллтрекинг (пул)', 'contacts' => 'страница «Контакты»', 'uncertain' => 'на сайте (код города?)',
'text' => 'в тексте сайта — проверьте'];
/** @var array<string,array{sub:bool,src:array<string,?string>}> */
/** @var array<string,array{sub:bool,confirmed?:bool,src:array<string,?string>}> */
private array $phones = [];
public function __construct(
@@ -80,7 +81,9 @@ final class DeepStudyCollector implements SourceCollector
$siteUrl = 'https://'.$this->idn($domain).'/';
foreach ($r->numbers as $c) {
$sub = in_array($c['kind'], ['displayed', 'pool'], true);
$this->addPhone($c['number'], [(self::SRC_CHIP[$c['kind']] ?? $c['kind']) => $siteUrl, $this->idnUtf($domain) => $siteUrl], $sub);
// text/uncertain — не подтверждённые (без tel:/справочника): помечаем «проверьте»
$confirmed = ! in_array($c['kind'], ['text', 'uncertain'], true);
$this->addPhone($c['number'], [(self::SRC_CHIP[$c['kind']] ?? $c['kind']) => $siteUrl, $this->idnUtf($domain) => $siteUrl], $sub, $confirmed);
}
// отдельная строка-источник на КАЖДЫЙ сайт (близнецы — порознь)
$siteRows[] = [
@@ -145,9 +148,10 @@ final class DeepStudyCollector implements SourceCollector
foreach ($info['src'] as $label => $url) {
$src[] = ['label' => (string) $label, 'url' => $url];
}
$kind = $info['sub'] ? 'substitute' : (($info['confirmed'] ?? true) ? 'real' : 'unverified');
$callRows[] = [
'signal_type' => 'call', 'identifier' => $digits,
'phone_kind' => $info['sub'] ? 'substitute' : 'real',
'phone_kind' => $kind,
'phone_type' => PhoneType::of($digits),
'provenance_url' => $src[0]['url'] ?? null,
'provenance_label' => $src[0]['label'] ?? null,
@@ -264,13 +268,15 @@ final class DeepStudyCollector implements SourceCollector
}
/** @param array<string,?string> $chips */
private function addPhone(string $number, array $chips, bool $sub = false): void
private function addPhone(string $number, array $chips, bool $sub = false, bool $confirmed = true): void
{
$d = $this->norm->phone($number);
if (strlen($d) !== 11) {
return;
}
$this->phones[$d]['sub'] = ($this->phones[$d]['sub'] ?? false) || $sub;
// подтверждён, если хоть один источник надёжный (tel:/справочник/карточка); text-only → нет
$this->phones[$d]['confirmed'] = ($this->phones[$d]['confirmed'] ?? false) || $confirmed;
foreach ($chips as $label => $url) {
if ((string) $label !== '') {
$this->phones[$d]['src'][$label] = $url ?: ($this->phones[$d]['src'][$label] ?? null);
@@ -40,8 +40,14 @@ final class PhoneReverseExpander implements PhoneReverse
}
$seenDomains = [];
$ownDomains = []; // «свои» домены (бренд конкурента) — замороженные, для гейта «та же фирма»
foreach ($seedDomains as $d) {
$seenDomains[$this->norm->domainHead((string) $d)] = true;
$h = $this->norm->domainHead((string) $d);
if ($h === '') {
continue;
}
$seenDomains[$h] = true;
$ownDomains[$h] = true;
}
// Очередь телефонов на разворот. Личные номера фирмы — семя; сюда же кладём новые
@@ -78,6 +84,13 @@ final class PhoneReverseExpander implements PhoneReverse
continue;
}
foreach ($this->reader->card($html, $firmUrl) as $card) {
// Гейт «та же фирма»: карточка 2ГИС из ЧУЖОГО города (город берём из ссылки —
// 100% надёжно) и с ЧУЖИМ доменом — не наша фирма (баг AutoMoney: разворот по
// партнёрскому/иногороднему номеру тащит чужие МФО). Свой город ИЛИ свой бренд
// (домен/поддомен) — оставляем. Яндекс тут не участвует (только 2ГИС).
if ($this->isForeignFirm($firmUrl, $card->siteUrl, $gisSlug, $ownDomains)) {
continue;
}
if ($card->siteUrl !== null && $card->siteUrl !== '') {
$dom = $this->norm->domainHead($card->siteUrl);
if ($dom !== '' && ! isset($seenDomains[$dom])) {
@@ -123,6 +136,40 @@ final class PhoneReverseExpander implements PhoneReverse
return count($urls) > $this->crowdedCap ? [] : $urls;
}
/**
* Гейт «та же фирма» для карточки 2ГИС из разворота. Город берём из URL карточки
* (/2gis.ru/<city>/firm/) 100% надёжный признак. Возвращает true (чужая, пропустить),
* только если город определён и ОТЛИЧАЕТСЯ от города конкурента И домен карточки не наш бренд.
* Город не определить (не 2ГИС / нет города) или своя-марка false (не трогаем, оставляем).
*
* @param array<string,bool> $ownDomains домены-бренды конкурента (для исключения филиалов)
*/
private function isForeignFirm(string $firmUrl, ?string $siteUrl, string $ownCity, array $ownDomains): bool
{
if ($ownCity === '' || ! preg_match('#2gis\.ru/([a-z0-9_-]+)/firm#i', $firmUrl, $m)) {
return false; // город не определить — не рискуем
}
if (strtolower($m[1]) === strtolower($ownCity)) {
return false; // тот же город — наша
}
// другой город: оставляем только если это наш бренд (тот же домен или его поддомен/надомен)
$dom = ($siteUrl !== null && $siteUrl !== '') ? $this->norm->domainHead($siteUrl) : '';
return ! ($dom !== '' && $this->sameBrand($dom, $ownDomains));
}
/** Тот же бренд: точное совпадение домена ИЛИ поддомен/надомен (ekb.krk-finance.ru ≈ krk-finance.ru). */
private function sameBrand(string $dom, array $ownDomains): bool
{
foreach (array_keys($ownDomains) as $own) {
if ($dom === $own || str_ends_with($dom, '.'.$own) || str_ends_with($own, '.'.$dom)) {
return true;
}
}
return false;
}
private function ensureScheme(string $url): string
{
return preg_match('#^[a-z]+://#i', $url) ? $url : 'https://'.$url;
+6
View File
@@ -146,6 +146,12 @@ return [
'widget_url_template' => env('JIVO_WIDGET_URL_TEMPLATE', 'https://code.jivo.ru/widget/{id}'),
],
// Яндекс.Метрика портала (lk.liderra.ru). Пусто = Метрика не грузится.
// Вебвизор пишет только кабинет клиента (см. resources/js/plugins/metrika.ts + router).
'metrika' => [
'counter_id' => env('METRIKA_COUNTER_ID'),
],
// Платёжный шлюз ЮKassa. webhook_ip_allowlist — CSV IP/CIDR из env (defense-in-depth
// на /api/webhook/payment). Пусто → fail-open (поток не ломается). На проде заполнить
// опубликованными ЮKassa подсетями: 185.71.76.0/27,185.71.77.0/27,77.75.153.0/25,
+2 -2
View File
@@ -135,7 +135,7 @@ export interface SourceDto {
competitor_id: number;
signal_type: 'site' | 'call';
identifier: string;
phone_kind: 'real' | 'substitute' | null;
phone_kind: 'real' | 'substitute' | 'unverified' | 'uncertain' | null;
phone_type: PhoneType;
box: Box;
provenance_url: string | null;
@@ -424,7 +424,7 @@ export async function deleteCompetitor(id: number): Promise<void> {
export interface SourcePatch {
identifier?: string;
phone_kind?: 'real' | 'substitute' | null;
phone_kind?: 'real' | 'substitute' | 'unverified' | 'uncertain' | null;
phone_type?: PhoneType;
provenance_url?: string | null;
provenance_label?: string | null;
+8
View File
@@ -171,6 +171,14 @@ export interface ApiDeal {
project_signal_identifier?: string | null;
project_sms_keyword?: string | null;
project_sms_senders?: string[] | null;
// Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок».
competitor_id?: number | null;
competitor_name?: string | null;
source_identifier?: string | null;
source_signal_type?: string | null;
source_project_id?: number | null;
source_is_active?: boolean | null;
source_extra_count?: number;
}
export interface ApiDealEvent {
@@ -70,4 +70,24 @@ onMounted(() => {
color: #0f6e56;
white-space: nowrap;
}
/* Телефон: три колонки не помещаются — название крошилось по слову.
Раскладываем: название на всю ширину, ниже строкой «когда списываем» + цена. */
@media (max-width: 599.98px) {
.ap-row {
flex-wrap: wrap;
gap: 4px 12px;
}
.ap-row__name {
flex: 1 1 100%;
}
.ap-row__when {
min-width: 0;
text-align: left;
flex: 0 1 auto;
}
.ap-row__price {
margin-left: auto;
}
}
</style>
@@ -137,6 +137,11 @@ const runwaySub = computed(() =>
.chain-cards {
flex-direction: column;
}
/* В столбце плитки — по высоте контента (иначе flex-basis:0 схлопывал их,
а overflow:hidden обрезал цифры/кнопки — «Кошелёк/Лиды/Запас» пустели). */
.chain-cards > .wallet-card {
flex: 0 0 auto;
}
.chain-arrow {
transform: rotate(90deg);
padding: 8px 0;
@@ -167,4 +167,34 @@ defineExpose({ load, invoices });
gap: 4px;
justify-content: flex-end;
}
/* Телефон: 4 колонки (дата | номер | сумма | кнопки) не влезают — при кнопках
«Счёт»+«Акт» номер крошился, а кнопки уезжали за край. Раскладываем в ярусы:
номер+сумма, дата, кнопки во всю ширину. */
@media (max-width: 599.98px) {
.inv-row {
grid-template-columns: 1fr auto;
grid-template-areas:
'name amount'
'when when'
'actions actions';
row-gap: 6px;
}
.inv-when {
grid-area: when;
}
.inv-name {
grid-area: name;
}
.inv-amount {
grid-area: amount;
text-align: right;
align-self: start;
}
.inv-actions {
grid-area: actions;
justify-content: flex-start;
flex-wrap: wrap;
}
}
</style>
@@ -6,6 +6,8 @@
*
* Billing v2 Spec A §3.4.8.
*/
import { tierRangeText } from '../../utils/pricingTiers';
interface TierPreview {
tier_no: number;
leads_in_tier: number | null;
@@ -18,14 +20,7 @@ const props = defineProps<{
}>();
function rangeText(idx: number): string {
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = props.tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = props.tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
return tierRangeText(props.tiers, idx);
}
</script>
@@ -12,6 +12,10 @@ import { ref, computed, watch } from 'vue';
import { topup, createInvoice } from '../../api/billing';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
import { redirectTo } from '../../utils/redirect';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: диалог на весь экран (плитки сумм/способы оплаты — шире узкого модала).
const { isPhone } = useLayoutTier();
const model = defineModel<boolean>({ required: true });
const emit = defineEmits<{ success: [balanceRub: string]; invoiced: [invoiceNumber: string] }>();
@@ -95,7 +99,7 @@ defineExpose({ method, amount, submit, canSubmit, errorMsg });
</script>
<template>
<v-dialog v-model="model" max-width="460">
<v-dialog v-model="model" max-width="460" :fullscreen="isPhone" scrollable>
<v-card>
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
<v-card-text>
@@ -4,9 +4,10 @@
* (Все / Пополнения / Списания / Возвраты). Данные — GET
* /api/billing/transactions (E3). Паттерн self-fetching из ChargesTab.
*/
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { getTransactions, type BillingTransaction } from '../../api/billing';
import { formatCost, txAmountClass } from '../../composables/billingFormatters';
import ResponsiveTable from '../common/ResponsiveTable.vue';
interface Tab {
id: string;
@@ -43,6 +44,7 @@ const OP_LABELS: Record<string, string> = {
lead_charge: 'Списание за лид',
topup: 'Пополнение баланса',
migration: 'Конвертация лидов в ₽',
autopodbor_charge: 'Списание за автоподбор',
};
function opLabel(tx: BillingTransaction): string {
@@ -100,6 +102,15 @@ async function loadOptions(opts: { page: number }): Promise<void> {
await load();
}
// Пейджер для карточек (телефон): страниц по 20, как у таблицы.
const PER_PAGE = 20;
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PER_PAGE)));
async function goToPage(p: number): Promise<void> {
page.value = p;
await load();
}
async function refresh(): Promise<void> {
page.value = 1;
await load();
@@ -125,30 +136,65 @@ defineExpose({ load, refresh, changeTab, activeTab, total, rows });
{{ loadError }}
</v-alert>
<v-data-table-server
:headers="headers"
:items="rows"
:items-length="total"
:loading="loading"
:items-per-page="20"
density="comfortable"
@update:options="loadOptions"
>
<template #[`item.created_at`]="{ item }">
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
<ResponsiveTable :items="rows">
<template #table>
<v-data-table-server
:headers="headers"
:items="rows"
:items-length="total"
:loading="loading"
:items-per-page="20"
density="comfortable"
@update:options="loadOptions"
>
<template #[`item.created_at`]="{ item }">
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
</template>
<template #[`item.description`]="{ item }">
<span class="tx-op">{{ opLabel(item) }}</span>
</template>
<template #[`item.code`]="{ item }">
<span class="tx-id">#{{ item.code }}</span>
</template>
<template #[`item.amount_rub`]="{ item }">
<span class="num" :class="txAmountClass(txAmountValue(item))">
{{ txAmountText(item) }}
</span>
</template>
</v-data-table-server>
</template>
<template #[`item.description`]="{ item }">
<span class="tx-op">{{ opLabel(item) }}</span>
<template #card>
<div class="tx-cards">
<article v-for="tx in rows" :key="tx.code" class="tx-card">
<div class="tx-card__top">
<span class="tx-card__op">{{ opLabel(tx) }}</span>
<span class="num tx-card__amount" :class="txAmountClass(txAmountValue(tx))">{{
txAmountText(tx)
}}</span>
</div>
<div class="tx-card__meta">
<span class="tx-when num">{{ formatWhen(tx.created_at) }}</span>
<span class="tx-id">#{{ tx.code }}</span>
</div>
</article>
<div v-if="rows.length === 0 && !loading" class="tx-empty text-medium-emphasis">
Нет транзакций
</div>
<v-pagination
v-if="pageCount > 1"
:model-value="page"
:length="pageCount"
:total-visible="5"
density="comfortable"
class="tx-pager"
@update:model-value="goToPage"
/>
</div>
</template>
<template #[`item.code`]="{ item }">
<span class="tx-id">#{{ item.code }}</span>
</template>
<template #[`item.amount_rub`]="{ item }">
<span class="num" :class="txAmountClass(txAmountValue(item))">
{{ txAmountText(item) }}
</span>
</template>
</v-data-table-server>
</ResponsiveTable>
</v-card>
</template>
@@ -192,4 +238,48 @@ defineExpose({ load, refresh, changeTab, activeTab, total, rows });
.tx-amount-neutral {
color: #66635c;
}
/* Карточки транзакций (телефон) */
.tx-cards {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px 16px;
}
.tx-card {
border: 1px solid rgba(1, 32, 25, 0.1);
border-radius: 12px;
padding: 12px 14px;
background: #fff;
}
.tx-card__top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.tx-card__op {
font-weight: 500;
color: #081319;
font-size: 14px;
min-width: 0;
}
.tx-card__amount {
font-weight: 700;
white-space: nowrap;
}
.tx-card__meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-top: 6px;
}
.tx-empty {
text-align: center;
padding: 24px 0;
}
.tx-pager {
margin-top: 4px;
}
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
/**
* Обёртка: десктоп (>=1280) — слот #table (обычный v-data-table),
* телефон и планшет (<1280) — слот #card (список карточек по каждому item).
* Широкие таблицы (Сделки/Списания) не влезают в узкую колонку планшета-портрета
* рядом с rail-меню — правые колонки обрезались, поэтому на планшете тоже карточки.
* Данные прокидываются через слот-пропсы, чтобы вызывающий сам рисовал карточку.
*/
import { useLayoutTier } from '../../composables/useLayoutTier';
defineProps<{ items: unknown[] }>();
const { isDesktop } = useLayoutTier();
</script>
<template>
<div>
<template v-if="isDesktop">
<slot name="table" :items="items" />
</template>
<template v-else>
<slot name="card" :items="items" />
</template>
</div>
</template>
@@ -109,4 +109,16 @@ const avgCostLabel = computed(() => {
font-feature-settings: 'tnum';
font-weight: 500;
}
/* Телефон: переключатель периода — на всю ширину, кнопки поровну (без внутренней прокрутки). */
@media (max-width: 599.98px) {
.page-head :deep(.v-btn-toggle) {
width: 100%;
}
.page-head :deep(.v-btn-toggle) > .v-btn {
flex: 1 1 0;
min-width: 0;
padding-inline: 6px;
}
}
</style>
@@ -6,9 +6,14 @@
* Тело — общий DealDetailBody.vue.
*/
import { computed } from 'vue';
import { useDisplay } from 'vuetify';
import type { MockDeal } from '../../composables/mockDeals';
import DealDetailBody from './DealDetailBody.vue';
// Телефон: overlay-панель на весь экран (фикс. 480px был шире экрана).
const { width, xs } = useDisplay();
const drawerWidth = computed(() => (xs.value ? width.value : 480));
const props = withDefaults(
defineProps<{
open: boolean;
@@ -43,7 +48,7 @@ function close() {
@status-changed="(s: string) => emit('status-changed', s)"
/>
</aside>
<v-navigation-drawer v-else v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
<v-navigation-drawer v-else v-model="drawerOpen" location="right" temporary :width="drawerWidth" class="deal-drawer">
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@@ -69,4 +74,18 @@ function close() {
position: sticky;
top: 16px;
}
/* Телефон: панель деталей — полноэкранный оверлей поверх списка
(фикс. 400px рядом со списком давал горизонтальный скролл и крошил карточки). */
@media (max-width: 599.98px) {
.deal-detail-inline {
position: fixed;
inset: 0;
z-index: 1200;
width: 100%;
max-height: none;
border: none;
border-radius: 0;
overflow-y: auto;
}
}
</style>
@@ -35,11 +35,11 @@ function formatRelative(minutes: number): string {
<header class="hero pa-5">
<div class="hero-eyebrow text-caption text-medium-emphasis">Сделка #{{ deal.id }}</div>
<div class="hero-row mt-1">
<h2 class="hero-name text-h5">{{ deal.name }}</h2>
<h2 class="hero-name text-h5 ym-hide-content">{{ deal.name }}</h2>
<v-btn icon="mdi-close" variant="text" size="small" aria-label="Закрыть панель" @click="$emit('close')" />
</div>
<div class="hero-meta mt-2">
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link">{{ deal.phone }}</a>
<a :href="`tel:${deal.phone.replace(/[^+\d]/g, '')}`" class="phone-link ym-hide-content">{{ deal.phone }}</a>
<span class="sep">·</span>
<span class="text-caption text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-clock-outline</v-icon>
@@ -37,7 +37,7 @@ const hasActiveFilter = () => props.filterStatus !== null || props.filterProject
density="compact"
hide-details
clearable
class="filters-search"
class="filters-search ym-disable-keys"
data-testid="filter-search-phone"
@update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
/>
+243 -11
View File
@@ -8,6 +8,7 @@ import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import { stripChannelPrefix } from '../../composables/projectName';
import StatusPill from '../ui/StatusPill.vue';
import ResponsiveTable from '../common/ResponsiveTable.vue';
const props = withDefaults(
defineProps<{
@@ -30,6 +31,26 @@ function signalLabel(t: MockDeal['signalType']): string {
return t ? (SIGNAL_LABELS[t] ?? '') : '';
}
// Метка канала в колонке «Источник»: канал источника автоподбора, иначе — канал проекта.
function sourceSignalLabel(deal: MockDeal): string {
const t = deal.sourceSignalType ?? deal.signalType;
return t ? (SIGNAL_LABELS[t] ?? '') : '';
}
// Есть ли у сделки источник автоподбора (для карточки телефона: показывать
// отдельную строку «Источник» только когда есть что показать).
function hasSource(deal: MockDeal): boolean {
return Boolean(deal.sourceIdentifier || deal.sourceProjectId);
}
// «Конкурент» — только бренд, без описания: имя конкурента хранится как
// «Бренд, описание…», в колонке показываем часть до первой запятой.
function competitorShort(name: string | null | undefined): string {
if (!name) return '';
const i = name.indexOf(',');
return (i === -1 ? name : name.slice(0, i)).trim();
}
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
@@ -45,16 +66,29 @@ function formatDateTime(iso: string | null | undefined): string {
function rowProps(deal: MockDeal): Record<string, unknown> {
return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
}
// Выбор лида на карточке (телефон) — тот же контракт, что чекбоксы таблицы.
function isSelected(id: number): boolean {
return props.selectedIds.includes(id);
}
function toggleSelect(id: number): void {
const next = isSelected(id) ? props.selectedIds.filter((x) => x !== id) : [...props.selectedIds, id];
emit('update:selectedIds', next);
}
</script>
<template>
<v-card variant="outlined" class="deals-table-card">
<ResponsiveTable :items="deals">
<template #table>
<v-data-table
:model-value="selectedIds"
:items="deals"
:headers="[
{ title: 'Телефон', key: 'phone', sortable: true },
{ title: 'Источник', key: 'project', sortable: false },
{ title: 'Конкурент', key: 'competitor', sortable: false },
{ title: 'Источник', key: 'source', sortable: false },
{ title: 'Город', key: 'city', sortable: false },
{ title: 'Статус', key: 'statusSlug', sortable: false },
{ title: 'Комментарий', key: 'comment', sortable: false },
@@ -70,15 +104,36 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
>
<template #[`item.phone`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ item.phone }}</span>
<span class="num ld-mono ym-hide-content">{{ item.phone }}</span>
</template>
<template #[`item.project`]="{ item }: { item: MockDeal }">
<template #[`item.competitor`]="{ item }: { item: MockDeal }">
<router-link
v-if="item.competitorId"
class="cell-link"
:title="item.competitorName ?? undefined"
:to="{ name: 'autopodbor', query: { competitor: String(item.competitorId) } }"
@click.stop
>{{ competitorShort(item.competitorName) }}</router-link
>
<span v-else class="text-medium-emphasis">—</span>
</template>
<template #[`item.source`]="{ item }: { item: MockDeal }">
<div class="cell-source">
<span class="source-project">{{ stripChannelPrefix(item.project) }}</span>
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
signalLabel(item.signalType)
}}</span>
<router-link
v-if="item.sourceProjectId"
class="cell-link source-project"
:to="{
name: 'autopodbor',
query: { competitor: item.competitorId ? String(item.competitorId) : undefined, project: String(item.sourceProjectId) },
}"
@click.stop
>{{ item.sourceIdentifier
}}<span v-if="item.sourceExtraCount"> · +{{ item.sourceExtraCount }}</span></router-link
>
<span v-else class="source-project">{{ stripChannelPrefix(item.project) }}</span>
<span v-if="sourceSignalLabel(item)" class="source-signal">{{ sourceSignalLabel(item) }}</span>
</div>
</template>
@@ -94,9 +149,11 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
</template>
<template #[`item.comment`]="{ item }: { item: MockDeal }">
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
item.comment || '—'
}}</span>
<span
class="cell-comment ym-hide-content"
:class="{ 'text-medium-emphasis': !item.comment }"
>{{ item.comment || '—' }}</span
>
</template>
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
@@ -115,11 +172,81 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
<v-checkbox-btn
:model-value="isSelected(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
:aria-label="`Выбрать сделку #${(item as MockDeal).id}`"
@update:model-value="() => toggleSelect(internalItem)"
/>
</template>
</v-data-table>
</template>
<template #card>
<div class="deals-cards">
<article
v-for="deal in deals"
:key="deal.id"
class="deal-card"
:class="{ 'deal-card--active': deal.id === activeDealId }"
@click="emit('row-click', deal)"
>
<div class="deal-card__top">
<v-checkbox-btn
:model-value="isSelected(deal.id)"
density="compact"
hide-details
class="deal-card__check"
:aria-label="`Выбрать сделку #${deal.id}`"
@click.stop
@update:model-value="() => toggleSelect(deal.id)"
/>
<span class="deal-card__phone num ld-mono ym-hide-content">{{ deal.phone }}</span>
<StatusPill
:slug="deal.statusSlug"
:label="statusBySlug.get(deal.statusSlug)?.nameRu ?? deal.statusSlug"
/>
</div>
<div class="deal-card__meta">
<router-link
v-if="deal.competitorId"
class="deal-card__competitor"
:title="deal.competitorName ?? undefined"
:to="{ name: 'autopodbor', query: { competitor: String(deal.competitorId) } }"
@click.stop
>{{ competitorShort(deal.competitorName) }}</router-link
>
<span v-else class="deal-card__project">{{ stripChannelPrefix(deal.project) }}</span>
<span v-if="!hasSource(deal) && signalLabel(deal.signalType)" class="deal-card__signal">{{
signalLabel(deal.signalType)
}}</span>
<span class="deal-card__dot">·</span>
<span :class="{ 'text-medium-emphasis': !deal.city }">{{ deal.city || '—' }}</span>
</div>
<div v-if="hasSource(deal)" class="deal-card__source-row">
<router-link
v-if="deal.sourceProjectId"
class="deal-card__source-link"
:to="{
name: 'autopodbor',
query: {
competitor: deal.competitorId ? String(deal.competitorId) : undefined,
project: String(deal.sourceProjectId),
},
}"
@click.stop
>{{ deal.sourceIdentifier
}}<span v-if="deal.sourceExtraCount"> · +{{ deal.sourceExtraCount }}</span></router-link
>
<span v-else class="deal-card__source-plain">{{ deal.sourceIdentifier }}</span>
<template v-if="sourceSignalLabel(deal)">
<span class="deal-card__dot">·</span>
<span class="deal-card__signal">{{ sourceSignalLabel(deal) }}</span>
</template>
</div>
<div v-if="deal.comment" class="deal-card__comment ym-hide-content">{{ deal.comment }}</div>
<div class="deal-card__foot num ld-mono-s">{{ formatDateTime(deal.receivedAt) }}</div>
</article>
</div>
</template>
</ResponsiveTable>
<div v-if="deals.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
Нет сделок по выбранным фильтрам
@@ -148,6 +275,15 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
font-size: 11px;
color: #6b6356;
}
.cell-link {
color: rgb(var(--v-theme-primary));
text-decoration: none;
font-weight: 500;
cursor: pointer;
}
.cell-link:hover {
text-decoration: underline;
}
.cell-comment {
display: inline-block;
max-width: 240px;
@@ -159,4 +295,100 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
:deep(.deals-row-active) {
background: rgba(15, 110, 86, 0.07);
}
/* Карточки лидов (телефон) */
.deals-cards {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
}
.deal-card {
border: 1px solid rgba(1, 32, 25, 0.1);
border-radius: 14px;
padding: 14px 16px;
background: #fff;
cursor: pointer;
transition: border-color 150ms ease;
}
.deal-card:active {
border-color: rgba(15, 110, 86, 0.5);
}
.deal-card--active {
border-color: var(--liderra-teal);
box-shadow: 0 0 0 1px var(--liderra-teal);
}
.deal-card__top {
display: flex;
align-items: center;
gap: 8px;
}
.deal-card__check {
flex: 0 0 auto;
margin-left: -6px;
}
.deal-card__phone {
font-weight: 700;
flex: 1 1 auto;
min-width: 0;
}
.deal-card__meta {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
color: rgba(1, 32, 25, 0.6);
font-size: 13px;
margin-top: 8px;
}
.deal-card__project {
font-weight: 500;
color: #081319;
}
/* Конкурент — ссылка в автоподбор (как в колонке «Конкурент» на десктопе). */
.deal-card__competitor {
font-weight: 600;
color: rgb(var(--v-theme-primary));
text-decoration: none;
}
.deal-card__competitor:active {
text-decoration: underline;
}
.deal-card__signal {
font-size: 11px;
color: #6b6356;
}
/* Источник — отдельная строка под именем: домен/идентификатор + канал. */
.deal-card__source-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
font-size: 12px;
}
.deal-card__source-link {
color: rgb(var(--v-theme-primary));
text-decoration: none;
font-weight: 500;
}
.deal-card__source-link:active {
text-decoration: underline;
}
.deal-card__source-plain {
color: rgba(1, 32, 25, 0.7);
}
.deal-card__dot {
color: rgba(1, 32, 25, 0.3);
}
.deal-card__comment {
font-size: 13px;
margin-top: 6px;
color: rgba(1, 32, 25, 0.75);
}
.deal-card__foot {
font-size: 12px;
color: rgba(1, 32, 25, 0.5);
margin-top: 10px;
}
</style>
@@ -15,6 +15,10 @@ import { extractErrorMessage } from '../../api/client';
import { ref, watch } from 'vue';
import { LEAD_STATUSES } from '../../composables/leadStatuses';
import { type MockDeal, type MockManager } from '../../composables/mockDeals';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: диалог на весь экран (узкий модал резал поля).
const { isPhone } = useLayoutTier();
/**
* Списки проектов и менеджеров грузятся с backend через GET /api/projects,
@@ -184,7 +188,7 @@ function close() {
</script>
<template>
<v-dialog v-model="dialogOpen" :max-width="560" data-testid="new-deal-dialog">
<v-dialog v-model="dialogOpen" :max-width="560" :fullscreen="isPhone" scrollable data-testid="new-deal-dialog">
<v-card>
<v-card-title>Новая сделка</v-card-title>
<v-card-text>
@@ -216,6 +220,7 @@ function close() {
variant="outlined"
density="comfortable"
autofocus
class="ym-disable-keys"
:error-messages="errors.name ? [errors.name] : []"
data-testid="field-name"
/>
@@ -228,6 +233,7 @@ function close() {
variant="outlined"
density="comfortable"
inputmode="tel"
class="ym-disable-keys"
:error-messages="errors.phone ? [errors.phone] : []"
data-testid="field-phone"
/>
@@ -7,6 +7,10 @@
*/
import { computed, reactive, ref } from 'vue';
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: на весь экран (таблица сопоставления статусов широкая).
const { isPhone } = useLayoutTier();
const props = defineProps<{
modelValue: boolean;
@@ -62,7 +66,7 @@ defineExpose({ selection, save });
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="640">
<v-dialog v-model="dialogOpen" max-width="640" :fullscreen="isPhone" scrollable>
<v-card>
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
<v-card-text>
@@ -25,8 +25,8 @@ function formatCost(cost: number): string {
density="compact"
@click="emit('open', deal.id)"
>
<div class="card-name">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
<div class="card-name ym-hide-content">{{ deal.name }}</div>
<div class="card-phone text-caption text-medium-emphasis ym-hide-content">{{ deal.phone }}</div>
<div class="card-meta mt-2">
<span class="card-project text-caption">{{ stripChannelPrefix(deal.project) }}</span>
<span class="card-cost num">{{ deal.costKopecks != null ? formatCost(deal.costKopecks / 100) : '—' }}</span>
@@ -0,0 +1,59 @@
<script setup lang="ts">
/** Нижняя панель-вкладки для телефона. 5 главных разделов; остальное — в «Ещё». */
import { RouterLink, useRoute } from 'vue-router';
import { computed } from 'vue';
interface Tab { title: string; to: string; }
const tabs: Tab[] = [
{ title: 'Поле', to: '/autopodbor' },
{ title: 'Проекты', to: '/projects' },
{ title: 'Сделки', to: '/deals' },
{ title: 'Биллинг', to: '/billing' },
{ title: 'Канбан', to: '/kanban' },
];
const route = useRoute();
const activeTo = computed(() => tabs.find((t) => route.path === t.to)?.to ?? '');
</script>
<template>
<nav class="ld-bottomnav" aria-label="Основная навигация">
<RouterLink
v-for="t in tabs"
:key="t.to"
:to="t.to"
data-testid="bottomnav-item"
class="ld-bottomnav__item"
:class="{ 'ld-bottomnav__item--active': activeTo === t.to }"
>
<span class="ld-bottomnav__label">{{ t.title }}</span>
</RouterLink>
</nav>
</template>
<style scoped>
.ld-bottomnav {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1010;
display: flex;
height: 64px;
padding-bottom: env(safe-area-inset-bottom);
background: #ffffff;
border-top: 1px solid rgba(1, 32, 25, 0.1);
}
.ld-bottomnav__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-height: 56px;
text-decoration: none;
color: rgba(1, 32, 25, 0.55);
font-size: 11px;
font-weight: 600;
}
.ld-bottomnav__item--active { color: var(--liderra-teal); }
.ld-bottomnav__label { line-height: 1; }
</style>
@@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* Выезжающее «Ещё» для телефона: разделы вне 5 нижних вкладок.
* Ни один экран не выкидываем — Настройки (реквизиты), Отчёты, Импорт, Помощь, Дашборд
* доступны отсюда. Полнота функций — на десктопе; на телефоне вкладка работает, но урезана.
*/
const open = defineModel<boolean>('open', { default: false });
interface Item {
title: string;
to: string;
}
const items: Item[] = [
{ title: 'Дашборд', to: '/dashboard' },
{ title: 'Отчёты', to: '/reports' },
{ title: 'Настройки', to: '/settings' },
{ title: 'Импорт данных', to: '/import' },
{ title: 'Помощь', to: '/help' },
];
</script>
<template>
<v-navigation-drawer v-model="open" temporary location="right" width="280">
<v-list nav>
<v-list-item
v-for="i in items"
:key="i.to"
:to="i.to"
:title="i.title"
data-testid="more-item"
@click="open = false"
/>
</v-list>
</v-navigation-drawer>
</template>
@@ -30,6 +30,9 @@ interface NavGroup {
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
// Ярус экрана из AppLayout: на телефоне сайдбар скрыт (навигацию берёт нижняя панель-вкладки).
defineProps<{ tier?: 'phone' | 'tablet' | 'desktop' }>();
const route = useRoute();
const auth = useAuthStore();
const dealsCount = useDealsCountStore();
@@ -81,7 +84,7 @@ defineExpose({ navGroups });
</script>
<template>
<aside class="ld-sidebar" :data-open="drawerOpen">
<aside class="ld-sidebar" :data-open="drawerOpen" :data-tier="tier">
<div class="ld-sidebar__brand">
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
</div>
@@ -110,6 +113,7 @@ defineExpose({ navGroups });
:class="{ 'ld-nav-item--active': route.path === item.to }"
:data-tour="`nav-${item.to.replace('/', '')}`"
>
<span class="ld-nav-item__icon"><v-icon :icon="item.icon" size="20" /></span>
<span class="ld-nav-item__title">{{ item.title }}</span>
<span v-if="item.badge" class="ld-nav-item__new">{{ item.badge }}</span>
<span
@@ -140,6 +144,44 @@ defineExpose({ navGroups });
overflow-y: auto;
}
/* Телефон: сайдбара нет — навигацию берёт нижняя панель-вкладки (AppBottomNav). */
.ld-sidebar[data-tier='phone'] {
display: none;
}
/* Иконка пункта — по умолчанию скрыта (десктоп остаётся текстовым, как был). */
.ld-nav-item__icon {
display: none;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
/* Планшет: узкая колонка-иконки (rail). Прячем подписи/поиск/бренд-текст, показываем иконки. */
.ld-sidebar[data-tier='tablet'] {
width: 64px;
padding: 20px 8px;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand-name,
.ld-sidebar[data-tier='tablet'] .ld-cmdk-stub,
.ld-sidebar[data-tier='tablet'] .ld-nav-group__eyebrow,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__title,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__badge,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__new {
display: none;
}
.ld-sidebar[data-tier='tablet'] .ld-nav-item__icon {
display: inline-flex;
}
.ld-sidebar[data-tier='tablet'] .ld-nav-item {
justify-content: center;
padding: 10px 0;
gap: 0;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand {
height: 22px;
}
.ld-sidebar__brand {
font-size: 15px;
font-weight: 600;
@@ -89,7 +89,7 @@ async function handleLogout(): Promise<void> {
<template>
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
<v-app-bar-nav-icon class="d-md-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
<v-app-bar-nav-icon class="d-sm-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
<div class="crumb">
<strong>{{ pageTitle }}</strong>
@@ -203,9 +203,21 @@ async function handleLogout(): Promise<void> {
color: #e8e2d4 !important;
}
.app-topbar :deep(.v-toolbar__content) {
padding-left: 240px;
padding-left: 240px; /* десктоп — clear под сайдбар 232px */
color: #e8e2d4;
}
/* Планшет — clear под rail 64px */
@media (max-width: 1279.98px) {
.app-topbar :deep(.v-toolbar__content) {
padding-left: 72px;
}
}
/* Телефон — гамбургер слева, без отступа под сайдбар */
@media (max-width: 599.98px) {
.app-topbar :deep(.v-toolbar__content) {
padding-left: 4px;
}
}
.app-topbar :deep(.v-icon) {
color: #b8b0a0;
}
@@ -226,6 +238,12 @@ async function handleLogout(): Promise<void> {
color: #b8b0a0 !important;
border-color: rgba(255, 255, 255, 0.12) !important;
}
/* Телефон: длинный заголовок вплотную к «Поиску» — небольшой зазор слева. */
@media (max-width: 599.98px) {
.searchbar {
margin-left: 10px;
}
}
.search-kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
@@ -1,11 +1,11 @@
<template>
<v-dialog v-model="open" max-width="560">
<v-dialog v-model="open" max-width="560" :fullscreen="isPhone" scrollable>
<v-card>
<v-card-title>Дни сбора лидов для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить дни</div>
<div class="d-flex ga-2">
<div class="d-flex ga-2 day-row">
<v-btn
v-for="d in WEEKDAYS"
:key="`add-${d.bit}`"
@@ -20,7 +20,7 @@
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать дни</div>
<div class="d-flex ga-2">
<div class="d-flex ga-2 day-row">
<v-btn
v-for="d in WEEKDAYS"
:key="`remove-${d.bit}`"
@@ -48,6 +48,10 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { WEEKDAYS } from '../../constants/weekdays';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: на весь экран (ряд кнопок дней недели не влезал в узкий модал).
const { isPhone } = useLayoutTier();
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
@@ -91,3 +95,18 @@ function apply() {
open.value = false;
}
</script>
<style scoped>
/* Телефон: 7 кнопок дней (ПН…ВС) в d-flex не влезали — СБ/ВС уезжали за край.
Делим ряд поровну между кнопками, чтобы все семь помещались в ширину. */
@media (max-width: 599.98px) {
.day-row {
width: 100%;
}
.day-row > .v-btn {
flex: 1 1 0;
min-width: 0;
padding-inline: 0;
}
}
</style>
@@ -1,5 +1,5 @@
<template>
<v-dialog v-model="open" max-width="480">
<v-dialog v-model="open" max-width="480" :fullscreen="isPhone" scrollable>
<v-card>
<v-card-title>Лимит лидов для {{ count }} проектов</v-card-title>
<v-card-text>
@@ -57,6 +57,10 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: на весь экран (единообразно с «Дни/Регионы оптом»).
const { isPhone } = useLayoutTier();
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
@@ -3,7 +3,7 @@ import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import axios from 'axios';
import type { Project, BalancePayload } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { REGIONS_ALPHA, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { formatLeadDate, firstLeadDate } from '../../utils/leadDate';
const props = defineProps<{ project: Project | null }>();
@@ -29,7 +29,7 @@ const form = reactive<FormState>({
signal_identifier: '',
});
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
const selectableRegions = REGIONS_ALPHA;
function reseedFromProject(p: Project | null): void {
if (!p) return;
@@ -1,5 +1,5 @@
<template>
<v-dialog v-model="open" max-width="560">
<v-dialog v-model="open" max-width="560" :fullscreen="isPhone" scrollable>
<v-card>
<v-card-title>Регионы для {{ count }} проектов</v-card-title>
<v-card-text>
@@ -75,7 +75,11 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { REGIONS_ALPHA, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { useLayoutTier } from '../../composables/useLayoutTier';
// Телефон: на весь экран (сетка регионов шире узкого модала).
const { isPhone } = useLayoutTier();
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
@@ -84,7 +88,8 @@ const emit = defineEmits<{
}>();
// code:0 — sentinel «Вся РФ»; в bulk add/remove субъектов не выбирается.
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
// REGIONS_ALPHA уже без code 0, сортировка по сути названия.
const selectableRegions = REGIONS_ALPHA;
const open = ref(props.modelValue);
const addRegions = ref<number[]>([]);
@@ -97,5 +97,12 @@ export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
projectSignalIdentifier: api.project_signal_identifier ?? null,
projectSmsKeyword: api.project_sms_keyword ?? null,
projectSmsSenders: api.project_sms_senders ?? null,
competitorId: api.competitor_id ?? null,
competitorName: api.competitor_name ?? null,
sourceIdentifier: api.source_identifier ?? null,
sourceSignalType: (api.source_signal_type as MockDeal['sourceSignalType']) ?? null,
sourceProjectId: api.source_project_id ?? null,
sourceIsActive: api.source_is_active ?? null,
sourceExtraCount: api.source_extra_count ?? 0,
};
}
@@ -29,6 +29,14 @@ export interface MockDeal {
projectSignalIdentifier?: string | null;
projectSmsKeyword?: string | null;
projectSmsSenders?: string[] | null;
// Автоподбор (2026-07-08): конкурент + источник (колонки «Сделок»).
competitorId?: number | null;
competitorName?: string | null;
sourceIdentifier?: string | null;
sourceSignalType?: 'call' | 'site' | 'sms' | null;
sourceProjectId?: number | null;
sourceIsActive?: boolean | null;
sourceExtraCount?: number;
}
export const MOCK_DEALS: MockDeal[] = [
@@ -0,0 +1,31 @@
import { computed, type ComputedRef } from 'vue';
import { useDisplay } from 'vuetify';
export type LayoutTier = 'phone' | 'tablet' | 'desktop';
/**
* Единый источник «яруса» экрана для адаптива портала.
* phone < 600px (нижнее меню-вкладки)
* tablet 600..1279px (узкая колонка-иконки / rail)
* desktop >= 1280px (полный сайдбар 232px, как сейчас)
*/
export function useLayoutTier(): {
tier: ComputedRef<LayoutTier>;
isPhone: ComputedRef<boolean>;
isTablet: ComputedRef<boolean>;
isDesktop: ComputedRef<boolean>;
} {
const display = useDisplay();
const tier = computed<LayoutTier>(() => {
const w = display.width.value;
if (w < 600) return 'phone';
if (w < 1280) return 'tablet';
return 'desktop';
});
return {
tier,
isPhone: computed(() => tier.value === 'phone'),
isTablet: computed(() => tier.value === 'tablet'),
isDesktop: computed(() => tier.value === 'desktop'),
};
}
+20
View File
@@ -107,6 +107,26 @@ export const REGIONS: Region[] = [
{ code: 89, name: 'Ямало-Ненецкий автономный округ', federalDistrict: 6 },
];
// Слова-типы субъекта — отбрасываем их при сортировке, чтобы регион вставал
// по своему настоящему имени: «Республика Татарстан» → «Татарстан» (буква Т),
// «Краснодарский край» → «Краснодарский», а не сваливался кучей на «Р»/«к».
const TYPE_WORDS = new Set(['Республика', 'Народная', 'автономный', 'автономная', 'округ', 'край', 'область']);
function regionSortKey(name: string): string {
return name
.split(' ')
.filter((w) => !TYPE_WORDS.has(w))
.join(' ')
.trim();
}
// Регионы для выпадающих списков: без sentinel «Вся РФ» (code 0),
// отсортированы по сути названия (см. regionSortKey) через русскую локаль.
export const REGIONS_ALPHA: Region[] = REGIONS
.filter((r) => r.code > 0)
.slice()
.sort((a, b) => regionSortKey(a.name).localeCompare(regionSortKey(b.name), 'ru'));
export const FEDERAL_DISTRICT_NAMES: Record<number, string> = {
1: 'Центральный',
2: 'Северо-Западный',
+1
View File
@@ -104,6 +104,7 @@ export const LEGAL_DOCS: Record<'offer' | 'privacy' | 'refund', LegalDoc> = {
heading: '5. Хранение и защита',
paragraphs: [
'Персональные данные хранятся на территории Российской Федерации. Применяются разграничение доступа, шифрование, маскирование данных в выгрузках и журналирование действий.',
'Для улучшения сервиса используется Яндекс.Метрика, включая запись пользовательских сессий (Вебвизор). Персональные данные контактов (телефоны, имена) в таких записях маскируются и не передаются. Обработка выполняется на серверах в Российской Федерации.',
],
},
{
+28 -3
View File
@@ -18,6 +18,9 @@ import { usePolling } from '../composables/usePolling';
import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';
import AppSidebar from '../components/layout/AppSidebar.vue';
import AppTopbar from '../components/layout/AppTopbar.vue';
import AppBottomNav from '../components/layout/AppBottomNav.vue';
import AppMoreDrawer from '../components/layout/AppMoreDrawer.vue';
import { useLayoutTier } from '../composables/useLayoutTier';
import DevIndexBadge from '../components/DevIndexBadge.vue';
import CommandPalette from '../components/layout/CommandPalette.vue';
import JivoWidget from '../components/support/JivoWidget.vue';
@@ -30,7 +33,10 @@ const notifications = useNotificationsStore();
const tenant = useTenantStore();
const route = useRoute();
const { tier } = useLayoutTier();
const drawerOpen = ref(true);
// «Ещё» на телефоне (разделы вне 5 нижних вкладок)
const moreOpen = ref(false);
// Тот же навигационный pool что в AppSidebar — для crumb-resolution в topbar
// (sidebar и topbar — независимые, но navGroups совпадают по контракту).
@@ -73,10 +79,13 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
<template>
<v-app>
<ImpersonationSessionBanner />
<AppSidebar v-model:drawer-open="drawerOpen" />
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
<AppSidebar v-model:drawer-open="drawerOpen" :tier="tier" />
<AppTopbar
:page-title="currentPageTitle"
@toggle-drawer="tier === 'phone' ? (moreOpen = !moreOpen) : (drawerOpen = !drawerOpen)"
/>
<v-main class="app-main">
<v-main class="app-main" :data-tier="tier">
<BalanceFrozenBanner
:frozen="tenant.frozen"
:deficit-rub="tenant.deficitRub"
@@ -92,12 +101,28 @@ usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabl
<CommandPalette />
<JivoWidget />
<WelcomeTour />
<!-- Мобильная навигация: нижние вкладки + «Ещё» (только телефон) -->
<AppBottomNav v-if="tier === 'phone'" />
<AppMoreDrawer v-if="tier === 'phone'" v-model:open="moreOpen" />
</v-app>
</template>
<style scoped>
.app-main {
background: #f6f3ec;
}
/* Отступ под навигацию по ярусу.
Десктоп — полный сайдбар 232px; планшет — узкая колонка-иконки (rail) 64px;
телефон — сайдбара нет, снизу панель-вкладки (запас под неё padding-bottom). */
.app-main[data-tier='desktop'] {
padding-left: 232px;
}
.app-main[data-tier='tablet'] {
padding-left: 64px;
}
.app-main[data-tier='phone'] {
padding-left: 0;
padding-bottom: 64px;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
/**
* Ленивый загрузчик Яндекс.Метрики для портала.
* Грузится ТОЛЬКО из роутера при входе в кабинет (meta.layout==='app') — см. router/index.ts.
* id счётчика читается из <meta name="metrika-id"> (проставляется welcome.blade.php по config).
* Вебвизор пишет с маскировкой ПДн (классы ym-hide-content в компонентах кабинета).
*/
declare global {
interface Window {
ym?: (...args: unknown[]) => void;
}
}
let loaded = false;
function counterId(): number | null {
const meta = document.querySelector('meta[name="metrika-id"]');
const raw = meta?.getAttribute('content');
const id = raw ? Number(raw) : NaN;
return Number.isFinite(id) && id > 0 ? id : null;
}
/** Один раз инжектит tag.js и инициализирует Вебвизор. Идемпотентно. No-op без id. */
export function ensureLoaded(): void {
if (loaded) return;
const id = counterId();
if (id === null) return;
loaded = true;
// стандартный bootstrap-стаб ym до загрузки tag.js
const w = window as Window & { ym?: { (...a: unknown[]): void; a?: unknown[]; l?: number } };
w.ym =
w.ym ||
function (...args: unknown[]) {
(w.ym as { a?: unknown[] }).a = (w.ym as { a?: unknown[] }).a || [];
(w.ym as { a: unknown[] }).a.push(args);
};
(w.ym as { l?: number }).l = Number(new Date());
const s = document.createElement('script');
s.async = true;
s.src = `https://mc.yandex.ru/metrika/tag.js?id=${id}`;
document.head.appendChild(s);
// defer:true — без авто-первого-хита; первый хит шлёт роутер вручную
w.ym(id, 'init', {
webvisor: true,
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
defer: true,
});
}
/** Отправить заход (SPA-переход). No-op без id/ym. */
export function hit(url: string): void {
const id = counterId();
if (id === null || typeof window.ym !== 'function') return;
window.ym(id, 'hit', url);
}
+11
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { ensureLoaded, hit } from '../plugins/metrika';
/**
* Vue Router (фаза 2). История — `createWebHistory` (HTML5 history API);
@@ -413,3 +414,13 @@ router.beforeEach(async (to) => {
return true;
});
// Метрика/Вебвизор — только в кабинете клиента (layout==='app').
// Загружаем лениво при первом входе, затем шлём hit на каждом переходе внутри кабинета.
// Вход (auth), публичные (public), админка (admin), портал продаж (salesAuth) — БЕЗ Метрики.
router.afterEach((to) => {
if (to.meta.layout === 'app') {
ensureLoaded();
hit(to.fullPath);
}
});
+33
View File
@@ -0,0 +1,33 @@
/**
* Единый источник истины для показа объёмных ступеней тарифа.
*
* В БД у каждой ступени хранится ШИРИНА (`leads_in_tier`) сколько лидов
* помещается именно в эту ступень, а НЕ «до какого лида она действует».
* Живой расчёт списаний (PricingTierResolver / BalanceToLeadsConverter)
* накапливает эти ширины: tier 1 = 1..w1, tier 2 = w1+1..w1+w2, и т.д.
*
* Эта функция переводит ширину в тот накопительный диапазон, который видит
* клиент в кабинете. Держим её в ОДНОМ месте, чтобы админка и кабинет никогда
* не разошлись в цифрах.
*/
export interface TierWidth {
leads_in_tier: number | null;
}
/**
* Диапазон номеров лидов для ступени с индексом `idx` (0-based) в массиве
* ступеней, отсортированном по возрастанию tier_no.
*
* Пример (ширины 250/500/): idx=0 «1250», idx=1 «251750».
* Последняя ступень (`leads_in_tier === null`) «19251+».
*/
export function tierRangeText(tiers: ReadonlyArray<TierWidth>, idx: number): string {
let start = 1;
for (let i = 0; i < idx; i++) {
const cap = tiers[i].leads_in_tier;
if (cap !== null) start += cap;
}
const cap = tiers[idx].leads_in_tier;
if (cap === null) return `${start}+`;
return `${start}${start + cap - 1}`;
}
+101 -2
View File
@@ -32,11 +32,18 @@ import DealDetailDrawer from '../components/deals/DealDetailDrawer.vue';
import NewDealDialog from '../components/deals/NewDealDialog.vue';
import { useAuthStore } from '../stores/auth';
import { useLeadStatusesStore } from '../stores/leadStatuses';
import { useLayoutTier } from '../composables/useLayoutTier';
const auth = useAuthStore();
const leadStatusesStore = useLeadStatusesStore();
const leadStatuses = computed(() => leadStatusesStore.statuses);
// Телефон: одна колонка + переключатель этапов (drag вбок неудобен на узком экране;
// смена этапа сделки тапом по карточке через drawer).
const { isPhone } = useLayoutTier();
const activeSlug = ref<string>(LEAD_STATUSES[0]?.slug ?? '');
const activeStatus = computed(() => leadStatuses.value.find((s) => s.slug === activeSlug.value) ?? leadStatuses.value[0] ?? null);
interface DraggableChangeEvent {
added?: { element: MockDeal; newIndex: number };
removed?: { element: MockDeal; oldIndex: number };
@@ -222,7 +229,9 @@ defineExpose({
><span class="num">{{ totalDeals }}</span> сделок</span
>
<span class="sep">·</span>
<span class="text-caption text-medium-emphasis"> Перетаскивание между колонками </span>
<span class="text-caption text-medium-emphasis">{{
isPhone ? 'Выберите этап' : 'Перетаскивание между колонками'
}}</span>
</div>
</div>
<div class="d-flex ga-2">
@@ -244,7 +253,37 @@ defineExpose({
Не удалось загрузить сделки. Попробуйте обновить.
</v-alert>
<div class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
<!-- Телефон: переключатель этапов + одна колонка на всю ширину -->
<div v-if="isPhone" class="kanban-mobile mt-4">
<div class="stage-switcher" role="tablist" aria-label="Этапы воронки">
<button
v-for="status in leadStatuses"
:key="status.slug"
type="button"
role="tab"
:aria-selected="status.slug === activeSlug"
class="stage-chip"
:class="{ 'stage-chip--active': status.slug === activeSlug }"
:style="{ '--accent': status.colorHex }"
@click="activeSlug = status.slug"
>
<span class="stage-chip__name">{{ status.nameRu }}</span>
<span class="stage-chip__count">{{ (dealsByStatus[status.slug] || []).length }}</span>
</button>
</div>
<KanbanColumn
v-if="activeStatus"
:key="activeStatus.slug"
:status="activeStatus"
:deals="dealsByStatus[activeSlug] || []"
@update:deals="(list) => (dealsByStatus[activeSlug] = list)"
@change="(e) => onColumnChange(activeSlug as MockDeal['statusSlug'], e)"
@open-deal="onOpenDeal"
/>
</div>
<!-- Планшет/десктоп: все колонки с горизонтальной прокруткой -->
<div v-else class="kanban-board mt-4" tabindex="0" role="region" aria-label="Канбан-доска воронки продаж">
<KanbanColumn
v-for="status in leadStatuses"
:key="status.slug"
@@ -325,6 +364,66 @@ defineExpose({
padding-bottom: 12px;
}
/* Телефон: переключатель этапов (чипсы) + одна колонка на всю ширину */
.kanban-mobile {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.stage-switcher {
display: flex;
gap: 8px;
flex-wrap: wrap;
padding-bottom: 10px;
margin-bottom: 4px;
}
.stage-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid #d9d5cd;
background: #fff;
color: #3a3a33;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition:
border-color 150ms ease,
background 150ms ease;
}
.stage-chip__count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 12px;
color: #66635c;
background: #f0ede4;
padding: 0 7px;
border-radius: 10px;
}
.stage-chip--active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, #fff);
color: #081319;
box-shadow: inset 0 -2px 0 var(--accent);
}
.stage-chip--active .stage-chip__count {
background: #fff;
}
/* Колонка на телефоне — во всю ширину (перебиваем фикс 280px из KanbanColumn) */
.kanban-mobile :deep(.kanban-column) {
flex: 1 1 auto;
width: 100%;
max-height: none;
}
.kanban-mobile :deep(.column-body) {
max-height: calc(100vh - 340px);
}
.kanban-board::-webkit-scrollbar {
height: 8px;
}
+23 -4
View File
@@ -1,6 +1,6 @@
<template>
<v-container fluid class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
<div class="d-flex justify-space-between align-center mb-4">
<div class="d-flex justify-space-between align-center mb-4 ga-3 projects-head">
<h1 class="text-h4">Проекты</h1>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">Создать проект</v-btn>
</div>
@@ -29,7 +29,7 @@
</div>
</v-alert>
<div v-if="!isEmptyAccount" class="d-flex flex-wrap ga-3 mb-4">
<div v-if="!isEmptyAccount" class="d-flex flex-wrap ga-3 mb-4 filters-bar">
<v-select
v-model="store.filters.signal_type"
:items="typeFilters"
@@ -205,7 +205,7 @@ import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
import NewProjectDialog from './projects/NewProjectDialog.vue';
import EditProjectDialog from './projects/EditProjectDialog.vue';
import ProjectLimitOverloadDialog from '../components/projects/ProjectLimitOverloadDialog.vue';
import { REGIONS } from '../constants/regions';
import { REGIONS_ALPHA } from '../constants/regions';
import { formatAppliesFromMessage } from '../composables/appliesFromMessage';
const store = useProjectsStore();
@@ -297,7 +297,7 @@ const statusFilters = [
const PER_PAGE_OPTIONS = [20, 50, 100, 200];
// #7: фильтр регион все 89 субъектов (без code=0 «вся РФ» пустой regions[] и так попадает в выдачу).
const regionFilterItems = REGIONS.filter((r) => r.code !== 0).map((r) => ({ title: r.name, value: r.code }));
const regionFilterItems = REGIONS_ALPHA.map((r) => ({ title: r.name, value: r.code }));
// #7: фильтр по дню недели индексы совпадают с битами delivery_days_mask и dayLabels drawer'а.
const dayFilterItems = [
@@ -372,6 +372,25 @@ onUnmounted(() => store.stopPolling());
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* Телефон: фильтры во всю ширину в столбик (перебиваем inline max-width),
чтобы подписи не обрезались («Р», «Д»). */
@media (max-width: 599.98px) {
.filters-bar > * {
max-width: none !important;
flex: 1 1 100%;
}
/* Телефон: заголовок «Проекты» и кнопка «Создать проект» жались вплотную
ставим кнопку под заголовок во всю ширину, с воздухом между ними. */
.projects-head {
flex-direction: column;
}
/* align-self перебивает utility-класс .align-center (у него !important на
align-items) заголовок и кнопка прижаты к левому краю, как на десктопе. */
.projects-head > h1,
.projects-head > .v-btn {
align-self: flex-start;
}
}
.toolbar-check {
display: inline-flex;
align-items: center;
@@ -23,7 +23,7 @@
</span>
</v-card-title>
<v-data-table
:headers="tierHeaders"
:headers="activeHeaders"
:items="active"
:items-per-page="7"
density="comfortable"
@@ -33,10 +33,17 @@
<span v-if="item.leads_in_tier !== null">{{ item.leads_in_tier }}</span>
<span v-else class="text-medium-emphasis">все свыше</span>
</template>
<template #[`item.client_range`]="{ item }">
<span :data-testid="`active-range-${item.tier_no}`">{{ activeRangeText(item) }} лидов</span>
</template>
<template #[`item.price_rub`]="{ item }">
{{ fmtRub(item.price_per_lead_kopecks) }}
</template>
</v-data-table>
<div class="text-caption text-medium-emphasis px-4 pb-3">
«Лидов в ступени» сколько заявок помещается именно в эту ступень (ширина). «Диапазон»
как эти ступени видит клиент в кабинете: с какого по какой лид действует цена.
</div>
</v-card>
<v-card v-if="hasScheduled" class="mb-6" elevation="1">
@@ -68,11 +75,16 @@
style="max-width: 240px"
data-testid="effective-from-input"
/>
<p class="text-caption text-medium-emphasis mb-2">
В поле «Лидов в ступени» вводится <strong>ширина</strong> ступени (сколько заявок в неё
помещается). Справа сразу видно, <strong>что увидит клиент</strong> накопительный диапазон.
</p>
<table class="editor-table">
<thead>
<tr>
<th>Ступень</th>
<th>Лидов в ступени</th>
<th>Клиент увидит</th>
<th>Цена за лид ()</th>
</tr>
</thead>
@@ -90,6 +102,9 @@
/>
<span v-else class="text-medium-emphasis">все свыше</span>
</td>
<td :data-testid="`editor-range-${t.tier_no}`" class="editor-range num">
{{ editorRangeText(t) }} лидов
</td>
<td>
<v-text-field
v-model="t.price_rub"
@@ -148,6 +163,7 @@ import {
type PricingTierEditorRow,
} from '../../api/admin';
import { extractErrorMessage } from '../../api/client';
import { tierRangeText } from '../../utils/pricingTiers';
/**
* SaaS-admin Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
@@ -198,6 +214,28 @@ const tierHeaders = [
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
];
// Активная сетка показывает ещё и накопительный диапазон ровно то, что видит
// клиент в кабинете (устраняет путаницу «в админке одни числа, у клиента другие»).
const activeHeaders = [
{ title: '№', key: 'tier_no', sortable: false, width: 80 },
{ title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
{ title: 'Диапазон (у клиента)', key: 'client_range', sortable: false },
{ title: 'Цена за лид', key: 'price_rub', sortable: false },
];
// Диапазон для строки активной сетки: находим позицию ступени по tier_no
// и переводим ширины в накопительный диапазон (общий помощник как в кабинете).
function activeRangeText(item: AdminPricingTier): string {
const idx = active.value.findIndex((t) => t.tier_no === item.tier_no);
return tierRangeText(active.value, idx);
}
// Живой предпросмотр диапазона в редакторе пересчитывается при правке ширины.
function editorRangeText(row: PricingTierEditorRow): string {
const idx = editor.value.findIndex((r) => r.tier_no === row.tier_no);
return tierRangeText(editor.value, idx);
}
const nextMonthStart = computed(() => {
const d = new Date();
d.setDate(1);
@@ -307,4 +345,12 @@ defineExpose({
padding: 8px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.num {
font-family: 'JetBrains Mono', monospace;
font-feature-settings: 'tnum';
}
.editor-range {
color: #6b6456;
white-space: nowrap;
}
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, reactive, provide, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useAutopodborStore } from '../../stores/autopodborStore';
import FieldWorkspaceScreen from './screens/FieldWorkspaceScreen.vue';
import FieldCompetitorScreen from './screens/FieldCompetitorScreen.vue';
@@ -42,6 +43,7 @@ const ctx = reactive({
createdCount: 0,
launched: false,
openDupes: false, // флаг «открыть окно объединения дублей при заходе в поле»
openProjectSettingsId: null as number | null, // deep-link из «Сделок»: открыть настройки проекта
});
function go(name: ScreenName) {
@@ -66,8 +68,24 @@ const screens: Partial<Record<ScreenName, any>> = {
editproject: EditProjectScreen,
};
onMounted(() => {
void store.loadState();
const route = useRoute();
onMounted(async () => {
await store.loadState();
// Deep-link из «Сделок»: ?competitor=<id>[&project=<id>] открыть экран
// конкурента (и, если задан project, модалку «Настройки проекта»).
// route?. на случай монтирования без роутера (юнит-тесты): в приложении
// роутер всегда есть, поведение не меняется.
const q = route?.query ?? {};
const compId = Number(Array.isArray(q.competitor) ? q.competitor[0] : q.competitor);
if (Number.isInteger(compId) && compId > 0) {
ctx.competitorId = compId;
const projId = Number(Array.isArray(q.project) ? q.project[0] : q.project);
if (Number.isInteger(projId) && projId > 0) {
ctx.openProjectSettingsId = projId;
}
go('fieldcompetitor');
}
});
defineExpose({ go, screen, ctx });
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { inject, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { REGIONS } from '../../../constants/regions';
import { REGIONS_ALPHA } from '../../../constants/regions';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
@@ -90,7 +90,7 @@ async function submit() {
<select v-model="regionCode" class="ld-af-select">
<option :value="null" disabled> выберите регион </option>
<option v-for="r in REGIONS.filter(r => r.code > 0)" :key="r.code" :value="r.code">{{ r.name }}</option>
<option v-for="r in REGIONS_ALPHA" :key="r.code" :value="r.code">{{ r.name }}</option>
</select>
<label class="ld-af-check">
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { inject, computed, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { REGIONS } from '../../../constants/regions';
import { REGIONS_ALPHA } from '../../../constants/regions';
import type { LaunchSummary } from '../../../api/autopodbor';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
@@ -12,8 +12,8 @@ const selected = computed(() =>
store.sources.filter((s) => nav.ctx.selectedSourceIds.includes(s.id)),
);
// Регионы (только code > 0)
const regions = REGIONS.filter((r) => r.code > 0);
// Регионы для выпадашки (по сути названия, без «Вся РФ»)
const regions = REGIONS_ALPHA;
// Состояние формы
const regionCode = ref<number | null>(
@@ -174,6 +174,7 @@ async function doAddSource() {
{{ src.identifier }}
<span v-if="src.phone_kind === 'real'" class="ld-tag ld-tag--real">настоящий</span>
<span v-if="src.phone_kind === 'substitute'" class="ld-tag ld-tag--sub">подменный · с сайта</span>
<span v-if="src.phone_kind === 'unverified' || src.phone_kind === 'uncertain'" class="ld-tag ld-tag--check">на сайте · проверьте</span>
</div>
<div class="ld-rprov">
Где нашли:
@@ -447,6 +448,12 @@ async function doAddSource() {
border: 1px solid #f0e0b0;
}
.ld-tag--check {
background: #f6efdc;
color: #8a6d1f;
border: 1px solid #e6d8ad;
}
.ld-used {
display: inline-block;
font-size: 11.5px;
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { inject, ref, onMounted } from 'vue';
import axios from 'axios';
import { REGIONS } from '../../../constants/regions';
import { REGIONS_ALPHA } from '../../../constants/regions';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
// Регионы (только code > 0)
const regions = REGIONS.filter((r) => r.code > 0);
// Регионы для выпадашки (по сути названия, без «Вся РФ»)
const regions = REGIONS_ALPHA;
// Состояние формы
const name = ref('');
@@ -2,7 +2,7 @@
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage, collectResultMessage, type FieldSourceDto, type WhereFound } from '../../../api/autopodbor';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../../constants/regions';
import { REGIONS_ALPHA, FEDERAL_DISTRICT_NAMES } from '../../../constants/regions';
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
import { getRequisites, updateRequisites, type Requisites } from '../../../api/requisites';
import RefoundDeletedSources from './RefoundDeletedSources.vue';
@@ -10,7 +10,7 @@ import CollectingIndicator from './CollectingIndicator.vue';
interface AutopodborNav {
go: (s: string) => void;
ctx: { competitorId: number | null; editProjectId: number | null; selectedSourceIds: number[]; openDupes?: boolean };
ctx: { competitorId: number | null; editProjectId: number | null; selectedSourceIds: number[]; openDupes?: boolean; openProjectSettingsId?: number | null };
screen: { value: string };
}
const nav = inject('autopodborNav') as AutopodborNav;
@@ -22,7 +22,7 @@ const selected = ref<number[]>([]);
const busy = ref(false);
const toast = ref('');
const regions = REGIONS.filter((r) => r.code > 0);
const regions = REGIONS_ALPHA;
const DAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const MONTHS = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'];
@@ -48,11 +48,13 @@ function safeUrl(u: string | null): string | null {
return null;
}
}
// Сортировка: подменные вниз; среди остальных больше подтверждений выше.
// Сортировка: подменные в самый низ, «проверьте» ниже настоящих; среди равных больше подтверждений выше.
function sortSources(list: FieldSourceDto[]): FieldSourceDto[] {
const rank = (k: string | null | undefined) =>
k === 'substitute' ? 2 : k === 'unverified' || k === 'uncertain' ? 1 : 0;
return [...list].sort((a, b) => {
const sa = a.phone_kind === 'substitute' ? 1 : 0;
const sb = b.phone_kind === 'substitute' ? 1 : 0;
const sa = rank(a.phone_kind);
const sb = rank(b.phone_kind);
if (sa !== sb) return sa - sb;
const ca = confirms(a);
const cb = confirms(b);
@@ -108,13 +110,16 @@ function phoneTypeLabel(s: FieldSourceDto) {
// Понятная клиенту метка вида номера: настоящий vs рекламный подменный.
function kindTag(s: FieldSourceDto): { text: string; cls: string } | null {
if (s.signal_type !== 'call') return null;
return s.phone_kind === 'substitute'
? { text: '🎭 рекламный подменный', cls: 'ld-b-stop' }
: { text: '✓ настоящий номер', cls: 'ld-b-run' };
if (s.phone_kind === 'substitute') return { text: '🎭 рекламный подменный', cls: 'ld-b-stop' };
// номер найден на сайте, но не подтверждён кликабельной ссылкой/справочником просим проверить
if (s.phone_kind === 'unverified' || s.phone_kind === 'uncertain')
return { text: '⚠ на сайте — проверьте', cls: 'ld-b-wait' };
return { text: '✓ настоящий номер', cls: 'ld-b-run' };
}
// Технические метки «где нашли» человеческий язык (данные в БД не трогаем только показ).
const WF_LABELS: Record<string, string> = {
'в коде сайта': 'на сайте',
'в тексте сайта — проверьте': 'в тексте сайта',
коллтрекинг: 'показан на сайте',
'коллтрекинг (пул)': 'показан на сайте',
'на сайте (код города?)': 'на сайте (проверить)',
@@ -592,13 +597,14 @@ async function saveReqGate() {
}
// настройки проекта
const psettings = reactive({ open: false, projectId: null as number | null, label: '', regionCode: 24, limit: 5, mask: 127 });
const psettings = reactive({ open: false, projectId: null as number | null, label: '', regionCode: 24, limit: 5, mask: 127, isActive: true });
function openSettings(s: FieldSourceDto) {
if (!s.project) return;
Object.assign(psettings, {
open: true, projectId: s.project.id, label: sourceValue(s),
regionCode: s.project.regions?.[0] ?? regions[0]?.code ?? 24,
limit: s.project.daily_limit_target, mask: s.project.delivery_days_mask || 127,
isActive: s.project.is_active,
});
setQuery.value = regionName(psettings.regionCode);
}
@@ -626,6 +632,21 @@ async function saveSettings() {
busy.value = false;
}
}
// Пуск/стоп проекта прямо из модалки настроек не закрывая окно (2026-07-08).
async function toggleFromSettings(active: boolean) {
if (psettings.projectId === null || busy.value) return;
busy.value = true;
try {
await store.toggleProjectActive(psettings.projectId, active);
psettings.isActive = active;
await reload();
flash(active ? 'Проект возобновлён ▶' : 'Проект приостановлен ⏸ — уже заказанные лиды дойдут');
} catch {
flash('Не удалось изменить проект — ограничения по слепку или балансу.');
} finally {
busy.value = false;
}
}
// удаление источника (гвард слепка)
// Предложение (box=proposal) удаляем через окно-пул связанных строк (delPool): оно строит
@@ -748,6 +769,12 @@ async function saveEdit() {
onMounted(async () => {
await reload();
// Deep-link из «Сделок»: открыть модалку «Настройки проекта» для нужного источника.
if (nav.ctx.openProjectSettingsId) {
const target = inWork.value.find((s) => s.project?.id === nav.ctx.openProjectSettingsId);
if (target) openSettings(target);
nav.ctx.openProjectSettingsId = null;
}
// По конкуренту прямо сейчас идёт сбор (подхвачен из active_run) возобновляем опрос, чтобы
// индикатор «идёт сбор / этап» ожил после возврата на карточку и мы дождались итога.
if (collecting.value && store.currentRun) {
@@ -758,7 +785,7 @@ onMounted(async () => {
}
});
defineExpose({ ctab, selected, inWork, props: props, toast, create, reqGate, switchTab, toggle, openEdit, openCollect, openAdd, openCreate, openBulkCreate, doCreate, toggleProject, bulkWork, bulkMoveToWork, askBulkDeleteProps, bulkDeleteProps, delBulk, openDelete, del, delPool, confirmDeletePool, moveToWork, sourceValue });
defineExpose({ ctab, selected, inWork, props: props, toast, create, reqGate, switchTab, toggle, openEdit, openCollect, openAdd, openCreate, openBulkCreate, doCreate, toggleProject, bulkWork, bulkMoveToWork, askBulkDeleteProps, bulkDeleteProps, delBulk, openDelete, del, delPool, confirmDeletePool, moveToWork, sourceValue, openSettings, toggleFromSettings, psettings });
</script>
<template>
@@ -805,6 +832,7 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, reqGate, swi
<b>Как читать:</b>
<span class="ld-bdg ld-b-run"> настоящий номер</span> живой номер фирмы, по нему дозвонитесь;
<span class="ld-bdg ld-b-stop">🎭 рекламный подменный</span> номер из рекламы, он меняется, в работу брать не стоит;
<span class="ld-bdg ld-b-wait"> на сайте проверьте</span> нашли на сайте в тексте, но кликабельной ссылкой не подтверждён позвоните и убедитесь;
<b>«где нашли»</b> источник номера: кликните и проверьте сами.
</div>
@@ -1114,6 +1142,8 @@ defineExpose({ ctab, selected, inWork, props: props, toast, create, reqGate, swi
<div class="ld-days"><button v-for="(d, i) in DAYS" :key="i" class="ld-day" :class="{ 'ld-day--on': isDayOn(psettings.mask, i) }" @click="toggleSetDay(i)">{{ d }}</button></div>
<div style="margin-top: 7px"><span class="ld-link" @click="setSetDays(false)">Будни</span> &nbsp;·&nbsp; <span class="ld-link" @click="setSetDays(true)">Все дни</span></div></div>
<div class="ld-modal__foot">
<button v-if="psettings.isActive" class="ld-btn warn sm" :disabled="busy" style="margin-right: auto" @click="toggleFromSettings(false)"> Остановить</button>
<button v-else class="ld-btn primary sm" :disabled="busy" style="margin-right: auto" @click="toggleFromSettings(true)"> Запустить</button>
<button class="ld-btn gray sm" @click="psettings.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveSettings">Сохранить</button>
</div>
@@ -3,7 +3,7 @@ import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage } from '../../../api/autopodbor';
import type { ActualizeCard, CompetitorDto, DuplicateGroupDto, CompetitorElement } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
import { REGIONS_ALPHA } from '../../../constants/regions';
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
import CompetitorElementsEditor from './CompetitorElementsEditor.vue';
@@ -19,7 +19,7 @@ const selected = ref<number[]>([]);
const busy = ref(false);
const toast = ref('');
const regions = REGIONS.filter((r) => r.code > 0);
const regions = REGIONS_ALPHA;
const prices = computed(() => store.prices);
// Предложения приходят разложенными по группам (спека §3): новые / на актуализацию / ранее удалённые.
@@ -633,6 +633,19 @@ defineExpose({ list, selected, toggle, toggleAll, moveToField, moveSelected, ope
display: flex;
gap: 8px;
}
/* Телефон: 3 кнопки (Добавить/Заменить/Отказаться) не влезали в карточку и
заезжали за правый край раскладываем на всю ширину поровну. */
@media (max-width: 599.98px) {
.ld-actbtns {
width: 100%;
}
.ld-actbtns .ld-btn {
flex: 1 1 0;
min-width: 0;
padding-left: 6px;
padding-right: 6px;
}
}
.ld-alert-inline {
font-size: 12.5px;
color: #8a1c1c;
@@ -2,7 +2,7 @@
import { computed, inject, onMounted, reactive, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { autopodborErrorMessage, type FieldCompetitorDto, type DuplicateGroupDto, type CompetitorElement, type MergeEventDto } from '../../../api/autopodbor';
import { REGIONS } from '../../../constants/regions';
import { REGIONS_ALPHA } from '../../../constants/regions';
import { useOverlayDismiss } from '../../../composables/useOverlayDismiss';
import CompetitorElementsEditor from './CompetitorElementsEditor.vue';
import CollectingIndicator from './CollectingIndicator.vue';
@@ -19,7 +19,7 @@ const selected = ref<number[]>([]);
const busy = ref(false);
const toast = ref('');
const regions = REGIONS.filter((r) => r.code > 0);
const regions = REGIONS_ALPHA;
const prices = computed(() => store.prices);
const competitors = computed<FieldCompetitorDto[]>(() =>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { inject, ref } from 'vue';
import { useAutopodborStore } from '../../../stores/autopodborStore';
import { REGIONS } from '../../../constants/regions';
import { REGIONS_ALPHA } from '../../../constants/regions';
const nav = inject('autopodborNav') as { go: (s: string) => void; ctx: any; screen: any };
const store = useAutopodborStore();
@@ -91,7 +91,7 @@ async function submit() {
<select v-model="regionCode" class="ld-mf-select">
<option :value="null" disabled> выберите регион </option>
<option v-for="r in REGIONS.filter(r => r.code > 0)" :key="r.code" :value="r.code">{{ r.name }}</option>
<option v-for="r in REGIONS_ALPHA" :key="r.code" :value="r.code">{{ r.name }}</option>
</select>
<div class="ld-mf-divider"></div>
@@ -116,6 +116,36 @@
.ld-banner--amber .ld-banner__txt b {
color: #8a6a12;
}
/* Телефон: текст и ссылка не влезают в строку (текст крошился по слову,
«Разобрать предложения » вылезала за край) раскладываем в столбик. */
@media (max-width: 599.98px) {
.ld-banner {
flex-direction: column;
align-items: flex-start;
gap: 8px;
padding: 14px 16px;
}
.ld-banner__link {
white-space: normal;
}
/* «Выбрать всех» + подсказка — не жать в узкую колонку, подсказка на новую строку. */
.ld-selrow {
flex-wrap: wrap;
gap: 4px 12px;
}
/* Карточка источника: кнопки (Создать проект/Приостановить/Настройки/В источники)
стояли справа фикс. блоком и уезжали за край переносим их на строку ниже
во всю ширину карточки. */
.ld-srccard {
flex-wrap: wrap;
}
.ld-srcctl {
flex-basis: 100%;
max-width: none;
justify-content: flex-start;
margin-top: 4px;
}
}
.ld-tabs {
display: flex;
@@ -499,6 +529,10 @@
background: #f0e6e6;
color: #a05050;
}
.ld-b-wait {
background: #f6efdc;
color: #8a6d1f;
}
.ld-b-none {
background: #eef0f2;
color: #7a8a82;
@@ -1,5 +1,11 @@
<template>
<v-dialog :model-value="modelValue" max-width="720" @update:model-value="$emit('update:modelValue', $event)">
<v-dialog
:model-value="modelValue"
max-width="720"
:fullscreen="isPhone"
scrollable
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card style="position: relative">
<DevIndexBadge
:index="mode === 'edit' ? 19 : 18"
@@ -276,7 +282,7 @@
<div class="mt-3">
<span class="text-caption">Дни недели приёма</span>
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1">
<v-btn-toggle v-model="selectedDays" multiple density="comfortable" class="mt-1 weekday-toggle">
<v-btn v-for="(day, i) in dayLabels" :key="i" :value="i">{{ day }}</v-btn>
</v-btn-toggle>
<div class="mt-1">
@@ -330,15 +336,16 @@
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue';
import { useLayoutTier } from '../../composables/useLayoutTier';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
import { getRequisites, updateRequisites, type Requisites } from '../../api/requisites';
import { firstLeadDate, formatLeadDate } from '../../utils/leadDate';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { REGIONS_ALPHA, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue';
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
const selectableRegions = REGIONS_ALPHA;
// Дата старта лидов для баннера нового проекта (правило слепка 18:00 МСК).
const leadStart = computed(() => firstLeadDate());
@@ -408,6 +415,9 @@ const emit = defineEmits<{
saved: [appliesFrom: string | null];
}>();
// На телефоне диалог на весь экран (иначе узкий модал режет ряды кнопок/дней).
const { isPhone } = useLayoutTier();
// Plan 6: regions = subject codes (1..89) backend dual-writes region_mask/region_mode.
// Пустой массив = вся РФ.
const form = reactive({
@@ -723,6 +733,20 @@ defineExpose({
line-height: 1.4;
padding: 4px 2px;
}
/* Телефон: 7 кнопок дней недели (ПНВС) по 64px не влезали в 390px
СБ и ВС уезжали за правый край и были не видны. Растягиваем группу на всю
ширину и делим её поровну между кнопками, чтобы все 7 помещались в строку. */
@media (max-width: 599.98px) {
.weekday-toggle {
display: flex;
width: 100%;
}
.weekday-toggle :deep(.v-btn) {
flex: 1 1 0;
min-width: 0;
padding-inline: 0;
}
}
.steps-head {
display: flex;
align-items: center;
+10 -2
View File
@@ -201,7 +201,7 @@ async function runWebhookTest(): Promise<void> {
/>
</template>
</v-text-field>
<div class="d-flex ga-2 mt-2">
<div class="d-flex ga-2 mt-2 btn-row">
<v-btn
variant="outlined"
size="small"
@@ -296,7 +296,7 @@ async function runWebhookTest(): Promise<void> {
/>
</template>
</v-text-field>
<div class="d-flex ga-2 mt-2">
<div class="d-flex ga-2 mt-2 btn-row">
<v-btn
color="primary"
variant="flat"
@@ -359,4 +359,12 @@ async function runWebhookTest(): Promise<void> {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
/* Телефон: пары кнопок (Копировать+Перегенерировать, Сохранить+Тестовый webhook)
не влезали в строку длинная вторая кнопка уезжала за край. Разрешаем перенос
на вторую строку. */
@media (max-width: 599.98px) {
.btn-row {
flex-wrap: wrap;
}
}
</style>
@@ -262,4 +262,29 @@ const canSave = computed(() => dirty.value && !saving.value && auth.isAuthentica
gap: 12px;
margin-top: 8px;
}
/* Телефон: колонки каналов по 110/130px не влезали Email уезжал за край,
а название события сжималось в узкую колонку и текст крошился по одному слову.
Ужимаем колонки-галочки до ширины чекбокса и уменьшаем отступы, чтобы событию
осталась почти вся ширина. Заголовки каналов переносим по буквам. */
@media (max-width: 599.98px) {
.prefs-head,
.prefs-row {
grid-template-columns: 1fr 46px 46px;
}
.prefs-cell {
padding: 8px 6px;
}
.ch-col {
overflow-wrap: anywhere;
word-break: break-word;
line-height: 1.15;
}
/* Заголовки каналов — мельче, чтобы «EMAIL» помещался в строку, а не ломался. */
.prefs-head .ch-col {
font-size: 9px;
letter-spacing: 0.02em;
padding-inline: 3px;
}
}
</style>
@@ -196,7 +196,7 @@ async function save(): Promise<void> {
mandatory="force"
density="comfortable"
color="primary"
class="mt-1 d-block"
class="mt-1 d-block subject-type-toggle"
data-testid="requisites-subject-type"
>
<v-btn v-for="t in subjectTypes" :key="t.value" :value="t.value">{{ t.label }}</v-btn>
@@ -390,4 +390,27 @@ async function save(): Promise<void> {
font-variation-settings: 'opsz' 18;
letter-spacing: -0.005em;
}
/* Телефон: три длинных варианта «Тип лица» не влезают в строку
раскладываем сегмент-контрол в столбик во всю ширину. */
@media (max-width: 599.98px) {
.subject-type-toggle {
display: flex;
flex-direction: column;
height: auto;
width: 100%;
}
.subject-type-toggle :deep(.v-btn) {
width: 100%;
border-radius: 0;
}
.subject-type-toggle :deep(.v-btn:first-child) {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.subject-type-toggle :deep(.v-btn:last-child) {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
}
}
</style>
+3
View File
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ config('app.name', 'Лидерра') }}</title>
<meta name="support-email" content="{{ config('services.support.email') }}">
@if(config('services.metrika.counter_id'))
<meta name="metrika-id" content="{{ config('services.metrika.counter_id') }}">
@endif
@vite(['resources/css/app.css', 'resources/js/app.ts'])
@if(config('services.jivosite.widget_id'))
<script src="https://code.jivo.ru/widget/{{ config('services.jivosite.widget_id') }}" async></script>
+10 -1
View File
@@ -19,7 +19,16 @@ const { chromium } = require('playwright');
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
// 'domcontentloaded' вместо 'networkidle': «шумные» сайты (аналитика, чат-виджеты, коллтрекинг)
// никогда не «затихают» — networkidle упирался в 30с и рендер падал по таймауту, обнуляя весь сбор
// номеров с сайта. Берём DOM рано, затем короткая пауза, чтобы скрипты коллтрекинга
// (calltouch/comagic/…) успели подставить свой номер в видимые tel:-ссылки.
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 12000 });
} catch (e) {
// даже если навигация не уложилась в таймаут — забираем то, что успело отрендериться
}
await page.waitForTimeout(2500);
const html = await page.content();
const visiblePhones = await page.$$eval('a[href^="tel:"]', els =>
els.map(e => (e.textContent || '').trim()).filter(Boolean)
+2 -2
View File
@@ -106,7 +106,7 @@ test('register требует accept_offer, accept_pdn, captcha_token', function
->assertStatus(422)->assertJsonValidationErrors(['captcha_token']);
});
test('confirm верным кодом активирует тенанта/владельца, баланс 300, выполняет вход', function () {
test('confirm верным кодом активирует тенанта/владельца, баланс 1000, выполняет вход', function () {
$reg = $this->postJson('/api/auth/register', registerPayload())->assertStatus(201);
$code = $reg->json('_dev_plain_code');
@@ -124,7 +124,7 @@ test('confirm верным кодом активирует тенанта/вла
$tenant = Tenant::find($user->tenant_id);
expect($tenant->status)->toBe('active');
expect((float) $tenant->balance_rub)->toBe(300.0);
expect((float) $tenant->balance_rub)->toBe(1000.0);
});
test('confirm неверным кодом → 422 + failed_attempts++', function () {
@@ -44,3 +44,28 @@ it('оба сайта-близнеца — две отдельные строк
expect(collect($call['where_found'])->pluck('label'))->toContain('в коде сайта')
->and($call['phone_kind'])->toBe('real');
});
it('текстовый номер сайта → phone_kind unverified «проверьте»; tel: остаётся real', function () {
config()->set('services.exa', ['key' => 't', 'base_url' => 'https://api.exa.ai']);
Http::fake(['*' => Http::response(['response_body_base64' => ''])]);
$siteFetcher = new class implements Fetcher
{
public function site(string $url): FetchedSite
{
return new FetchedSite($url, '<a href="tel:+74950110359">код</a><div>Телефон: 8 (800) 200-00-10</div>');
}
public function directory(string $url): array
{
return [];
}
};
$collector = new DeepStudyCollector(new XfetchClient(null), new SiteOpener($siteFetcher));
$fp = new Fingerprint('Фирма', 'k.ru', 'Красноярск', '391', 'krasnoyarsk', false);
$byNum = collect($collector->collect($fp))->where('signal_type', 'call')->keyBy('identifier');
expect($byNum['74950110359']['phone_kind'])->toBe('real') // из tel: — настоящий
->and($byNum)->toHaveKey('78002000010') // текстовый НЕ потерян
->and($byNum['78002000010']['phone_kind'])->toBe('unverified'); // помечен «проверьте»
});
+101
View File
@@ -2,6 +2,9 @@
declare(strict_types=1);
use App\Models\AutopodborCompetitor;
use App\Models\AutopodborRun;
use App\Models\AutopodborSource;
use App\Models\Deal;
use App\Models\LeadCharge;
use App\Models\Project;
@@ -9,6 +12,7 @@ use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Тесты GET /api/deals list-endpoint для DealsView/KanbanView (замена MOCK_DEALS).
@@ -392,3 +396,100 @@ test('GET /api/deals cost_kopecks = null если списания нет', func
$r->assertStatus(200);
expect($r->json('deals.0.cost_kopecks'))->toBeNull();
});
/* ----------------------------------------------------------------------
* Автоподбор (2026-07-08): конкурент + источник в списке сделок.
* Связь deal.project_id autopodbor_sources.created_project_id competitor.
* -------------------------------------------------------------------- */
/**
* Создаёт конкурента автоподбора + его источники (box=field), привязанные к проекту.
* $sources массив пар [signal_type, identifier].
*/
function makeAutopodborField(int $tenantId, int $projectId, string $name, array $sources): AutopodborCompetitor
{
$run = AutopodborRun::create(['tenant_id' => $tenantId, 'kind' => 'study']);
$competitor = AutopodborCompetitor::create([
'tenant_id' => $tenantId,
'name' => $name,
'description' => 'микрокредитная компания',
'relevance_pct' => 58,
'dedup_key' => 'dk-'.Str::random(10),
'box' => 'field',
]);
foreach ($sources as [$signalType, $ident]) {
AutopodborSource::create([
'tenant_id' => $tenantId,
'competitor_id' => $competitor->id,
'study_run_id' => $run->id,
'signal_type' => $signalType,
'identifier' => $ident,
'dedup_key' => 'sk-'.Str::random(10),
'created_project_id' => $projectId,
'box' => 'field',
]);
}
return $competitor;
}
test('GET /api/deals отдаёт competitor_* и source_* для автоподбор-сделки', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$competitor = makeAutopodborField($this->tenant->id, $this->project->id, 'AutoMoney', [
['call', '79135396546'],
]);
Deal::factory()->for($this->tenant)->create([
'project_id' => $this->project->id,
'phone' => '79000000001',
'received_at' => now(),
]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_id'])->toBe($competitor->id);
expect($res['competitor_name'])->toBe('AutoMoney');
expect($res['source_identifier'])->toBe('79135396546');
expect($res['source_signal_type'])->toBe('call');
expect($res['source_project_id'])->toBe($this->project->id);
expect($res['source_is_active'])->toBeTrue();
expect($res['source_extra_count'])->toBe(0);
});
test('GET /api/deals: merge источников на проект → один конкурент, source_extra_count>0', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
makeAutopodborField($this->tenant->id, $this->project->id, 'Сфера займа24', [
['call', '79029826282'],
['site', 'sfera.ru'],
]);
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBe('Сфера займа24');
expect($res['source_extra_count'])->toBe(1);
});
test('GET /api/deals: сделка без автоподбор-источника → competitor/source = null', function () {
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project2->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_id'])->toBeNull();
expect($res['competitor_name'])->toBeNull();
expect($res['source_identifier'])->toBeNull();
expect($res['source_project_id'])->toBeNull();
expect($res['source_is_active'])->toBeNull();
expect($res['source_extra_count'])->toBe(0);
});
test('GET /api/deals: источник чужого tenant не подтягивается к сделке', function () {
// Источник чужого tenant с created_project_id нашего проекта — where(tenant_id)+RLS отсекают.
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
makeAutopodborField($this->otherTenant->id, $this->project->id, 'Чужой', [['call', '70000000000']]);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBeNull();
});
@@ -143,6 +143,62 @@ describe('AdminPricingTiersView', () => {
});
});
describe('AdminPricingTiersView — клиентский диапазон (устранение путаницы admin↔кабинет)', () => {
beforeEach(() => {
vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
});
it('в активной сетке рядом с шириной показывает накопительный диапазон, как у клиента', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// ширины 100,200 → ступень 2 у клиента = 101–300 (а не «200»)
const cell = wrapper.find('[data-testid="active-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('ступень 7 в активной сетке — открытый диапазон со знаком +', async () => {
const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
// 100+200+400+800+1500+3000 = 6000 → ступень 7 = 6001+
expect(wrapper.find('[data-testid="active-range-7"]').text()).toContain('6001+');
});
it('редактор показывает живой клиентский диапазон рядом с полем ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(wrapper.vm as any).editorOpen = true;
await wrapper.vm.$nextTick();
// defaultEditor: ширины 100,200 → ступень 2 = 101300
const cell = wrapper.find('[data-testid="editor-range-2"]');
expect(cell.exists()).toBe(true);
expect(cell.text()).toContain('101300');
});
it('предпросмотр в редакторе пересчитывается при изменении ширины', async () => {
const wrapper = mount(AdminPricingTiersView, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.editorOpen = true;
vm.editor[0].leads_in_tier = 250; // была 100 → ступень 2 сдвигается на 251–450
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="editor-range-2"]').text()).toContain('251450');
});
});
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
afterEach(() => {
vi.clearAllMocks();
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import AppBottomNav from '../../resources/js/components/layout/AppBottomNav.vue';
const router = createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/autopodbor', component: { template: '<div/>' } },
{ path: '/projects', component: { template: '<div/>' } },
{ path: '/deals', component: { template: '<div/>' } },
{ path: '/billing', component: { template: '<div/>' } },
{ path: '/kanban', component: { template: '<div/>' } },
],
});
describe('AppBottomNav', () => {
it('рендерит ровно 5 вкладок Поле/Проекты/Сделки/Биллинг/Канбан по порядку', async () => {
const vuetify = createVuetify();
router.push('/deals');
await router.isReady();
const wrapper = mount(AppBottomNav, { global: { plugins: [vuetify, router] } });
const labels = wrapper.findAll('[data-testid="bottomnav-item"]').map((n) => n.text());
expect(labels).toEqual(['Поле', 'Проекты', 'Сделки', 'Биллинг', 'Канбан']);
});
});
@@ -14,7 +14,7 @@ const vuetify = createVuetify();
function makeNav(competitorId: number | null = 3) {
return {
go: vi.fn(),
ctx: reactive({ competitorId, editProjectId: null, selectedSourceIds: [] as number[] }),
ctx: reactive({ competitorId, editProjectId: null, selectedSourceIds: [] as number[], openProjectSettingsId: null as number | null }),
screen: ref('fieldcompetitor'),
};
}
@@ -292,4 +292,47 @@ describe('FieldCompetitorScreen', () => {
expect(w.find('.ld-collecting__spin').exists()).toBe(true);
expect(w.text()).toContain('Изучаю: Сибиряк');
});
// Кнопка пуск/стоп в модалке «Настройки проекта» (2026-07-08).
it('кнопка пуск/стоп в модалке настроек переключает проект, не закрывая окно', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]);
const toggleSpy = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue();
const w = mountFc(makeNav(3));
await new Promise((r) => setTimeout(r, 0));
// открыть модалку настроек для проекта 100
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(w.vm as any).openSettings((w.vm as any).inWork[0]);
await w.vm.$nextTick();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((w.vm as any).psettings.isActive).toBe(true);
expect(w.text()).toContain('Остановить');
// нажать «Остановить»
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (w.vm as any).toggleFromSettings(false);
await new Promise((r) => setTimeout(r, 0));
expect(toggleSpy).toHaveBeenCalledWith(100, false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((w.vm as any).psettings.isActive).toBe(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((w.vm as any).psettings.open).toBe(true); // НЕ закрылась
});
it('открывает модалку настроек при заходе по ссылке (ctx.openProjectSettingsId)', async () => {
const store = useAutopodborStore();
seed(store, [src({ id: 10, project: proj({ id: 100, is_active: true }) })]);
const nav = makeNav(3);
nav.ctx.openProjectSettingsId = 100;
const w = mountFc(nav);
await new Promise((r) => setTimeout(r, 0));
await w.vm.$nextTick();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((w.vm as any).psettings.open).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((w.vm as any).psettings.projectId).toBe(100);
expect(nav.ctx.openProjectSettingsId).toBeNull();
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createRouter, createMemoryHistory } from 'vue-router';
vi.mock('../../resources/js/stores/autopodborStore', () => ({
useAutopodborStore: () => ({ loadState: vi.fn().mockResolvedValue(undefined) }),
}));
import AutopodborView from '../../resources/js/views/autopodbor/AutopodborView.vue';
async function mountWithQuery(query: Record<string, string>) {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/autopodbor', name: 'autopodbor', component: { template: '<div />' } }],
});
await router.push({ path: '/autopodbor', query });
await router.isReady();
return mount(AutopodborView, {
global: {
plugins: [router],
stubs: {
FieldWorkspaceScreen: { template: '<div>field</div>' },
FieldCompetitorScreen: { template: '<div class="fc">competitor screen</div>' },
EditProjectScreen: true,
FieldProposalsScreen: true,
EntryScreen: true,
AutoFormScreen: true,
ManualFormScreen: true,
LoadingScreen: true,
ListScreen: true,
DetailScreen: true,
CreateScreen: true,
DoneScreen: true,
},
},
});
}
describe('AutopodborView deep-link (?competitor=&project=)', () => {
beforeEach(() => setActivePinia(createPinia()));
it('query competitor → экран конкурента с ctx.competitorId', async () => {
const w = await mountWithQuery({ competitor: '3' });
await flushPromises();
expect((w.vm as any).screen).toBe('fieldcompetitor');
expect((w.vm as any).ctx.competitorId).toBe(3);
});
it('query competitor+project → выставлен ctx.openProjectSettingsId', async () => {
const w = await mountWithQuery({ competitor: '3', project: '5' });
await flushPromises();
expect((w.vm as any).ctx.openProjectSettingsId).toBe(5);
});
it('без query — дефолтный экран field', async () => {
const w = await mountWithQuery({});
await flushPromises();
expect((w.vm as any).screen).toBe('field');
expect((w.vm as any).ctx.competitorId).toBeNull();
});
it('невалидный competitor — остаёмся на field', async () => {
const w = await mountWithQuery({ competitor: 'abc' });
await flushPromises();
expect((w.vm as any).screen).toBe('field');
});
});
+124
View File
@@ -4,6 +4,10 @@ import { createVuetify } from 'vuetify';
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
// проверяют табличный слой — фиксируем десктоп-ширину.
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
const vuetify = createVuetify();
const sampleDeals: MockDeal[] = [
@@ -75,4 +79,124 @@ describe('DealsTable', () => {
await w.find('tbody tr').trigger('click');
expect(w.emitted('row-click')?.[0]?.[0]).toMatchObject({ id: 1 });
});
// Автоподбор (2026-07-08): колонки «Конкурент» + «Источник».
const RouterLinkStub = {
name: 'RouterLink',
props: ['to'],
template: '<a class="rl"><slot /></a>',
};
function mountTable(deals: MockDeal[]) {
return mount(DealsTable, {
props: { deals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify], stubs: { RouterLink: RouterLinkStub } },
});
}
it('рендерит колонки «Конкурент» и «Источник»', () => {
const w = mountTable(sampleDeals);
const headers = w.findAll('thead th').map((h) => h.text());
['Конкурент', 'Источник'].forEach((label) => {
expect(headers.some((h) => h.includes(label))).toBe(true);
});
});
it('рисует ссылку на конкурента и на источник', () => {
const deal: MockDeal = {
id: 10,
name: '79000000001',
phone: '79000000001',
statusSlug: 'new',
project: 'X',
manager: { initials: '—', name: '—' },
cost: 0,
receivedMinutesAgo: 1,
competitorId: 3,
competitorName: 'AutoMoney',
sourceIdentifier: '79135396546',
sourceSignalType: 'call',
sourceProjectId: 5,
sourceIsActive: true,
sourceExtraCount: 0,
};
const w = mountTable([deal]);
expect(w.text()).toContain('AutoMoney');
expect(w.text()).toContain('79135396546');
expect(w.text()).toContain('Звонки');
const links = w.findAllComponents(RouterLinkStub);
expect(links.length).toBe(2);
// Конкурент → autopodbor?competitor=3
expect(links[0].props('to')).toMatchObject({ name: 'autopodbor', query: { competitor: '3' } });
// Источник → autopodbor?competitor=3&project=5
expect(links[1].props('to')).toMatchObject({ name: 'autopodbor', query: { competitor: '3', project: '5' } });
});
it('«Конкурент» — только бренд до первой запятой, без описания', () => {
const deal: MockDeal = {
id: 12,
name: '79000000002',
phone: '79000000002',
statusSlug: 'new',
project: 'X',
manager: { initials: '—', name: '—' },
cost: 0,
receivedMinutesAgo: 1,
competitorId: 4,
competitorName: 'AutoMoney, микрокредитная компания',
sourceIdentifier: '79135396546',
sourceSignalType: 'call',
sourceProjectId: 5,
sourceIsActive: true,
sourceExtraCount: 0,
};
const w = mountTable([deal]);
expect(w.text()).toContain('AutoMoney');
expect(w.text()).not.toContain('микрокредитная');
});
it('показывает « · +N» при нескольких источниках проекта', () => {
const deal: MockDeal = {
id: 11,
name: 'x',
phone: 'x',
statusSlug: 'new',
project: 'X',
manager: { initials: '—', name: '—' },
cost: 0,
receivedMinutesAgo: 1,
competitorId: 3,
competitorName: 'Сфера займа24',
sourceIdentifier: '79029826282',
sourceSignalType: 'call',
sourceProjectId: 5,
sourceIsActive: true,
sourceExtraCount: 2,
};
const w = mountTable([deal]);
expect(w.text()).toContain('+2');
});
it('без конкурента — «Конкурент» = прочерк, «Источник» = название проекта без ссылок', () => {
const deal: MockDeal = {
id: 12,
name: 'y',
phone: '79000000002',
statusSlug: 'new',
project: 'Обычный проект',
manager: { initials: '—', name: '—' },
cost: 0,
receivedMinutesAgo: 1,
competitorId: null,
competitorName: null,
sourceIdentifier: null,
sourceSignalType: null,
sourceProjectId: null,
sourceIsActive: null,
sourceExtraCount: 0,
};
const w = mountTable([deal]);
expect(w.text()).toContain('Обычный проект');
expect(w.findAllComponents(RouterLinkStub).length).toBe(0);
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import ResponsiveTable from '../../resources/js/components/common/ResponsiveTable.vue';
function mountAt(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
window.dispatchEvent(new Event('resize'));
const vuetify = createVuetify();
return mount(ResponsiveTable, {
global: { plugins: [vuetify] },
props: { items: [{ id: 1, phone: '+7 900 000' }] },
slots: {
table: '<div data-testid="as-table">TABLE</div>',
card: '<div data-testid="as-card">CARD</div>',
},
});
}
describe('ResponsiveTable', () => {
it('на телефоне (<600) показывает слот card', () => {
const w = mountAt(390);
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на планшете-портрете (768) показывает слот card, а не обрезанную таблицу', () => {
const w = mountAt(768);
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на десктопе (>=1280) показывает слот table', () => {
const w = mountAt(1440);
expect(w.find('[data-testid="as-table"]').exists()).toBe(true);
expect(w.find('[data-testid="as-card"]').exists()).toBe(false);
});
});
@@ -7,6 +7,10 @@ import type { BillingTransaction, TransactionsPage } from '../../resources/js/ap
vi.mock('../../resources/js/api/billing');
// ResponsiveTable показывает таблицу только на десктопе (>=1280); эти тесты
// проверяют табличный слой — фиксируем десктоп-ширину.
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 });
const vuetify = createVuetify();
function txn(over: Partial<BillingTransaction> = {}): BillingTransaction {
+31
View File
@@ -106,4 +106,35 @@ describe('mapApiDeal', () => {
const m2 = mapApiDeal(baseApi);
expect(m2.costKopecks).toBeNull();
});
// Автоподбор (2026-07-08): конкурент + источник.
it('прокидывает competitor/source поля автоподбора', () => {
const m = mapApiDeal({
...baseApi,
competitor_id: 3,
competitor_name: 'AutoMoney',
source_identifier: '79135396546',
source_signal_type: 'call',
source_project_id: 5,
source_is_active: true,
source_extra_count: 1,
});
expect(m.competitorId).toBe(3);
expect(m.competitorName).toBe('AutoMoney');
expect(m.sourceIdentifier).toBe('79135396546');
expect(m.sourceSignalType).toBe('call');
expect(m.sourceProjectId).toBe(5);
expect(m.sourceIsActive).toBe(true);
expect(m.sourceExtraCount).toBe(1);
});
it('competitor/source = null/0 когда полей нет в ответе', () => {
const m = mapApiDeal(baseApi);
expect(m.competitorId).toBeNull();
expect(m.competitorName).toBeNull();
expect(m.sourceIdentifier).toBeNull();
expect(m.sourceProjectId).toBeNull();
expect(m.sourceIsActive).toBeNull();
expect(m.sourceExtraCount).toBe(0);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
/**
* Тесты ленивого загрузчика Яндекс.Метрики (resources/js/plugins/metrika.ts).
* Загрузчик грузится только из роутера при входе в кабинет (см. router/index.ts).
*/
describe('metrika loader', () => {
beforeEach(() => {
document.head.innerHTML = '';
document.body.innerHTML = '';
delete window.ym;
vi.resetModules();
});
it('без meta-тега ничего не грузит и не падает', async () => {
const { ensureLoaded } = await import('../../resources/js/plugins/metrika');
ensureLoaded();
expect(document.querySelector('script[src*="mc.yandex.ru"]')).toBeNull();
});
it('с meta-тегом инжектит tag.js один раз (идемпотентно)', async () => {
const meta = document.createElement('meta');
meta.name = 'metrika-id';
meta.content = '99999999';
document.head.appendChild(meta);
const { ensureLoaded } = await import('../../resources/js/plugins/metrika');
ensureLoaded();
ensureLoaded();
const scripts = document.querySelectorAll('script[src*="mc.yandex.ru/metrika/tag.js"]');
expect(scripts.length).toBe(1);
});
it('hit() вызывает window.ym с id из meta и переданным url', async () => {
const meta = document.createElement('meta');
meta.name = 'metrika-id';
meta.content = '99999999';
document.head.appendChild(meta);
const ym = vi.fn();
window.ym = ym;
const { hit } = await import('../../resources/js/plugins/metrika');
hit('/dashboard');
expect(ym).toHaveBeenCalledWith(99999999, 'hit', '/dashboard');
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { tierRangeText } from '../../resources/js/utils/pricingTiers';
/**
* Единый источник истины для «ширина ступени накопительный диапазон».
* Числа ровно те, что клиент видит в кабинете (боевая сетка 08.07.2026):
* ширины 250/500/1000/2500/5000/10000/ 1250, 251750, , 19251+.
*/
const tiers = [
{ leads_in_tier: 250 },
{ leads_in_tier: 500 },
{ leads_in_tier: 1000 },
{ leads_in_tier: 2500 },
{ leads_in_tier: 5000 },
{ leads_in_tier: 10000 },
{ leads_in_tier: null },
];
describe('tierRangeText', () => {
it('первая ступень начинается с 1', () => {
expect(tierRangeText(tiers, 0)).toBe('1250');
});
it('вторая ступень — накопительный диапазон, а не «сама ширина»', () => {
expect(tierRangeText(tiers, 1)).toBe('251750');
});
it('третья ступень продолжает накопление', () => {
expect(tierRangeText(tiers, 2)).toBe('7511750');
});
it('шестая ступень', () => {
// 250+500+1000+2500 = 4250 → пятая 42519250 → шестая 925119250
expect(tierRangeText(tiers, 5)).toBe('925119250');
});
it('последняя ступень (leads_in_tier=null) — открытый диапазон со знаком +', () => {
// 250+500+1000+2500+5000+10000 = 19250 → tier 7 стартует с 19251
expect(tierRangeText(tiers, 6)).toBe('19251+');
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest';
import { REGIONS, REGIONS_ALPHA } from '../../resources/js/constants/regions';
const names = () => REGIONS_ALPHA.map((r) => r.name);
const idx = (name: string) => names().indexOf(name);
describe('REGIONS_ALPHA — регионы для выпадашек, по сути названия', () => {
it('исключает sentinel «Вся РФ» (code 0) и содержит все остальные регионы', () => {
expect(REGIONS_ALPHA.some((r) => r.code === 0)).toBe(false);
expect(REGIONS_ALPHA).toHaveLength(REGIONS.length - 1);
});
it('первым идёт «Республика Адыгея» (Адыгея — раньше Алтая/Амурской)', () => {
expect(names()[0]).toBe('Республика Адыгея');
});
it('республики встают по своему имени, а не сваливаются в кучу на «Р»', () => {
// Отбрасываем тип: «Республика Татарстан» → «Татарстан» → буква Т,
// поэтому она ПОСЛЕ «Свердловской области» (С), а не рядом с другими Р.
expect(idx('Республика Татарстан')).toBeGreaterThan(idx('Свердловская область'));
expect(idx('Республика Татарстан')).toBeGreaterThan(idx('Тамбовская область'));
expect(idx('Республика Татарстан')).toBeLessThan(idx('Тверская область'));
});
it('«Донецкая Народная Республика» сортируется по «Донецкая» (Д), рядом с Дагестаном', () => {
expect(idx('Республика Дагестан')).toBeLessThan(idx('Донецкая Народная Республика'));
expect(idx('Донецкая Народная Республика')).toBeLessThan(idx('Республика Ингушетия'));
});
it('края и области — тоже по имени: «Краснодарский» и «Красноярский» рядом', () => {
expect(idx('Красноярский край')).toBe(idx('Краснодарский край') + 1);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { defineComponent } from 'vue';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { useLayoutTier } from '../../resources/js/composables/useLayoutTier';
function tierAtWidth(width: number): string {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
window.dispatchEvent(new Event('resize'));
const vuetify = createVuetify();
let captured = '';
const Harness = defineComponent({
setup() {
const { tier } = useLayoutTier();
captured = tier.value;
return () => null;
},
});
mount(Harness, { global: { plugins: [vuetify] } });
return captured;
}
describe('useLayoutTier', () => {
it('телефон при ширине < 600', () => { expect(tierAtWidth(390)).toBe('phone'); });
it('планшет при 600..1279', () => { expect(tierAtWidth(820)).toBe('tablet'); });
it('десктоп при >= 1280', () => { expect(tierAtWidth(1440)).toBe('desktop'); });
});
@@ -0,0 +1,63 @@
<?php
use App\Services\Autopodbor\Agent\Extract\CandidateBuilder;
use App\Services\Autopodbor\Agent\Extract\HtmlPhoneScanner;
use App\Services\Autopodbor\Agent\Extract\SourceAggregator;
use App\Services\Autopodbor\Agent\Fetch\FetchedSite;
/**
* Второй пробел (после фикса таймаута рендера): номера, написанные на сайте ПРОСТЫМ ТЕКСТОМ
* (не кликабельные tel:, без коллтрекинга), терялись. Пример Центрофинанс: 8 800 200-00-10
* («Телефон:» у юр. адреса), +7 931 106-54-50 (подвал). Их надо показывать клиенту с пометкой
* «на сайте проверьте» (phoneKind unverified), аккуратно, без шума из <script>.
*/
it('scanner: не тянет в тело номера из <script>/<style> (шум конфигов)', function () {
$html = '<script>var t="8 (495) 111-22-33";</script><style>/* 8 (495) 444-55-66 */</style>'
.'<div>Телефон: 8 (495) 011-03-59</div>';
$r = (new HtmlPhoneScanner)->scan($html);
expect($r['body'])->toHaveKey('74950110359') // видимый текстовый — есть
->and($r['body'])->not->toHaveKey('74951112233') // из <script> — нет
->and($r['body'])->not->toHaveKey('74954445566'); // из <style> — нет
});
it('builder: текстовый номер (не tel:, без трекера) → кандидат kind=text «проверьте»', function () {
$site = new FetchedSite(
url: 'https://k.ru/',
rawHtml: '<a href="tel:+74950110359">кликабельный</a>'
.'<div>Телефон: 8 (800) 200-00-10</div>',
);
$byNum = collect((new CandidateBuilder)->build([$site], []))->keyBy('number');
expect($byNum['74950110359']['kind'])->toBe('code'); // из tel: — настоящий
expect($byNum)->toHaveKey('78002000010'); // текстовый НЕ потерян
expect($byNum['78002000010']['kind'])->toBe('text');
expect($byNum['78002000010']['label'])->toContain('проверьте');
});
it('builder: номер и в tel:, и в тексте — только code (без дубля text)', function () {
$site = new FetchedSite(
url: 'https://k.ru/',
rawHtml: '<a href="tel:88007003767">8 800 700-37-67</a> звоните 8 800 700-37-67',
);
$kinds = collect((new CandidateBuilder)->build([$site], []))
->where('number', '78007003767')->pluck('kind');
expect($kinds)->toContain('code')->and($kinds)->not->toContain('text');
});
it('aggregator: текстовый номер не отбрасывает, помечает phoneKind=unverified', function () {
$out = (new SourceAggregator)->aggregate([
['number' => '78002000010', 'kind' => 'text', 'label' => 'в тексте сайта — проверьте', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => false],
]);
expect($out)->toHaveCount(1)
->and($out[0]->phoneKind)->toBe('unverified');
});
it('aggregator: номер и в тексте, и в коде — настоящий (code перебивает text)', function () {
$out = (new SourceAggregator)->aggregate([
['number' => '78007003767', 'kind' => 'text', 'label' => 'в тексте сайта — проверьте', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => false],
['number' => '78007003767', 'kind' => 'code', 'label' => 'в коде сайта', 'url' => 'https://k.ru/', 'office' => null, 'tracker' => false],
]);
expect($out)->toHaveCount(1)
->and($out[0]->phoneKind)->toBe('real');
});
@@ -0,0 +1,41 @@
<?php
use App\Services\Autopodbor\Agent\Fetch\CurlPlaywrightFetcher;
/**
* Регрессия боевого бага (07.07.2026, конкурент «Центрофинанс», tenant info@lkomega.ru):
* curl успешно скачивал сайт с tel:-номерами, но рендер (node render-page.cjs) висел на
* networkidle и падал по таймауту. Symfony Process::run() при таймауте БРОСАЕТ
* ProcessTimedOutException и оно вылетало из site(), обнуляя уже успешный curl.
* Итог: ни одного номера с сайта. Рендер обязан быть НЕ фатальным: его падение не должно
* ни ронять site()/directory(), ни терять номера, добытые curl.
*
* Сиды конструктора (curlRunner/nodeRunner) подменяют сеть и запуск node без подпроцесса.
* nodeRunner бросает исключение = «рендер завис/упал» (в бою это ProcessTimedOutException;
* продакшн ловит \Throwable, тип не важен).
*/
function fetcherWithFailingRender(?callable $curl = null): CurlPlaywrightFetcher
{
return new CurlPlaywrightFetcher(
timeout: 1,
curlRunner: $curl ?? fn (string $url): string => '',
nodeRunner: function (string $script, string $url): ?array {
throw new \RuntimeException('render timed out');
},
);
}
it('site(): при падении рендера НЕ теряет номера, добытые curl', function () {
$curl = fn (string $url): string => '<a href="tel:88007003767">8 800</a><a href="tel:84950110359">8 495</a>';
// 8.8.8.8 — публичный IP: проходит isSafeUrl без DNS; сеть не трогаем (curl подменён).
$site = fetcherWithFailingRender($curl)->site('https://8.8.8.8/');
expect($site->rawHtml)->toContain('tel:88007003767')
->and($site->rawHtml)->toContain('tel:84950110359')
->and($site->visiblePhones)->toBe([]); // рендер упал — видимых нет, но curl цел
});
it('directory(): при падении рендера возвращает [] вместо исключения', function () {
expect(fetcherWithFailingRender()->directory('https://8.8.8.8/'))->toBe([]);
});
@@ -189,6 +189,39 @@ it('сайт без схемы в карточке — добавляем https:
->and($site['site_url'])->toBe('https://zvezda-dsk.ru');
});
it('гейт «та же фирма»: чужой город + чужой домен выкидывает; свой город и свой бренд оставляет', function () {
// Баг AutoMoney: разворот по общему/партнёрскому номеру тащит чужие МФО из Москвы/Питера.
// Город карточки 2ГИС берём из ссылки (…/<city>/firm/…) — 100% надёжно.
$search = 'https://2gis.ru/krasnoyarsk/search/73912273531';
$own = 'https://2gis.ru/krasnoyarsk/firm/111'; // свой город
$foreign = 'https://2gis.ru/moscow/firm/222'; // чужой город + чужой домен → выкинуть
$branch = 'https://2gis.ru/ekaterinburg/firm/333'; // чужой город, НО наш бренд (поддомен) → оставить
$fetch = fakeFetcher([
$search => '<a href="/krasnoyarsk/firm/111">Наша</a>'
.'<a href="/moscow/firm/222">Чужая МФО</a>'
.'<a href="/ekaterinburg/firm/333">Наш филиал</a>',
$own => '{"url":"https://my-krsk.ru","type":"website"}{"type":"phone","value":"+73912002288"}',
$foreign => '{"url":"https://moneyman.ru","type":"website"}{"type":"phone","value":"+74991234567"}',
$branch => '{"url":"https://ekb.my-krsk.ru","type":"website"}{"type":"phone","value":"+73431112233"}',
]);
$exp = new PhoneReverseExpander($fetch);
$res = $exp->expand('krasnoyarsk', ['73912273531'], ['my-krsk.ru']);
$domains = array_column($res->sites, 'domain');
$phones = array_column($res->phones, 'digits');
// свой город — оставлен (сайт = seed-домен, дедуплен; проверяем по телефону)
expect($phones)->toContain('73912002288');
// чужой город + чужой домен — выкинут целиком (и сайт, и телефон)
expect($domains)->not->toContain('moneyman.ru')
->and($phones)->not->toContain('74991234567');
// чужой город, но наш бренд (поддомен my-krsk.ru) — оставлен
expect($domains)->toContain('ekb.my-krsk.ru')
->and($phones)->toContain('73431112233');
});
it('ровно порог фирм — НЕ общая линия, находки берём', function () {
$search = 'https://2gis.ru/krasnoyarsk/search/73912273531';
$links = '';
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────────────────────
# Лидерра — ЗАЩИЩЁННЫЙ выкат фронт-сборки портала (public/build).
#
# ЗАЧЕМ. Портал выкатывается ПОЛНОЙ заменой папки public/build. Если собрать
# из УСТАРЕВШЕЙ ветки (где нет чужих свежих фич) и залить — чужая работа
# МОЛЧА затирается. Реальный инцидент 08.07.2026: выкат Яндекс.Метрики,
# собранный из ветки без колонок «Сделок», снёс колонки «Конкурент/Источник».
#
# ЧТО ДЕЛАЕТ. Перед подменой сверяет: новая сборка НЕ должна ПОТЕРЯТЬ ни одной
# критичной фичи, которая есть в живой сборке. Потеряла — ОТКАЗ, боевой не тронут.
# Осознанный откат (когда фичу убираем намеренно) — только с FORCE=1.
#
# ВАЖНО. Маркеры ищем ТОЛЬКО в чанках, на которые ссылается manifest.json
# (активная сборка), а не по всей assets/ — там копятся «осколки» старых
# overlay-выкатов, из-за которых наивная проверка промахивалась.
#
# ИСПОЛЬЗОВАНИЕ (с dev-машины):
# tar czf build.tgz -C app/public/build .
# scp build.tgz liderra:/tmp/build.tgz
# ssh liderra 'sudo bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "ветка/sha/что выкатываем"'
# # только проверить, не выкатывая:
# ssh liderra 'sudo DRY_RUN=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "проверка"'
# # осознанный откат/удаление фичи:
# ssh liderra 'sudo FORCE=1 bash /var/www/liderra/deploy-build.sh /tmp/build.tgz "rollback: ..."'
#
# Откат в любой момент: mv build.bak-guard-<ts> build (см. вывод скрипта).
# ─────────────────────────────────────────────────────────────────────────────
set -euo pipefail
TGZ="${1:?укажи путь к tar.gz со сборкой (содержимое папки build)}"
INFO="${2:-}"
FORCE="${FORCE:-}"
DRY_RUN="${DRY_RUN:-}"
BASE=/var/www/liderra/app/public
LIVE="$BASE/build"
# Критичные маркеры фич: если строка есть в ЖИВОЙ сборке — обязана быть и в новой.
# ➕ Добавляй строку сюда, когда выкатываешь новую пользовательскую фичу,
# чтобы её тоже нельзя было случайно затереть следующим выкатом.
MARKERS=(
"Конкурент" # колонки «Сделок»: Конкурент / Источник
"mc.yandex" # Яндекс.Метрика (счётчик + Вебвизор)
"ym-hide-content" # маскировка ПДн лидов для Метрики (152-ФЗ) — критично
"deal-card" # мобильные/планшетные карточки сделок
"autopodbor" # автоподбор конкурентов
)
echo "▶ Защищённый выкат сборки портала. Инфо: ${INFO:-}"
[ -f "$TGZ" ] || { echo "❌ Нет файла: $TGZ"; exit 2; }
STAGING="$BASE/.build-staging.$$"
rm -rf "$STAGING"; mkdir -p "$STAGING"
tar xzf "$TGZ" -C "$STAGING"
[ -f "$STAGING/manifest.json" ] || { echo "❌ В сборке нет manifest.json — это не билд портала."; rm -rf "$STAGING"; exit 2; }
# Список АКТИВНЫХ чанков (js/css) по manifest — без orphan-осколков прошлых выкатов.
active_files() { # $1 = dir сборки → печатает полные пути к активным чанкам
local dir="$1"
grep -oE 'assets/[A-Za-z0-9_.-]+\.(js|css)' "$dir/manifest.json" | sort -u | sed "s|^|$dir/|"
}
mapfile -t LIVE_FILES < <(active_files "$LIVE" 2>/dev/null || true)
mapfile -t NEW_FILES < <(active_files "$STAGING" 2>/dev/null || true)
hits() { # $1 = маркер, далее список файлов → сколько файлов содержат маркер
local m="$1"; shift
[ "$#" -eq 0 ] && { echo 0; return 0; }
# grep возвращает 1 при «не найдено»; при pipefail+set -e это рвёт скрипт —
# поэтому глушим код через "|| true", а число берём из вывода wc.
local c
c=$(grep -lF -- "$m" "$@" 2>/dev/null | wc -l) || true
echo "${c:-0}"
}
# ── ГЛАВНАЯ ЗАЩИТА: новая сборка не должна терять фичи живой ──────────────────
missing=()
for m in "${MARKERS[@]}"; do
live_n=$(hits "$m" "${LIVE_FILES[@]}")
new_n=$(hits "$m" "${NEW_FILES[@]}")
if [ "$live_n" -gt 0 ] && [ "$new_n" -eq 0 ]; then
missing+=("$m")
fi
done
if [ "${#missing[@]}" -gt 0 ] && [ "$FORCE" != "1" ]; then
echo ""
echo "🛑 ОТКАЗ: новая сборка ТЕРЯЕТ фичи, которые есть на боевом:"
printf ' • %s\n' "${missing[@]}"
echo ""
echo " Похоже, собрано из УСТАРЕВШЕЙ ветки (без чужой работы)."
echo " Пересобери из ветки-надмножества — где есть ВСЁ живое."
echo " Если это ОСОЗНАННЫЙ откат/удаление фичи — повтори с FORCE=1."
rm -rf "$STAGING"
exit 3
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "⚠ FORCE=1: осознанно теряем фичи (${missing[*]}) — по прямому указанию."
fi
if [ "$DRY_RUN" = "1" ]; then
echo "✅ DRY_RUN: проверка пройдена, все живые фичи на месте. Выкат НЕ выполнялся."
rm -rf "$STAGING"
exit 0
fi
# ── Подмена с бэкапом и отметкой о выкате ────────────────────────────────────
ts="$(date -u +%Y%m%d-%H%M%S)"
mv "$LIVE" "$BASE/build.bak-guard-$ts"
mv "$STAGING" "$LIVE"
appjs=$(grep -oE 'assets/app-[a-zA-Z0-9_-]+\.js' "$LIVE/manifest.json" | head -1)
printf '{"deployed_at":"%s","info":"%s","bundle":"%s","guard_ok":true}\n' \
"$ts" "${INFO//\"/}" "$appjs" > "$LIVE/DEPLOYED.json"
chown -R www-data:www-data "$LIVE"
echo "✅ Выкат ок. Бандл: $appjs. Бэкап прежней сборки: build.bak-guard-$ts"
@@ -6,7 +6,7 @@
## Где мы
Автоподбор «Конкурентное поле» **работает на боевом** liderra.ru у клиента. Тестовый кабинет —
**info@lkomega.ru = tenant 2** (ниша «займы под залог авто», Красноярск). Портал — **lk.liderra.ru**,
ЛК поставщика — crm.lead.store. Ветка работы — **`worktree-avtopodbor`**, gitea main на `d4aec229`.
ЛК поставщика — crm.lead.store. Ветка работы — **`worktree-avtopodbor`**, gitea `94cfc7ce`.
## Что сделано сегодня 07.07 (ВСЁ на проде, по TDD; детали — память + ПИЛОТ.md)
1. **RLS-хотфикс** — автоподбор падал у первого клиента (`ModelNotFoundException`): 3 джобы читали прогон
@@ -19,6 +19,10 @@
5. **Кнопка «Отказаться»** на актуализации (dismiss-actualize): колонка `dismissed_actualize_keys`
(миграция `2026_07_07_130000` на проде), классификатор вычитает отклонённые ключи.
6. schema.sql **v8.63** + CHANGELOG, ПИЛОТ.md, память обновлены.
7. **Сортировка регионов** во всех выпадашках (автоподбор 7 экранов + раздел «Проекты» 4 выпадашки) —
по сути названия (Адыгея→А, Татарстан→Т, республики не кучей). Константа `REGIONS_ALPHA` в
`resources/js/constants/regions.ts` + тест `regionsAlpha.spec.ts`. На проде (фронт-сборка залита).
Коммиты `4451231c` (автоподбор) + `94cfc7ce` («Проекты»).
Память пунктов 2–5: `project-autopodbor-post-hotfix-fixes-2026-07-07`.
## 🔴 ОТКРЫТО / следующая работа (по приоритету)
@@ -30,8 +34,15 @@
`project-autopodbor-step2-reverse-phone-pollution-2026-07-07`.
2. **Живая приёмка кнопки «Отказаться»** под клиентом (в кабинете есть 3 карточки актуализации:
Драйв Займ, Центрофинанс×2) — не жал сам, чтобы не отклонять его реальные карточки.
3. **Невыкаченное на прод** (в ветке, за тумблерами): deep-study порт, render-cache, EXA-прокси-тоннель
см. память `project-autopodbor-deep-study-port`, `project-autopodbor-render-cache`, `project-autopodbor-exa-proxy-tunnel`.
3. ~~Невыкаченное на прод (в ветке, за тумблерами): deep-study порт, render-cache, EXA-прокси-тоннель.~~
**ИСПРАВЛЕНО 07.07 — эти три УЖЕ на проде и ВКЛЮЧЕНЫ** (проверено командой на боевом):
`AUTOPODBOR_DEEP_STUDY=true`, `AUTOPODBOR_RENDER_CACHE=true` (TTL 5 дн), `EXA_PROXY=socks5h://127.0.0.1:10808`
(тоннель живой — прокси отдаёт зарубежный IP `203.14.32.179` ≠ прямой прод `111.88.246.137`); пакет
`Study/` (19 файлов) и `CachingPageFetcher.php` на проде. Уехали **06.07** в составе релиза автоподбора;
прежняя запись «в ветке, за тумблерами» — устарела (заметки point-in-time, состояние проверять командой).
⚠️ **Следствие:** баг «шаг 2 AutoMoney» (п.1) — это баг в **уже-живом** deep-study, а не отложенная фича,
т.е. он реально портит выдачу клиенту сейчас → фактически приоритет №1.
Память: `project-autopodbor-deep-study-port`, `project-autopodbor-render-cache`, `project-autopodbor-exa-proxy-tunnel`.
## Ключевые факты для работы (чтобы не гадать)
- **БД боевая** = Managed PG кластер `c9q2cvtjpq3hgq6l0r96` (rw *.rw.mdb.yandexcloud.net:6432). Приложение —
@@ -0,0 +1,841 @@
# «Сделки» → колонки «Конкурент» + «Источник» — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Разбить колонку «Источник» в реестре сделок на две кликабельные колонки — «Конкурент» (→ экран конкурента в автоподборе) и «Источник» (→ модалка «Настройки проекта» с новой кнопкой пуск/стоп) — не меняя дизайн существующих компонентов.
**Architecture:** Бэкенд `DealController::index` дополнительно резолвит по каждой сделке конкурента и источник автоподбора (связь `deal.project_id → autopodbor_sources.created_project_id → competitor`), грузит пачкой без N+1. Фронт расширяет контракт `ApiDeal`/`MockDeal`, рисует две колонки ссылками (тот же зелёный `<router-link>`), а автоподбор учится принимать deep-link `?competitor=&project=`. В модалку настроек добавляется одна кнопка пуск/стоп поверх готового `store.toggleProjectActive`.
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 (backend); Vue 3 / Vuetify 3 / Pinia / Vitest + @vue/test-utils (frontend). Ветка: `worktree-avtopodbor`.
**Дизайн-инвариант (HARD):** визуальный вид не меняем. Только: было 1 колонка «Источник» → стало 2 колонки; в модалке настроек +1 кнопка. Никаких новых стилей/шрифтов/цветов сверх уже существующих классов.
---
## Файловая карта
**Backend:**
- Modify: `app/app/Http/Controllers/Api/DealController.php` — импорт `AutopodborSource`; в `index()` добавить `is_active` в eager-load проекта, батч-резолв источника/конкурента, 7 новых полей в payload.
- Test: `app/tests/Feature/DealIndexTest.php` — 4 новых кейса.
**Frontend (контракт + маппер):**
- Modify: `app/resources/js/api/deals.ts` — 7 полей в `ApiDeal`.
- Modify: `app/resources/js/composables/mockDeals.ts` — 6 опц. полей в `MockDeal`.
- Modify: `app/resources/js/composables/dealsApiMapper.ts` — прокинуть поля.
- Test: `app/tests/Frontend/dealsApiMapper.spec.ts`.
**Frontend (таблица):**
- Modify: `app/resources/js/components/deals/DealsTable.vue` — 1 колонка → 2 (Конкурент/Источник) со ссылками.
- Test: `app/tests/Frontend/DealsTable.spec.ts`.
**Frontend (deep-link + кнопка):**
- Modify: `app/resources/js/views/autopodbor/AutopodborView.vue` — чтение `route.query` + поле `ctx.openProjectSettingsId`.
- Modify: `app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue``psettings.isActive`, кнопка пуск/стоп, авто-открытие по `ctx.openProjectSettingsId`.
- Test: `app/tests/Frontend/AutopodborView.deeplink.spec.ts` (новый), `app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts`.
---
## Предисловие для воркера: как гонять тесты в этом worktree
Тест-БД поднимается отдельно (НЕ прод). Перед первым backend-прогоном в свежем worktree — рецепт бутстрапа (память `feedback-worktree-test-bootstrap-recipe`): своя тест-БД + `php artisan migrate` + `php artisan partitions:create-months`. Pest — ТОЛЬКО последовательно если `--parallel` флейкает (quirks 72/73/77).
- Backend один тест: `cd app && php artisan test --filter='<name>'`
- Frontend один файл: `cd app && npx vitest run tests/Frontend/<File>.spec.ts`
---
## Task 1: Backend — резолв конкурента и источника в списке сделок
**Files:**
- Modify: `app/app/Http/Controllers/Api/DealController.php` (импорт + `index()`)
- Test: `app/tests/Feature/DealIndexTest.php`
Связь (из схемы): `autopodbor_sources.created_project_id → projects.id`, `autopodbor_sources.competitor_id → autopodbor_competitors.id (name, description)`. Один проект может иметь несколько источников одного конкурента (merge) → конкурент единственный, источник берём представительный (сначала `box='field'`, иначе первый). `source_extra_count` = сколько источников сверх показанного.
- [ ] **Step 1: Написать падающий тест — сделка с автоподбор-источником отдаёт конкурента и источник**
В `app/tests/Feature/DealIndexTest.php` добавить в конец файла:
```php
test('GET /api/deals отдаёт competitor_* и source_* для автоподбор-сделки', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$competitor = \App\Models\AutopodborCompetitor::create([
'tenant_id' => $this->tenant->id,
'name' => 'AutoMoney',
'description' => 'микрокредитная компания',
'relevance_pct' => 58,
'box' => 'field',
]);
\App\Models\AutopodborSource::create([
'tenant_id' => $this->tenant->id,
'competitor_id' => $competitor->id,
'signal_type' => 'call',
'identifier' => '79135396546',
'created_project_id' => $this->project->id,
'box' => 'field',
]);
Deal::factory()->for($this->tenant)->create([
'project_id' => $this->project->id,
'phone' => '79000000001',
'received_at' => now(),
]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_id'])->toBe($competitor->id);
expect($res['competitor_name'])->toBe('AutoMoney');
expect($res['source_identifier'])->toBe('79135396546');
expect($res['source_signal_type'])->toBe('call');
expect($res['source_project_id'])->toBe($this->project->id);
expect($res['source_is_active'])->toBeTrue();
expect($res['source_extra_count'])->toBe(0);
});
```
- [ ] **Step 2: Запустить — убедиться, что падает**
Run: `cd app && php artisan test --filter='отдаёт competitor_'`
Expected: FAIL — ключа `competitor_id` нет в ответе (undefined index / null не равен id).
- [ ] **Step 3: Реализовать — импорт модели**
В `app/app/Http/Controllers/Api/DealController.php` после `use App\Models\ActivityLog;` (строка 8) добавить:
```php
use App\Models\AutopodborSource;
```
- [ ] **Step 4: Реализовать — is_active в eager-load проекта**
В `index()` в строке 107 в списке полей проекта добавить `is_active`. Было:
```php
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
```
Стало:
```php
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders,is_active', 'manager:id,email,first_name,last_name']);
```
- [ ] **Step 5: Реализовать — батч-резолв источников автоподбора**
В `index()` сразу ПОСЛЕ блока расчёта `$costByDeal` (после строки 207, перед `$payload = [`) вставить:
```php
// Автоподбор: по каждой сделке — конкурент + источник (связь
// deal.project_id → autopodbor_sources.created_project_id → competitor).
// Батч-запрос по project_id всей страницы (без N+1). Своя транзакция с
// app.current_tenant_id — иначе RLS на autopodbor_* вернёт 0 строк.
$sourcesByProject = collect();
$projectIds = $deals->pluck('project_id')->filter()->unique()->values()->all();
if ($projectIds !== []) {
$sourcesByProject = DB::transaction(function () use ($tenantId, $projectIds) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
return AutopodborSource::query()
->where('tenant_id', $tenantId)
->whereIn('created_project_id', $projectIds)
->with('competitor:id,name')
->get()
->groupBy('created_project_id');
});
}
```
- [ ] **Step 6: Реализовать — 7 новых полей в payload**
В `index()` в `$deals->map(...)` (внутри массива, после строки `'cost_kopecks' => $costByDeal[$d->id] ?? null,` — строка 232) добавить:
```php
// Автоподбор (audit 2026-07-08): конкурент + источник для колонок «Сделок».
...(function () use ($d, $sourcesByProject) {
$group = $sourcesByProject[$d->project_id] ?? collect();
$repr = $group->firstWhere('box', 'field') ?? $group->first();
return [
'competitor_id' => $repr?->competitor_id,
'competitor_name' => $repr?->competitor?->name,
'source_identifier' => $repr?->identifier,
'source_signal_type' => $repr?->signal_type,
'source_project_id' => $repr ? $d->project_id : null,
'source_is_active' => $repr ? (bool) $d->project?->is_active : null,
'source_extra_count' => max(0, $group->count() - 1),
];
})(),
```
- [ ] **Step 7: Запустить — тест зелёный**
Run: `cd app && php artisan test --filter='отдаёт competitor_'`
Expected: PASS.
- [ ] **Step 8: Падающий тест — merge (несколько источников на проект) → один конкурент, extra_count>0**
Добавить в `DealIndexTest.php`:
```php
test('GET /api/deals: merge источников на проект → один конкурент, source_extra_count>0', function () {
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$competitor = \App\Models\AutopodborCompetitor::create([
'tenant_id' => $this->tenant->id, 'name' => 'Сфера займа24', 'box' => 'field',
]);
foreach (['79029826282', 'sfera.ru'] as $i => $ident) {
\App\Models\AutopodborSource::create([
'tenant_id' => $this->tenant->id,
'competitor_id' => $competitor->id,
'signal_type' => $i === 0 ? 'call' : 'site',
'identifier' => $ident,
'created_project_id' => $this->project->id,
'box' => 'field',
]);
}
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBe('Сфера займа24');
expect($res['source_extra_count'])->toBe(1);
});
```
- [ ] **Step 9: Запустить — PASS (реализация из Step 5-6 уже покрывает)**
Run: `cd app && php artisan test --filter='merge источников'`
Expected: PASS.
- [ ] **Step 10: Падающий тест — обычная сделка без автоподбора → поля null/0**
```php
test('GET /api/deals: сделка без автоподбор-источника → competitor/source = null', function () {
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project2->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_id'])->toBeNull();
expect($res['competitor_name'])->toBeNull();
expect($res['source_identifier'])->toBeNull();
expect($res['source_project_id'])->toBeNull();
expect($res['source_is_active'])->toBeNull();
expect($res['source_extra_count'])->toBe(0);
});
```
- [ ] **Step 11: Запустить — PASS**
Run: `cd app && php artisan test --filter='без автоподбор-источника'`
Expected: PASS.
- [ ] **Step 12: Падающий тест — tenant-изоляция (чужой источник не подтягивается)**
```php
test('GET /api/deals: источник чужого tenant не подтягивается к сделке', function () {
// Проект нашего tenant, но autopodbor-источник создаём в контексте ЧУЖОГО tenant
// с created_project_id нашего проекта — RLS/where(tenant_id) должны его отсечь.
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
$alien = \App\Models\AutopodborCompetitor::create([
'tenant_id' => $this->otherTenant->id, 'name' => 'Чужой', 'box' => 'field',
]);
\App\Models\AutopodborSource::create([
'tenant_id' => $this->otherTenant->id, 'competitor_id' => $alien->id,
'signal_type' => 'call', 'identifier' => '70000000000',
'created_project_id' => $this->project->id, 'box' => 'field',
]);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
Deal::factory()->for($this->tenant)->create(['project_id' => $this->project->id, 'received_at' => now()]);
$res = $this->getJson('/api/deals')->assertOk()->json('deals.0');
expect($res['competitor_name'])->toBeNull();
});
```
- [ ] **Step 13: Запустить — PASS (where tenant_id + RLS отсекают)**
Run: `cd app && php artisan test --filter='чужого tenant не подтягивается'`
Expected: PASS.
- [ ] **Step 14: Прогнать весь DealIndexTest + Pint**
Run: `cd app && php artisan test --filter=DealIndexTest && composer pint -- app/Http/Controllers/Api/DealController.php`
Expected: все PASS, Pint без изменений/ошибок.
- [ ] **Step 15: Commit**
```bash
git add app/app/Http/Controllers/Api/DealController.php app/tests/Feature/DealIndexTest.php
git commit -m "feat(сделки): бэк отдаёт конкурента и источник автоподбора в списке сделок"
```
---
## Task 2: Frontend — контракт ApiDeal/MockDeal + маппер
**Files:**
- Modify: `app/resources/js/api/deals.ts:153-174` (интерфейс `ApiDeal`)
- Modify: `app/resources/js/composables/mockDeals.ts:10-32` (интерфейс `MockDeal`)
- Modify: `app/resources/js/composables/dealsApiMapper.ts:75-101` (`mapApiDeal`)
- Test: `app/tests/Frontend/dealsApiMapper.spec.ts`
- [ ] **Step 1: Падающий тест — маппер прокидывает новые поля**
В `app/tests/Frontend/dealsApiMapper.spec.ts` добавить (в существующий `describe`, там где мапится `ApiDeal`; если хелпера `makeApiDeal` нет — собрать объект `ApiDeal` инлайн со всеми обязательными полями):
```ts
it('прокидывает competitor/source поля автоподбора', () => {
const api = {
id: 1, tenant_id: 1, project_id: 5, project_name: 'X', phone: '79000000001',
contact_name: null, status: 'new', manager_id: null, manager_name: null,
manager_initials: null, received_at: '2026-07-08T09:30:00+00:00', comment: null,
city: 'Красноярский край', project_signal_type: null,
competitor_id: 3, competitor_name: 'AutoMoney',
source_identifier: '79135396546', source_signal_type: 'call',
source_project_id: 5, source_is_active: true, source_extra_count: 1,
} as any;
const d = mapApiDeal(api);
expect(d.competitorId).toBe(3);
expect(d.competitorName).toBe('AutoMoney');
expect(d.sourceIdentifier).toBe('79135396546');
expect(d.sourceSignalType).toBe('call');
expect(d.sourceProjectId).toBe(5);
expect(d.sourceIsActive).toBe(true);
expect(d.sourceExtraCount).toBe(1);
});
```
- [ ] **Step 2: Запустить — FAIL**
Run: `cd app && npx vitest run tests/Frontend/dealsApiMapper.spec.ts`
Expected: FAIL — `d.competitorId` undefined / TS-поле отсутствует.
- [ ] **Step 3: Реализовать — расширить `ApiDeal`**
В `app/resources/js/api/deals.ts` внутри `interface ApiDeal` (после `project_sms_senders?: string[] | null;` — строка 173) добавить:
```ts
// Автоподбор (2026-07-08): конкурент + источник для колонок «Сделок».
competitor_id?: number | null;
competitor_name?: string | null;
source_identifier?: string | null;
source_signal_type?: string | null;
source_project_id?: number | null;
source_is_active?: boolean | null;
source_extra_count?: number;
```
- [ ] **Step 4: Реализовать — расширить `MockDeal`**
В `app/resources/js/composables/mockDeals.ts` внутри `interface MockDeal` (после `projectSmsSenders?: string[] | null;` — строка 31) добавить:
```ts
// Автоподбор (2026-07-08): конкурент + источник (колонки «Сделок»).
competitorId?: number | null;
competitorName?: string | null;
sourceIdentifier?: string | null;
sourceSignalType?: 'call' | 'site' | 'sms' | null;
sourceProjectId?: number | null;
sourceIsActive?: boolean | null;
sourceExtraCount?: number;
```
- [ ] **Step 5: Реализовать — прокинуть в `mapApiDeal`**
В `app/resources/js/composables/dealsApiMapper.ts` в объекте возврата `mapApiDeal` (после `projectSmsSenders: api.project_sms_senders ?? null,` — строка 99) добавить:
```ts
competitorId: api.competitor_id ?? null,
competitorName: api.competitor_name ?? null,
sourceIdentifier: api.source_identifier ?? null,
sourceSignalType: (api.source_signal_type as MockDeal['sourceSignalType']) ?? null,
sourceProjectId: api.source_project_id ?? null,
sourceIsActive: api.source_is_active ?? null,
sourceExtraCount: api.source_extra_count ?? 0,
```
- [ ] **Step 6: Запустить — PASS**
Run: `cd app && npx vitest run tests/Frontend/dealsApiMapper.spec.ts`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add app/resources/js/api/deals.ts app/resources/js/composables/mockDeals.ts app/resources/js/composables/dealsApiMapper.ts app/tests/Frontend/dealsApiMapper.spec.ts
git commit -m "feat(сделки): контракт ApiDeal/MockDeal — поля конкурента и источника"
```
---
## Task 3: Frontend — две колонки в таблице сделок
**Files:**
- Modify: `app/resources/js/components/deals/DealsTable.vue`
- Test: `app/tests/Frontend/DealsTable.spec.ts`
Меняем: одну колонку `project` (headers) → две `competitor` + `source`. Ссылки — `<router-link>` в зелёный экран автоподбора; `@click.stop` чтобы не открывать drawer строки. Fallback без `competitorId` — обычный текст.
- [ ] **Step 1: Падающий тест — рендер двух колонок со ссылками + fallback**
В `app/tests/Frontend/DealsTable.spec.ts` добавить кейсы (учесть, что монтирование требует router-stub; посмотреть существующий `mount(...)` в этом файле и добавить `global.stubs: { RouterLink: { template: '<a><slot/></a>', props:['to'] } }` или реальный router — по образцу файла). Тело:
```ts
it('рисует ссылку на конкурента и на источник', () => {
const deal = {
id: 1, name: '79000000001', phone: '79000000001', statusSlug: 'new',
project: 'X', manager: { initials: '—', name: '—' }, cost: 0, receivedMinutesAgo: 1,
competitorId: 3, competitorName: 'AutoMoney',
sourceIdentifier: '79135396546', sourceSignalType: 'call', sourceProjectId: 5,
sourceIsActive: true, sourceExtraCount: 0,
} as any;
const wrapper = mountTable([deal]); // хелпер по образцу файла
expect(wrapper.text()).toContain('AutoMoney');
expect(wrapper.text()).toContain('79135396546');
const links = wrapper.findAllComponents({ name: 'RouterLink' });
expect(links.length).toBeGreaterThanOrEqual(2);
});
it('без конкурента — колонка «Конкурент» показывает прочерк без ссылки', () => {
const deal = {
id: 2, name: 'Y', phone: '79000000002', statusSlug: 'new',
project: 'Обычный проект', manager: { initials: '—', name: '—' }, cost: 0, receivedMinutesAgo: 1,
competitorId: null, competitorName: null, sourceIdentifier: null,
sourceSignalType: null, sourceProjectId: null, sourceIsActive: null, sourceExtraCount: 0,
} as any;
const wrapper = mountTable([deal]);
expect(wrapper.text()).toContain('Обычный проект');
expect(wrapper.findAllComponents({ name: 'RouterLink' }).length).toBe(0);
});
```
> Если в файле уже есть локальный хелпер монтирования — переиспользовать его вместо `mountTable`. Router-link стаб обязателен: компонент теперь использует `<router-link>`.
- [ ] **Step 2: Запустить — FAIL**
Run: `cd app && npx vitest run tests/Frontend/DealsTable.spec.ts`
Expected: FAIL — колонок «Конкурент»/ссылок нет.
- [ ] **Step 3: Реализовать — headers (2 колонки вместо одной)**
В `app/resources/js/components/deals/DealsTable.vue` в массиве `:headers` заменить строку:
```ts
{ title: 'Источник', key: 'project', sortable: false },
```
на две:
```ts
{ title: 'Конкурент', key: 'competitor', sortable: false },
{ title: 'Источник', key: 'source', sortable: false },
```
- [ ] **Step 4: Реализовать — шаблоны ячеек**
В том же файле заменить блок `<template #[`item.project`]...>` (строки 76-83) на два блока:
```vue
<template #[`item.competitor`]="{ item }: { item: MockDeal }">
<router-link
v-if="item.competitorId"
class="cell-link"
:to="{ name: 'autopodbor', query: { competitor: String(item.competitorId) } }"
@click.stop
>{{ item.competitorName }}</router-link
>
<span v-else class="text-medium-emphasis">—</span>
</template>
<template #[`item.source`]="{ item }: { item: MockDeal }">
<div class="cell-source">
<router-link
v-if="item.sourceProjectId"
class="cell-link source-project"
:to="{
name: 'autopodbor',
query: { competitor: String(item.competitorId), project: String(item.sourceProjectId) },
}"
@click.stop
>{{ item.sourceIdentifier
}}<span v-if="item.sourceExtraCount"> · +{{ item.sourceExtraCount }}</span></router-link
>
<span v-else class="source-project">{{ stripChannelPrefix(item.project) }}</span>
<span v-if="sourceSignalLabel(item)" class="source-signal">{{ sourceSignalLabel(item) }}</span>
</div>
</template>
```
- [ ] **Step 5: Реализовать — хелпер метки канала источника**
В `<script setup>` того же файла рядом с `signalLabel` (после строки 31) добавить:
```ts
function sourceSignalLabel(deal: MockDeal): string {
const t = deal.sourceSignalType ?? deal.signalType;
return t ? (SIGNAL_LABELS[t] ?? '') : '';
}
```
- [ ] **Step 6: Реализовать — стиль ссылки (переиспользуем токен primary, без нового дизайна)**
В `<style scoped>` того же файла добавить:
```css
.cell-link {
color: rgb(var(--v-theme-primary));
text-decoration: none;
font-weight: 500;
cursor: pointer;
}
.cell-link:hover {
text-decoration: underline;
}
```
- [ ] **Step 7: Запустить — PASS**
Run: `cd app && npx vitest run tests/Frontend/DealsTable.spec.ts`
Expected: PASS.
- [ ] **Step 8: Прогнать смежные specs (DealsView, интеграция листа) — не сломали**
Run: `cd app && npx vitest run tests/Frontend/DealsView.spec.ts tests/Frontend/DealsListIntegration.spec.ts`
Expected: PASS (если падают из-за отсутствия router-link стаба — добавить стаб в этих specs аналогично).
- [ ] **Step 9: Commit**
```bash
git add app/resources/js/components/deals/DealsTable.vue app/tests/Frontend/DealsTable.spec.ts
git commit -m "feat(сделки): колонки «Конкурент» и «Источник» со ссылками в автоподбор"
```
---
## Task 4: Frontend — приём deep-link `?competitor=&project=` в автоподборе
**Files:**
- Modify: `app/resources/js/views/autopodbor/AutopodborView.vue`
- Test: `app/tests/Frontend/AutopodborView.deeplink.spec.ts` (новый)
Автоподбор листает экраны в памяти. Учим `onMounted` читать `route.query`: `competitor` → экран конкурента; `+project` → флаг `ctx.openProjectSettingsId` (модалку откроет Task 5).
- [ ] **Step 1: Падающий тест — query competitor открывает экран конкурента**
Создать `app/tests/Frontend/AutopodborView.deeplink.spec.ts`:
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
vi.mock('../../resources/js/stores/autopodborStore', () => ({
useAutopodborStore: () => ({ loadState: vi.fn().mockResolvedValue(undefined) }),
}));
import AutopodborView from '../../resources/js/views/autopodbor/AutopodborView.vue';
function mountWithQuery(query: Record<string, string>) {
return mount(AutopodborView, {
global: {
mocks: { $route: { query } },
stubs: {
FieldWorkspaceScreen: { template: '<div>field</div>' },
FieldCompetitorScreen: { template: '<div class="fc">competitor screen</div>' },
EditProjectScreen: true, FieldProposalsScreen: true, EntryScreen: true,
AutoFormScreen: true, ManualFormScreen: true, LoadingScreen: true,
ListScreen: true, DetailScreen: true, CreateScreen: true, DoneScreen: true,
},
},
});
}
beforeEach(() => setActivePinia(createPinia()));
it('query competitor → экран конкурента с ctx.competitorId', async () => {
const w = mountWithQuery({ competitor: '3' });
await new Promise((r) => setTimeout(r, 0));
expect((w.vm as any).screen).toBe('fieldcompetitor');
expect((w.vm as any).ctx.competitorId).toBe(3);
});
it('query competitor+project → выставлен ctx.openProjectSettingsId', async () => {
const w = mountWithQuery({ competitor: '3', project: '5' });
await new Promise((r) => setTimeout(r, 0));
expect((w.vm as any).ctx.openProjectSettingsId).toBe(5);
});
it('без query — дефолтный экран field', async () => {
const w = mountWithQuery({});
await new Promise((r) => setTimeout(r, 0));
expect((w.vm as any).screen).toBe('field');
});
```
> Компонент экспонирует `go, screen, ctx` через `defineExpose` — тесту они видны как `w.vm`.
- [ ] **Step 2: Запустить — FAIL**
Run: `cd app && npx vitest run tests/Frontend/AutopodborView.deeplink.spec.ts`
Expected: FAIL — `screen` остаётся `'field'`, `ctx.openProjectSettingsId` undefined.
- [ ] **Step 3: Реализовать — импорт useRoute + поле ctx + чтение query**
В `app/resources/js/views/autopodbor/AutopodborView.vue`:
(a) в импорт vue-router (добавить строку после блока импортов, до `const store`):
```ts
import { useRoute } from 'vue-router';
```
(b) в `const ctx = reactive({ ... })` добавить поле (после `openDupes: false,`):
```ts
openProjectSettingsId: null as number | null,
```
(c) заменить `onMounted`:
```ts
const route = useRoute();
onMounted(async () => {
await store.loadState();
const q = route.query;
const compId = Number(Array.isArray(q.competitor) ? q.competitor[0] : q.competitor);
if (Number.isInteger(compId) && compId > 0) {
ctx.competitorId = compId;
const projId = Number(Array.isArray(q.project) ? q.project[0] : q.project);
if (Number.isInteger(projId) && projId > 0) {
ctx.openProjectSettingsId = projId;
}
go('fieldcompetitor');
}
});
```
- [ ] **Step 4: Запустить — PASS**
Run: `cd app && npx vitest run tests/Frontend/AutopodborView.deeplink.spec.ts`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add app/resources/js/views/autopodbor/AutopodborView.vue app/tests/Frontend/AutopodborView.deeplink.spec.ts
git commit -m "feat(автоподбор): открытие по ссылке ?competitor=&project= из «Сделок»"
```
---
## Task 5: Frontend — кнопка пуск/стоп в модалке + авто-открытие по ссылке
**Files:**
- Modify: `app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue`
- Test: `app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts`
Модалка `psettings` (регион/лимит/дни) получает поле `isActive` и кнопку пуск/стоп поверх `store.toggleProjectActive`, срабатывает НЕ закрывая окно. По `nav.ctx.openProjectSettingsId` — авто-открытие модалки для нужного проекта.
- [ ] **Step 1: Падающий тест — кнопка переключает состояние, модалка не закрывается**
В `app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts` добавить кейс (переиспользовать существующие хелперы `makeNav`, `src`, `proj`, mock `api`/store из шапки файла):
```ts
it('кнопка пуск/стоп в модалке настроек переключает проект, не закрывая окно', async () => {
const nav = makeNav(3);
const store = useAutopodborStore();
const toggle = vi.spyOn(store, 'toggleProjectActive').mockResolvedValue(undefined);
// источник с активным проектом
store.sources = [src({ id: 10, project: proj({ id: 5, is_active: true }) })] as any;
store.competitor = { id: 3, name: 'AutoMoney' } as any;
const wrapper = mount(FieldCompetitorScreen, {
global: { plugins: [vuetify], provide: { autopodborNav: nav } },
});
await flushPromises();
// открыть модалку настроек для проекта 5
(wrapper.vm as any).openSettings((wrapper.vm as any).inWork[0]);
await wrapper.vm.$nextTick();
// нажать пуск/стоп
await (wrapper.vm as any).toggleFromSettings(false);
expect(toggle).toHaveBeenCalledWith(5, false);
expect((wrapper.vm as any).psettings.isActive).toBe(false);
expect((wrapper.vm as any).psettings.open).toBe(true); // НЕ закрылась
});
```
> Точные имена store-state (`sources`/`competitor`) и хелперов — свериться с шапкой файла; если store инициализируется иначе (через mock api + reload), настроить источник через тот же путь, что уже используют соседние кейсы этого spec.
- [ ] **Step 2: Запустить — FAIL**
Run: `cd app && npx vitest run tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts -t 'кнопка пуск/стоп'`
Expected: FAIL — `toggleFromSettings` не существует, `psettings.isActive` undefined.
- [ ] **Step 3: Реализовать — поле isActive в psettings + запись при открытии**
В `app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue`:
(a) в `const psettings = reactive({...})` (строка 600) добавить `isActive: true`:
```ts
const psettings = reactive({ open: false, projectId: null as number | null, label: '', regionCode: 24, limit: 5, mask: 127, isActive: true });
```
(b) в `openSettings(s)` в `Object.assign(psettings, {...})` (строки 603-607) добавить `isActive`:
```ts
Object.assign(psettings, {
open: true, projectId: s.project.id, label: sourceValue(s),
regionCode: s.project.regions?.[0] ?? regions[0]?.code ?? 24,
limit: s.project.daily_limit_target, mask: s.project.delivery_days_mask || 127,
isActive: s.project.is_active,
});
```
- [ ] **Step 4: Реализовать — метод toggleFromSettings**
В том же файле рядом с `saveSettings` (после его закрывающей `}` около строки 632) добавить:
```ts
async function toggleFromSettings(active: boolean): Promise<void> {
if (psettings.projectId === null || busy.value) return;
busy.value = true;
try {
await store.toggleProjectActive(psettings.projectId, active);
psettings.isActive = active;
await reload();
flash(active ? 'Проект возобновлён ▶' : 'Проект приостановлен ⏸ — уже заказанные лиды дойдут');
} catch {
flash('Не удалось изменить проект — ограничения по слепку или балансу.');
} finally {
busy.value = false;
}
}
```
(добавить `toggleFromSettings` в `defineExpose({... })` — в список после `sourceValue`).
- [ ] **Step 5: Реализовать — кнопка в шаблоне модалки**
В шаблоне модалки `psettings`, в блоке `<div class="ld-modal__foot">` (строки 1122-1124), заменить на:
```vue
<div class="ld-modal__foot">
<button
v-if="psettings.isActive"
class="ld-btn warn sm"
:disabled="busy"
style="margin-right: auto"
@click="toggleFromSettings(false)"
>
⏸ Остановить
</button>
<button
v-else
class="ld-btn primary sm"
:disabled="busy"
style="margin-right: auto"
@click="toggleFromSettings(true)"
>
▶ Запустить
</button>
<button class="ld-btn gray sm" @click="psettings.open = false">Отмена</button>
<button class="ld-btn primary sm" :disabled="busy" @click="saveSettings">Сохранить</button>
</div>
```
> `margin-right: auto` разводит пуск/стоп влево, «Отмена/Сохранить» — вправо, в рамках существующего `.ld-modal__foot` (никакого нового дизайна, только раскладка кнопок).
- [ ] **Step 6: Запустить — PASS**
Run: `cd app && npx vitest run tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts -t 'кнопка пуск/стоп'`
Expected: PASS.
- [ ] **Step 7: Падающий тест — авто-открытие модалки по ctx.openProjectSettingsId**
```ts
it('открывает модалку настроек при заходе по ссылке (ctx.openProjectSettingsId)', async () => {
const nav = makeNav(3);
nav.ctx.openProjectSettingsId = 5;
const store = useAutopodborStore();
store.sources = [src({ id: 10, project: proj({ id: 5, is_active: true }) })] as any;
store.competitor = { id: 3, name: 'AutoMoney' } as any;
const wrapper = mount(FieldCompetitorScreen, {
global: { plugins: [vuetify], provide: { autopodborNav: nav } },
});
await flushPromises();
await wrapper.vm.$nextTick();
expect((wrapper.vm as any).psettings.open).toBe(true);
expect((wrapper.vm as any).psettings.projectId).toBe(5);
expect(nav.ctx.openProjectSettingsId).toBeNull(); // сброшен
});
```
> `makeNav` в шапке файла нужно расширить полем `openProjectSettingsId: null` в `ctx` — добавить его туда.
- [ ] **Step 8: Запустить — FAIL**
Run: `cd app && npx vitest run tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts -t 'открывает модалку настроек при заходе'`
Expected: FAIL — модалка не открывается.
- [ ] **Step 9: Реализовать — авто-открытие в onMounted**
В `onMounted` (строки 754-763) после `await reload();` добавить:
```ts
if (nav.ctx.openProjectSettingsId) {
const target = inWork.value.find((s) => s.project?.id === nav.ctx.openProjectSettingsId);
if (target) openSettings(target);
nav.ctx.openProjectSettingsId = null;
}
```
> Свериться: computed-список источников в работе называется `inWork``defineExpose` он есть). Если имя иное — использовать актуальное.
- [ ] **Step 10: Запустить — PASS**
Run: `cd app && npx vitest run tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts`
Expected: PASS (весь файл).
- [ ] **Step 11: Commit**
```bash
git add app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue app/tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts
git commit -m "feat(автоподбор): кнопка пуск/стоп в модалке настроек + авто-открытие по ссылке"
```
---
## Task 6: Финальная проверка
- [ ] **Step 1: Frontend — весь набор изменённых specs**
Run: `cd app && npx vitest run tests/Frontend/dealsApiMapper.spec.ts tests/Frontend/DealsTable.spec.ts tests/Frontend/DealsView.spec.ts tests/Frontend/AutopodborView.deeplink.spec.ts tests/Frontend/AutopodborFieldCompetitorScreen.spec.ts`
Expected: все PASS.
- [ ] **Step 2: Type-check + backend-набор**
Run: `cd app && npm run type-check && php artisan test --filter=DealIndexTest`
Expected: type-check без ошибок в тронутых файлах; Pest PASS.
- [ ] **Step 3: Быстрый регресс**
Run: `/regression quick`
Expected: GREEN (lint/format/type-check).
- [ ] **Step 4: Self-review — дизайн-инвариант**
Проверить глазами diff `DealsTable.vue` и модалки: не добавлено новых цветов/шрифтов/компонентов сверх `.cell-link` (primary-токен) и раскладки кнопок; колонок стало 2, кнопок в модалке +1. Никаких иных визуальных изменений.
- [ ] **Step 5: Итоговый статус владельцу (без деплоя)**
Сообщить: что сделано, что тесты зелёные, и что на боевой выкат — только по отдельному «выкатывай».
---
## Self-review плана (проверено при написании)
- **Покрытие спеки:** колонки Конкурент/Источник (Task 3), убрано «Описание» (в Task 3 колонки только 2 — описание не выводится ✓), ссылка конкурент→экран (Task 3+4), ссылка источник→модалка (Task 3+4+5), кнопка пуск/стоп в модалке (Task 5), fallback без автоподбора (Task 1 Step 10 + Task 3 Step 1), бэк-резолв без N+1 (Task 1 Step 5), tenant-изоляция (Task 1 Step 12). Все пункты спеки имеют задачу.
- **Плейсхолдеры:** конкретные пути/строки/код во всех шагах; места, требующие сверки с реальными именами хелперов в spec-файлах, помечены явными NB (не «TODO», а конкретная проверка имени).
- **Согласованность типов:** поля бэка (`competitor_id`,`source_identifier`,`source_signal_type`,`source_project_id`,`source_is_active`,`source_extra_count`,`competitor_name`) ↔ `ApiDeal` ↔ маппер (`competitorId`,`sourceIdentifier`,...) ↔ шаблон `DealsTable` — имена совпадают сквозь Task 1→2→3. `toggleFromSettings`/`psettings.isActive`/`openProjectSettingsId` согласованы Task 4↔5.
@@ -0,0 +1,169 @@
# Дизайн: «Сделки» → колонки «Конкурент» и «Источник» с переходами в автоподбор
**Дата:** 2026-07-08
**Ветка:** `worktree-avtopodbor` (боевой код lk.liderra.ru; последний прод-коммит `2aace1ff`)
**Автор запроса:** Дмитрий (владелец)
**Статус:** утверждён замысел, ждёт ревью спеки → план реализации
---
## 1. Проблема
В разделе «Сделки» колонка **«Источник»** показывает одной ячейкой автоназвание
проекта-поставщика (например `AutoMoney, микрокредитная компания` + канал
`Звонки`/`Сайт`). Для автоподборных лидов это неинформативно и не кликабельно:
клиент не видит, какой это конкурент и какой конкретно источник (номер/сайт),
и не может из сделки провалиться в «Конкурентное поле» или в настройки проекта.
## 2. Цель
Разбить «Источник» на две кликабельные колонки и связать сделку с автоподбором:
- **Конкурент** — имя фирмы-конкурента, ссылка на экран этого конкурента (скрин 2).
- **Источник** — сам источник лида (номер/сайт + канал), ссылка на «Настройки
проекта» (скрин 3), куда дополнительно добавляется кнопка старт/стоп.
«Описание» конкурента в таблице **не показываем** (решение владельца — чтобы не
загромождать).
## 3. Что видит пользователь
### 3.1. Таблица сделок
Колонки становятся: `☑ · Телефон · Конкурент · Источник · Город · Статус ·
Комментарий · Поставлен` (было: `… · Источник · …` одной колонкой).
| Колонка | Содержимое | Клик |
|---|---|---|
| **Конкурент** | `AutoMoney` (имя конкурента, без описания). Стиль — ссылка. | → `/autopodbor?competitor=<id>` — экран конкурента (скрин 2) |
| **Источник** | `📞 79029826282 · Звонки` или `🌐 automoney-krsk.ru · Сайт` — реальный источник, НЕ автоназвание. Стиль — ссылка. | → `/autopodbor?competitor=<id>&project=<projectId>` — экран конкурента + сразу открыта модалка «Настройки проекта» этого источника (скрин 3) |
**Fallback (не автоподборная сделка):** если у сделки нет привязанного источника
автоподбора (обычный поставщик), колонка «Конкурент» = `—` (не ссылка), «Источник»
= прежнее название проекта (не ссылка). Таблица не ломается.
### 3.2. Модалка «Настройки проекта» (скрин 3)
Добавляется кнопка старт/стоп рядом с «Отмена / Сохранить»:
- проект запущен (`is_active = true`) → **«⏸ Остановить»**;
- проект на паузе (`is_active = false`) → **«▶ Запустить»**.
Поведение (решение владельца): кнопка срабатывает **на месте, не закрывая модалку**
нажал, статус переключился, клиент видит результат и закрывает сам. Реюз существующей
логики `store.toggleProjectActive(projectId, active)`.
## 4. Модель данных и связь
Связь сделки с конкурентом/источником уже существует в схеме:
```
Deal.project_id
└─→ AutopodborSource.created_project_id (источник, создавший проект)
├─ signal_type, identifier, phone_kind (номер/сайт + канал)
├─ project.is_active (запущен/пауза)
└─→ AutopodborCompetitor (competitor_id)
└─ name (имя конкурента)
```
- Один проект может быть создан из нескольких источников одного конкурента
(merge). Конкурент при этом **единственный** — берём его однозначно.
- Источник для показа: если у проекта один источник — его `identifier` + канал;
если несколько — представительный (первый по `id`) + пометка `· +N`. Ссылка
«Источник» ведёт в настройки **проекта** (per-project), поэтому корректна при
любом числе источников.
## 5. Backend
**`DealController::index`** (и `show`, для единообразия) — по каждой сделке
дополнительно отдаёт:
```
competitor_id: int | null
competitor_name: string | null
source_identifier: string | null // номер/сайт представительного источника
source_signal_type: 'call' | 'site' | 'sms' | null
source_project_id: int | null // = deal.project_id, если это автоподбор-проект
source_is_active: bool | null
source_extra_count: int // сколько ещё источников у проекта сверх показанного (0 если один)
```
**Резолвинг без N+1:** после выборки страницы сделок собрать `project_id`
всех строк → один запрос `AutopodborSource::whereIn('created_project_id', $ids)
->with('competitor')` → сгруппировать по `created_project_id` → на каждую сделку
подставить конкурента (первого источника) и представительный источник.
RLS: `autopodbor_sources` / `autopodbor_competitors` — tenant-scoped; выборка идёт
под тем же tenant-контекстом, что и сделки. Проверка — через `rls-reviewer`, если
запрос затрагивает новые таблицы в контроллере.
## 6. Frontend
### 6.1. Контракт и маппер
- `api/deals.ts` — расширить `ApiDeal` новыми полями (п.5).
- `composables/mockDeals.ts` — добавить в `MockDeal`: `competitorId`,
`competitorName`, `sourceIdentifier`, `sourceSignalType`, `sourceProjectId`,
`sourceIsActive`, `sourceExtraCount` (все опциональны — Канбан не трогаем).
- `composables/dealsApiMapper.ts` — прокинуть новые поля.
### 6.2. Таблица
- `components/deals/DealsTable.vue` — заменить одну колонку `project` на две:
`competitor` и `source`. Рендер ссылок через `<router-link>`:
- Конкурент: `{ name: 'autopodbor', query: { competitor: competitorId } }`.
- Источник: `{ name: 'autopodbor', query: { competitor: competitorId, project: sourceProjectId } }`.
- Клик по ссылке **не должен** триггерить `row-click` (открытие drawer) —
`@click.stop` на ссылках.
- Fallback без `competitorId` — обычный текст, без ссылок.
- Источник: иконка канала (📞/🌐) + `sourceIdentifier` + метка канала
(`Звонки`/`Сайт`) + `· +N` при `sourceExtraCount > 0`.
### 6.3. Приём ссылок в автоподборе
- `views/autopodbor/AutopodborView.vue` — в `onMounted` после `store.loadState()`
прочитать `route.query`:
- `competitor` задан → `ctx.competitorId = Number(...)`, `go('fieldcompetitor')`.
- дополнительно `project` задан → выставить `ctx.openProjectSettingsId = Number(...)`
(новое поле в `ctx`), чтобы экран конкурента открыл модалку.
- невалидные/чужие id — игнор (остаёмся на дефолтном экране `field`; экран
конкурента сам покажет пусто/ошибку, если конкурент не найден).
### 6.4. Экран конкурента + модалка
- `views/autopodbor/screens/FieldCompetitorScreen.vue`:
- в `onMounted`/после загрузки: если `nav.ctx.openProjectSettingsId` задан —
найти источник с этим `project.id` и вызвать `openSettings(s)`, затем сбросить
`ctx.openProjectSettingsId`.
- `psettings` reactive — добавить поле `isActive: boolean`; в `openSettings(s)`
писать `isActive: s.project.is_active`.
- в шаблоне модалки — кнопка старт/стоп: при `psettings.isActive` показывает
«⏸ Остановить» → `toggleFromSettings(false)`, иначе «▶ Запустить» →
`toggleFromSettings(true)`.
- `toggleFromSettings(active)`: `await store.toggleProjectActive(psettings.projectId, active)`
`psettings.isActive = active``flash(...)``await reload()`.
Модалку НЕ закрываем.
## 7. Тесты
- **Backend (Pest):** `DealIndexTest` — добавить кейсы: (a) сделка с автоподбор-
источником → в ответе есть `competitor_*` и `source_*`; (b) merge (несколько
источников на проект) → один конкурент, `source_extra_count > 0`; (c) обычная
сделка без автоподбора → все новые поля `null`/`0`; (d) tenant-изоляция (чужой
конкурент не подтягивается). Запуск — `--parallel`, учесть quirks 72/73/77.
- **Frontend (Vitest):** `DealsTable` — рендер двух колонок, ссылки с правильными
`query`, `@click.stop` не открывает drawer, fallback без конкурента.
- **Frontend:** маппер `dealsApiMapper` — прокидывание новых полей.
- **Frontend:** `AutopodborView` — query `competitor`/`project` → нужный экран +
флаг модалки; невалидный id → дефолт.
- **Регресс:** `/regression quick` перед завершением.
## 8. Границы (YAGNI)
- Не меняем экспорт в Excel/CSV (колонки экспорта — вне запроса).
- Не трогаем Канбан и drawer-детали сделки.
- Не добавляем сортировку/фильтр по конкуренту (не просили).
- Не меняем данные в БД, автоназвания проектов остаются как есть (показ — только
на фронте/в ответе API).
## 9. Деплой
Ветка `worktree-avtopodbor` = боевой код. Сборка фронта + overlay backend +
рестарт воркеров/веба — **только по явному «выкатывай» владельца** (правило
CLAUDE.md §ГЛАВНОЕ). Спека и код — автономно; коммит — с эскейпом.