Files
portal/docs/superpowers/specs/2026-06-22-project-source-edit-lock-ux-design.md
T

9.3 KiB
Raw Blame History

Спека: UX блокировки смены источника проекта + баннер нового проекта

Дата: 2026-06-22 Статус: дизайн согласован владельцем. РЕАЛИЗАЦИЯ ОТЛОЖЕНА — делает другая сессия по команде владельца. НЕ реализовывать без явного «делай». Тип: UX-правка (фронт + одно read-only поле бэкенда). Revenue-ядро НЕ трогаем.


1. Проблема (откуда выросло)

Клиент (омега, проект 135 «Caranga», sms) внёс ключевое слово в активный проект — оно молча не сохранилось, у поставщика ничего не создалось. Диагноз:

  • Правка источника (sms_keyword/sms_senders/signal_identifier) у активного supplier-связанного проекта блокируется SupplierSnapshotGuard::assertCanMutateSource → возвращает 422 {errors:{project:[…]}}.
  • Форма (ProjectDetailsDrawer.vue) ловит 422 и показывает баннер errors.project (строки 148-149), но баннер вверху, а поле источника и кнопка «Сохранить» — ниже, без тоста и без прокрутки к ошибке → клиент не видит → ощущение «молча не сохранилось».

2. Главное решение: ГАРД ОСТАВЛЯЕМ (не смягчать, не сносить)

Рассматривали «смягчение» (разрешить смену источника на лету + баннер-предупреждение). Отклонено — это фундаментальная переделка revenue-ядра. Доказательство из кода:

  • Раздача ищет проект для входящего лида только через pivotLeadRouter::matchEligibleProjects (app/app/Services/LeadRouter.php:114): EXISTS (project_supplier_links psl WHERE psl.project_id=snap.project_id AND psl.supplier_project_id=?).
  • Смена источника рвёт этот pivot и удаляет старый supplier_project — ProjectService::detachOldSourceSupplierProjects (app/app/Services/Project/ProjectService.php:159-176) → DeleteSupplierProjectJob::dispatch.
  • Поставщик по СТАРОМУ источнику уже заказал и шлёт лиды (~сутки + хвост). После разрыва связи эти лиды не находят проект → 0 сделок → потеря (ровно паттерн «726 zero-deal», что видели на проде).

Вывод: гард — несущая стена. Он специально форсирует «пауза → grace → смена», чтобы к моменту смены источника в полёте не было старых лидов. Снять его = строить переходную раздачу на два источника одновременно (узлы: detachOldSourceSupplierProjects, DeleteSupplierProjectJob+чистка, LeadRouter::matchEligibleProjects, RouteSupplierLeadJob+project_routing_snapshots, биллинг). Не делаем.

3. Что делаем (вариант 3 — только UX, безопасно)

3.1 Бэкенд — ProjectResource отдаёт 2 read-only поля

Источник расчёта — SupplierSnapshotGuard (без изменения его логики):

  • source_locked: bool = SupplierSnapshotGuard::isProtected($project).
  • source_unlock_at: string|null (ISO8601, МСК):
    • проект на паузе в gracecomputeGraceUntil($project->paused_at) (твёрдая дата);
    • проект активен (паузы ещё нет) → прогноз computeGraceUntil(now()) — «если поставить паузу сейчас»;
    • не заблокирован → null.
  • Фронту отдаём флаг source_unlock_projected: bool (true для активного — чтобы показать «если поставите паузу сейчас»).

3.2 Фронт — ProjectDetailsDrawer.vue

Когда source_locked = true:

  • поля источника (signal_identifier / sms_senders / sms_keyword) — disabled, серые, с замком 🔒;
  • под ними — подсказка (тексты §4);
  • остальные поля (название, лимит, регионы, дни) — редактируются как обычно.

Красный баннер errors.project НЕ убираем — он остаётся для других ошибок (например, «нельзя удалить — есть сделки»). Меняем поведение только для источника: было «дать нажать и упасть» → стало «закрыть поле и подсказать заранее».

4. Тексты (согласованы, без жаргона; «Лидерра», не «поставщик»)

Дата подставляется системой; формат «D MMMM», для разблокировки — «после 21:00».

Проект РАБОТАЕТ (активен) — поле серое, замок, дата как прогноз:

🔒 Чтобы изменить источник, поставьте проект на паузу. Лидерра уже собирает по нему лиды. Поставите паузу сейчас — изменить сможете {дата} после 21:00.

Проект НА ПАУЗЕ (идёт grace) — поле серое, замок, дата твёрдая:

🔒 Изменить источник можно будет {дата} после 21:00. Лидерра ещё получает лиды по старому источнику.

Пауза, срок прошёл — поле обычное, белое, без подсказки.

Поменял источник и сохранил — тост:

Готово. Лиды по новому источнику пойдут со следующего дня.

Баннер при создании НОВОГО проекта (вверху формы / после сохранения):

📣 Проект создан — Лидерра уже ставит его в сбор. Первые лиды по нему пойдут с {дата}.

(тёплый альт, на выбор владельца: «🚀 Готово! Проект создан. Лидерра начнёт собирать по нему лиды — первые ждите с {дата}.»)

5. Логика дат (та же «до/после порога»)

  • Разблокировка источника (computeGraceUntil): ближайшие 21:00 МСК после паузы (если пауза ≥21:00 → следующий день) + 24 ч.
    • пауза до 21:00 → разблокировка завтра после 21:00;
    • пауза после 21:00 → разблокировка послезавтра после 21:00.
  • Первые лиды нового проекта / вступление правок (порог 18:00 МСК — внутренний запас до слепка поставщика 21:00; уже используется в SupplierSnapshotGuard::appliesFrom):
    • создал/правил до 18:00 → лиды с завтра;
    • после 18:00 → лиды с послезавтра.

6. Вне scope (НЕ делать)

  • НЕ убирать/смягчать SupplierSnapshotGuard.
  • НЕ трогать раздачу (LeadRouter/RouteSupplierLeadJob), detachOldSourceSupplierProjects, DeleteSupplierProjectJob, project_routing_snapshots, биллинг.

7. Тесты (при реализации)

  • Бэкенд: ProjectResource отдаёт source_locked/source_unlock_at/source_unlock_projected корректно в 3 состояниях (активен / пауза-в-grace / пауза-после-grace / нет supplier-связей).
  • Фронт: при source_locked поля источника disabled + видна подсказка; другие поля редактируемы; красный баннер errors.project остаётся живым для прочих ошибок; новый проект показывает баннер-объявление.

8. Файлы-якоря

  • app/app/Services/Project/SupplierSnapshotGuard.php (источник isProtected/computeGraceUntil — НЕ менять логику).
  • app/app/Http/Resources/ProjectResource.php (+ 3 поля).
  • app/resources/js/components/projects/ProjectDetailsDrawer.vue (disable поля + подсказка).
  • app/resources/js/views/projects/NewProjectDialog.vue (баннер нового проекта).
  • Контекст «почему гард»: app/app/Services/LeadRouter.php:114, app/app/Services/Project/ProjectService.php:159-176, app/app/Jobs/RouteSupplierLeadJob.php.