# MainActionsComponent: миграция с Livewire на HTML + vanilla JS

Дата: 2026-06-30
Статус: дизайн согласован, ожидает финального ревью спеки

## Контекст и причина

Страница клиента (`/admin/client/{id}`, `ClientController@show`) собирается из ~8 Livewire-компонентов.
Вкладка «Gestión» — это `App\Livewire\Client\MainActionsComponent` + его partials. Сейчас это **гибрид**:
Alpine (`gestionForm`) держит реактивность формы на клиенте, а Livewire используется как транспорт к
серверу (`$wire.*`) и для рассылки событий соседним компонентам.

### Баг, который чиним

Нативный `<select>` «Contacto» периодически рендерится как пустой серый блок. Это **не данные** — в
99.999% случаев страница открывается нормально. Причина — гонка: нативный popup `<select>`-а (слой ОС)
открыт в момент, когда Livewire морфит DOM поддерева (morphdom) после серверного round-trip. Усугубляют:

- двойной диспатч на одном клике (`@click="setAction(...); $wire.setAction(...)"` в `phones.blade.php:74`,
  `emails.blade.php:27`);
- loading-оверлей `.loading-waiter` с `backdrop-filter: blur(3px)` и `z-index: 999999` (`app.css:4741`),
  показываемый на время `wire:loading`;
- `<select wire:ignore>` с опциями от Alpine `x-for` — рассинхрон между тем, что считает «своим» Livewire и Alpine.

Открытый popup теряет связь с актуальным `<select>` и зависает серым ghost. Убрав Livewire-морфинг из зоны
формы, мы устраняем этот класс багов физически.

## Границы (scope)

- Переписываем **только** `MainActionsComponent` и его вьюхи:
  `livewire/main-actions.blade.php`, `livewire/actions-wrapper.blade.php`,
  `livewire/client/gestion-form.blade.php`, `livewire/client/phones.blade.php`,
  `livewire/client/emails.blade.php`, `livewire/normatividad.blade.php`,
  `livewire/client/modal/{add-phone,add-email}-form.blade.php`.
- Остальные компоненты страницы (`SideMenu`, `TopMenu`, `HeaderComponent`, `FilesComponent`,
  `CommentsComponent`, `LoansComponent`, `CommunicationsComponent`) **остаются на Livewire**.
- `MainActionsComponent` **не удаляем** — оставляем мёртвым кодом (перестаём мепить в `ClientController`).
  Удаление — отдельный будущий PR.

## Принятые решения

1. **Scope:** только MainActions + его partials.
2. **Бэкенд:** новый Admin-контроллер + admin-роуты (та же session-авторизация и middleware, что у панели,
   CSRF через стандартный токен). Form Requests для валидации.
3. **JS:** чистый vanilla, без Alpine, **инлайн** в blade-вью.
4. **Обновление UI:** action-эндпоинты возвращают JSON-конверт; где меняется таблица/список — сервер
   рендерит Blade-partial и кладёт готовый HTML в поле `html`, JS делает `container.innerHTML = html`.
5. **Мост к Livewire:** после успешных действий JS вызывает
   `Livewire.dispatch('refreshCommunications')` и `Livewire.dispatch('refreshHeader')` — Livewire на
   странице остаётся, поэтому работает.

## Архитектура и поток данных

`ClientController@show` для вкладки «Gestión» вместо `Livewire::mount(MainActionsComponent)` рендерит
обычный Blade-partial (`admin/client/gestion/index.blade.php`), прокидывая туда всё, что раньше собирал
`mount()`: телефоны (отсортированные), email, расписания звонков, словари, `gestionData()`-блоб, флаги
конфига, текущий заём, `isCloseCommunication`, CSRF-токен, URL'ы эндпоинтов.

Реактивность формы — один инлайн `<script>`, читающий начальные данные из `window.__gestion = @json(...)`.
Все действия → `fetch()` на новые эндпоинты. Ответ — `{ ok, message?, level?, errors?, html?, payload? }`.

## Бэкенд

### `App\Admin\Controllers\Client\ClientGestionController` (тонкий)

Под префиксом `config('admin.route.prefix')`, та же авторизация, что у панели. Эндпоинты:

| Метод | HTTP | Действие | Источник в компоненте |
|---|---|---|---|
| `saveGestion` | POST | валидация + `CommunicationService::saveCommunication`; ответ — сигнал refresh | `save()` |
| `saveCallSchedule` | POST | создать `CallSchedule`; вернуть HTML списка расписаний | `saveCallSchedule()` |
| `initWebitelCall` | POST | `WebitelCallService::call`; вернуть `externalCommunicationId` | `initWebitelCall()` |
| `sendSms` | POST | `SmsService`; вернуть статус + ссылку на bucket | `sendSMS()` |
| `sendEmail` | POST | `EmailService`; вернуть статус + ссылку | `sendEmail()` |
| `addPhone` | POST | создать `Phone`; вернуть HTML таблицы телефонов | `addPhone()` |
| `addEmail` | POST | создать `Email`; вернуть HTML таблицы email | `addEmail()` |
| `togglePhoneStatus` | POST | обновить `idactivo` | `togglePhoneStatus()` |
| `toggleEmailStatus` | POST | обновить `idactivo` | `toggleEmailStatus()` |
| `setChannels` | POST | сохранить каналы клиента | `setChannels()` |
| `smsPreview` | POST | собрать текст SMS по company/options/variable | `updatedSendSms*`, `setSMSVariable()` |
| `emailTemplateFields` | GET | поля шаблона по `companyId` | `updatedSelectedEmailPlantillas()` |
| `emailPreview` | POST | HTML превью письма | `getEmailPreview()` / `showPreview()` |

