# MainActionsComponent — перевод каскада гестии на Alpine

**Дата:** 2026-06-22
**Компонент:** `app/Livewire/Client/MainActionsComponent.php`
**Статус:** утверждён к планированию

## Проблема

Панель «Agregar gestión» (`MainActionsComponent` + `resources/views/livewire/main-actions.blade.php`)
на каждое мелкое UI-действие делает сетевой round-trip к Livewire-бэкенду и перерисовывает
весь компонент. Болевые точки:

- `wire:click="setAction(1, <phoneId>)"` — вход в форму гестии (round-trip + загрузка `$contacts` из БД).
- Каскад зависимых селектов через `wire:model.live`: `contactId → nonPaymentReasonId/strategyId → resultId`,
  каждый шаг — отдельный запрос (`updatedContactId`, `updateResults`, `updatedResultId`).
- `wire:model.live.debounce.250ms="sendSmsText"` — запрос на каждый ввод символа ради счётчика длины SMS.
- Переключение вкладок `setTab()` — round-trip ради смены строки состояния.

При этом справочные данные, на которых строится весь каскад, крошечные и статичные:
`5_relacion_codigos` (граф action→contact→result) — **226 строк**; словари —
11 acciones, 8 contactos, 29 resultados, 26 motivos, 9 estrategias. Всё это легко
предзагрузить в браузер один раз и вести каскад полностью на клиенте.

## Охват

**В охвате:** каскад гестии (вход в форму + зависимые селекты + видимость полей value/date
+ превью-текст), переключение вкладок и счётчик символов SMS как лёгкие бонусы.

**Вне охвата:** перенос на клиент форм отправки SMS/Email и блока расписания звонков —
остаются на Livewire в этой итерации.

## Подход

**Подход 1 — выделенный Alpine-компонент + терминальный `$wire.save(payload)`.**
Вся UI-логика каскада живёт в одном изолированном Alpine-модуле, работающем на
предзагруженном JSON-графе. Серверная бизнес-логика (валидация, `CommunicationDTO`,
`CommunicationService`) не переписывается — меняется только источник данных для неё
(payload вместо публичных свойств). Ноль round-trip'ов от клика «добавить гестию»
до клика «Guardar».

Отвергнутые альтернативы:
- *Гибрид с deferred `wire:model`* — дуальная привязка `wire:model` ⊕ `x-model` на одних
  и тех же `<select>` хрупка; вид список↔форма всё равно требует `x-show`.
- *Расширение на SMS/Email-формы* — вне выбранного охвата.

## Архитектура и файловая раскладка

**Новое:**
- `resources/views/livewire/client/gestion-form.blade.php` — partial с формой «Agregar gestión»
  (текущие строки 313–415 `main-actions.blade.php`), переписанной на `x-model`/`x-show`/`x-for`.
- `resources/js/livewire/gestion-form.js` — `Alpine.data('gestionForm', ...)`: реактивное
  состояние + computed-геттеры каскада. Регистрируется там, где Livewire инициализирует Alpine.
  Собирается через Vite (`npm run build`).

**Изменяется:**
- `MainActionsComponent.php`:
  - добавляется `gestionData(): array` — собирает JSON-контракт (см. ниже) из уже
    загруженных в `mount()` справочников + один `CodeRelation::query()->get([...])`.
  - `save()` рефакторится под приём `array $payload`.
  - удаляются серверные каскад-хуки: `updatedContactId`, `updatedResultId`, `updateResults`,
    каскадная часть `updated()`. Серверный `setAction`/`setEmailAction` упрощаются/удаляются
    в пользу клиентских аналогов (с учётом email-ветки — см. «Открытые детали для плана»).
- `main-actions.blade.php`:
  - корневой `<div>` оборачивается `x-data="gestionForm(@js($this->gestionData()))"`.
  - серверные `@if($actionId)` / `@if($selectedTab === ...)` переключатели вида → `x-show`;
    разметка рендерится один раз, видимость на клиенте.
  - inline-`<script>` (таймер, flatpickr, Swal) сохраняется; события чистого UI
    (`hideTabs/showTabs/buildDatepicker/showScheduleCalendar`) переводятся на прямые Alpine-вызовы.

**Граница модуля:** `gestion-form.js` не знает о Livewire ничего, кроме единственной точки
выхода — `$wire.save(payload)` (и точечных `$wire.setChannels(...)` для побочного эффекта).
Вход — JSON-граф, выход — выбранное состояние; тестируется и читается в отрыве.

## Контракт данных (`gestionData()` → Alpine)

Один раз при рендере компонент отдаёт:

```
{
  graph: [ { a: idaccion, c: idcontacto, r: idresultado }, ... ],   // 5_relacion_codigos, ~226 строк
  actions:  { [id]: { descripcion, guion } },                       // 1_acciones
  contacts: { [id]: { descripcion, guion, idgrupo } },              // 2_contacto
  results:  { [id]: { descripcion, guion, valor: bool, fecha: bool } }, // 4_resultado
  reasons:  [ { id, descripcion } ],                                // motivos (novacion=0, уже фильтр в mount)
  strategies:[ { id, descripcion } ],
  clientPopup: int,            // client.popup — для логики авторизации каналов
  hideChannels: bool,          // config('polaris.client.hide_channels')
  defaultChannels: array       // config('polaris.client.default_channels')
}
```

Выводимое на клиенте:
- `contactsFor(actionId)` — уникальные `idcontacto` из `graph` по `a==actionId` → `descripcion` из `contacts`.
- `resultsFor(actionId, contactId)` — `idresultado` из `graph` по паре → `descripcion` из `results`
  (полностью заменяет серверный `updateResults()`).
- `contactGroupId` = `contacts[contactId].idgrupo` → видимость блока reason/strategy (группа 1).
- `showValueInput`/`showDateInput` = `results[resultId].valor` / `.fecha`.
- `preformText` = `actions[actionId].guion` + телефон/email + `contacts[contactId].guion`
  + (группа 1) `reason.descripcion` + `results[resultId].guion` — точная копия `getPreformText()`.

`actions`, `reasons`, `strategies` переиспользуют уже загруженные в `mount()` коллекции —
дополнительных запросов в БД метод не делает (кроме одного select графа + словарей результатов/контактов
через `listCached()`).

## Логика каскада на клиенте

Состояние Alpine: `view ('list'|'form')`, `selectedTab`, `actionId`, `phoneId`, `emailId`,
`contactId`, `nonPaymentReasonId`, `strategyId`, `resultId`, `value`, `date`, `comment`, `time`,
`externalCommunicationId`.

Поток (без сервера):
1. `setAction(actionId, phoneId)` — Alpine-метод: пишет `actionId/phoneId`, `view='form'`,
   прячет вкладки, сбрасывает вниз-зависимые поля, стартует таймер.
2. Селект «Contacto» — `x-for` по `contactsFor(actionId)`.
3. Выбор `contactId` → геттер `contactGroupId` мгновенно тоглит reason+strategy (`x-show`);
   при группе ≠ 1 результаты доступны сразу.
4. Селект «Resultado» — `x-for` по `resultsFor(actionId, contactId)`.
5. Выбор `resultId` → `showValueInput`/`showDateInput` тоглят поля value/date; `preformText`
   пересчитывается реактивно.
6. Счётчик SMS и переключение вкладок — Alpine-геттеры/сеттеры, ноль запросов.

Крайние случаи:
- **Авторизация каналов (Normatividad).** При выборе результата группы 1, если `clientPopup==0`:
  при `hideChannels` → `$wire.setChannels(defaultChannels)`; иначе → существующая JS-модалка
  Normatividad, которая шлёт `Livewire.dispatch('setChannels')`. Поведение 1:1 с текущим `updatedResultId`.
- **flatpickr.** `showDateInput=true` (и блок расписания) → инициализация через `$nextTick`
  вместо серверных событий `buildDatepicker`/`showScheduleCalendar`.
- **Cancel.** Кнопка «Cancelar» → Alpine `resetForm()` (`view='list'`, показать вкладки,
  обнулить поля). Серверный `resetState()` остаётся только в пост-save очистке.

## Терминальный `save` через payload

Alpine `submit()` собирает payload и зовёт `$wire.save(payload)`:

```
{ actionId, phoneId, emailId, contactId, contactGroupId,
  nonPaymentReasonId, strategyId, resultId,
  value, date, comment, time, externalCommunicationId,
  showValueInput, showDateInput }   // флаги нужны для условных правил валидации
```

`time` берётся из таймера на момент клика (`setTimer()` встраивается в `submit()`, заменяя
текущий хак с `dispatchEvent('input')` + скрытым сабмитом).

`MainActionsComponent::save(array $payload)`:
1. Валидация переезжает с публичных свойств на payload через
   `Validator::make($payload, $this->rules(), $this->messages())`; правила/сообщения
   переписываются на ключи payload (`required_if:contactGroupId,1`, условия по
   `showValueInput`/`showDateInput`).