`setAction`-эндпоинт **не нужен**: контакты/результаты фронт считает из `gestionData.graph` на клиенте
(как Alpine сейчас).

### `App\Services\Client\GestionService`

Переносим из компонента: `buildPreformText()`, `buildCommunicationDto()`, `buildDtoFromPayload()`,
сбор `gestionData()`. Зовётся контроллером и при первичном рендере вью.

### Form Requests

`SaveGestionRequest` (переносит `rules()`/`messages()` компонента), `AddEmailRequest`,
`SaveCallScheduleRequest`, `SendSmsRequest`, `SendEmailRequest`. Для телефона переиспользуем/дорабатываем
существующий `AddClientPhoneRequest`. Серверная валидация — авторитетная; клиентская (`canSubmit`) —
дублирующая для UX. Проверить у соседних Form Request, массивный или строковый стиль правил.

## Фронтенд

### Blade-вьюхи (новые, под `admin/client/gestion/`)

- `index.blade.php` — корень: контейнеры табов и режимов form/list, инлайн `<script>` + `window.__gestion`.
- partials: `tabs`, `phones-table`, `emails-table`, `schedule-list`, `gestion-form`, `sms-form`,
  `email-form`, `normatividad`, модалки `add-phone`/`add-email`. Маппинг на текущие partials ~1:1, минус `wire:*` и Alpine `x-*`.

### Инлайн JS-модуль (vanilla, без фреймворков)

- **Состояние:** `state = { view, selectedTab, actionId, phoneId, emailId, contactId, nonPaymentReasonId,
  strategyId, resultId, value, date, comment, time, externalCommunicationId, errors, timer }`.
- **Граф/деривации (порт из Alpine 1:1):** `availableContacts`, `availableResults`, `contactGroupId`,
  `showReasonStrategy`, `showValueInput`, `showDateInput`, `identifier`, `preformText`, `canSubmit`.
- **Рендер:** ручное заполнение `<select>` контактов/результатов; показ/скрытие блоков через `hidden`/`classList`;
  подсветка ошибок (`is-invalid` + `.invalid-feedback`).
- **Сеть:** обёртка `post(url, body)`/`get(url)` с CSRF-заголовком; разбор `{ ok, errors, html, message, level }`.
- **Мост:** `Livewire.dispatch('refreshCommunications' | 'refreshHeader')` после успеха.
- **Внешние либы (остаются):** SweetAlert (алерты + модалки add-phone/add-email/normatividad),
  flatpickr (даты гестии и расписания), IMask (маска телефона), Bootstrap-модалка превью email.

## Обработка ошибок и edge-cases

- Ошибка сервера: `{ ok:false, message }` → SweetAlert error (как сейчас `showError`).
- Ошибки валидации: `{ ok:false, errors }` → подсветка полей (как `showValidationErrors`).
- SMS/email `STATUS_WAITING` → info-алерт; `STATUS_FAILED` → error; в обоих случаях гестия не сохраняется,
  поведение как сейчас.
- Нерабочий день (`Calendar::isTodayNonWorking()`) → блокирующий error до отправки.
- `isCloseCommunication` (чужая каса) → блокирующий warning во вью вместо формы.
- CSRF/сессия: HTTP 419 → понятный алерт «сессия истекла».
- `openLink` (ссылка на bucket) → `window.open(link, '_blank')` (через `finally`, как сейчас).

## Тестирование

- PHPUnit feature-тесты на каждый эндпоинт: happy-path + ошибки валидации + festivo + waiting/failed для
  sms/email. (`php artisan test --compact --filter=...`.)
- Перед финалом `vendor/bin/pint --dirty`.
- Ручная/Playwright проверка первопричины бага: открыть `<select>` «Contacto» и параллельно инициировать
  действие — убедиться, что серого ghost-popup больше нет.

## Порядок работ (high-level, детализируется в плане)

1. `GestionService` + Form Requests (вынос бизнес-логики из компонента).
2. `ClientGestionController` + admin-роуты + feature-тесты.
3. Blade-вьюхи `admin/client/gestion/*` + инлайн vanilla JS.
4. Переключить `ClientController@show` на новый вью (перестать мепить `MainActionsComponent`).
5. Прогнать тесты, Pint, ручная проверка бага. `MainActionsComponent` остаётся мёртвым кодом.

## Открытые риски

- `gestionData.graph` должен полностью покрывать клиентскую деривацию контактов/результатов — проверить, что
  никакая ветка `setAction()`/`updateResults()` не делала чего-то сверх графа.
- SMS/email превью и сбор текста шаблонов завязаны на сервисы (`SmsService`, `EmailHelper`) — эндпоинты
  должны точно повторить текущий результат `fillMessagePlaceholders`.
- Telephony (`initWebitelCall`, Colombia SIP, Peru Webitel) — поведение per-instance, не регрессировать.