2. Ветвление `actionId == 5 || 12` → `saveCommunicationForEmail()` / иначе `saveCommunication()` —
   остаётся; источником данных вместо `$this->*` становятся поля payload. `CommunicationDTO`,
   `CommunicationMetadataDTO`, `CommunicationService::saveCommunication()` не трогаются.
3. Пост-save: трекинг `filled_clients`, `dispatch('saveSuccess')`, обновление `phoneRelations`,
   `refreshCommunications`/`refreshHeader` — без изменений.

Остаются серверными как и были (терминальные write / внешние API): `sendSMS`, `sendEmail`,
`saveCallSchedule`, `initWebitelCall`, `addPhone`, `addEmail`, `togglePhoneStatus`,
`toggleEmailStatus`, `setChannels`. Вызовы из Alpine — точечно через `$wire.<method>(...)`.

## Ошибки, валидация и побочные эффекты

Двухуровневая валидация (сервер — источник истины):
- *Клиент:* кнопка «Guardar» дизейблится, пока не заполнены обязательные по текущему состоянию
  поля (result всегда; reason+strategy для группы 1; value при `showValueInput`; date при
  `showDateInput`). Правила `date >= сегодня` и `value > 0` дублируются для мгновенной подсказки.
- *Сервер:* `Validator` на payload — полноценная защита. При провале —
  `$this->dispatch('showValidationErrors', errors: ...)`, Alpine раскладывает их под полями.
  Один round-trip только на самом сохранении.

Побочные эффекты сохраняются:
- Normatividad / `setChannels` — как описано выше.
- `saveSuccess`/`showError`/`showInfo`/`openLink`/`openEmailPreviewModal` — Swal/Bootstrap
  на `Livewire.on(...)` (реакции на серверные терминальные действия).
- Таймер — JS-таймер; старт/сброс перевязаны на Alpine (`setAction` стартует,
  `resetForm`/`saveSuccess` обнуляют).

Регрессионные риски под контролем:
- Конфликт `wire:model` ⊕ `x-model` исключён: в форме гестии все поля переходят на `x-model`
  целиком, `wire:model.live` оттуда убираются.
- Форма всегда отрендерена и переключается `x-show` → Livewire-морфинг её не пересобирает.

## Тестирование и верификация

Автотесты (PHPUnit feature, `php artisan make:test --phpunit`) — `MainActionsComponentTest`:
- `save()` с валидным payload (группа 1, результат с `valor`+`fecha`) → вызван
  `CommunicationService::saveCommunication` с корректным `CommunicationDTO` (спай сервиса),
  диспатчатся `saveSuccess`/`refreshCommunications`.
- email-ветка (`actionId=5` и `=12`) → `saveCommunicationForEmail`, email в DTO.
- валидация: пустой `resultId`; группа 1 без reason/strategy; `showValueInput=true` и `value<=0`;
  `date` раньше сегодня — ошибки.
- `gestionData()` возвращает непустой граф и словари ожидаемой формы (у результата есть `valor`/`fecha`).
- Где нет фабрик (справочники/`CodeRelation`) — сидим минимальные строки на нужных connection'ах.

Ручная проверка (Network открыта):
1. «Добавить гестию» → форма без запроса.
2. contact → result → value/date — без запросов; единственный запрос на «Guardar».
3. Группа 1 → reason+strategy; Normatividad/`setChannels` как раньше.
4. Счётчик SMS и вкладки — без запросов.
5. Email-ветка (5/12) сохраняется; SMS/расписание/звонок не сломаны.
6. Серверные ошибки валидации видны под полями.

Инструменты перед финализацией: `vendor/bin/pint --dirty`,
`php artisan test --compact --filter=MainActionsComponentTest`, `npm run build`.

## Открытые детали для этапа планирования

- Email-ветка использует отдельный `setEmailAction` (грузит `emailPlantillas`, `results`).
  Поскольку отправка email вне охвата, но **сохранение гестии по email (action 5/12) в охвате**,
  в плане уточнить: клиентский `setAction` для email-гестии должен выставлять `emailId` и
  пройти тот же каскад contact→result; загрузка `emailPlantillas` нужна только форме отправки
  (вне охвата) и остаётся серверной.
- Точное место регистрации `Alpine.data` в текущей сборке (где Livewire поднимает Alpine).