# MainActionsComponent Alpine Cascade — 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:** Перевести каскад формы «Agregar gestión» в `MainActionsComponent` на клиентский Alpine, убрав все промежуточные Livewire round-trip'ы — от клика «добавить гестию» до клика «Guardar» сетевых запросов быть не должно.

**Architecture:** В `mount()` компонент отдаёт в браузер крошечный JSON-граф справочников (`5_relacion_codigos` + словари). Один инлайн Alpine-компонент `gestionForm` ведёт весь UI-каскад (выбор контакта → результата → видимость value/date, переключение вкладок, счётчик SMS) на клиенте. Единственная точка выхода на сервер — терминальный `$wire.save(payload)`, где остаётся вся серверная бизнес-логика (валидация, `CommunicationDTO`, `CommunicationService`). Серверные каскад-хуки удаляются.

**Tech Stack:** PHP 8.3, Laravel 10, Livewire 3 (Alpine идёт в комплекте), PHPUnit 10. JS — инлайн `alpine:init` + `Alpine.data` (паттерн уже используется в `resources/views/livewire/support/index.blade.php`). Сборка Vite для этой логики НЕ требуется (Alpine грузится Livewire'ом, регистрация — инлайн в блейде).

## Global Constraints

- Команды выполнять внутри контейнера: `docker exec -i polaris-service-new php artisan ...`, `docker exec -i polaris-service-new vendor/bin/pint --dirty`, `docker exec -i polaris-service-new php artisan test --compact`.
- Перед финализацией: `vendor/bin/pint --dirty` (не `--test`).
- Не использовать `DB::` — только Eloquent на правильном connection (`collection`).
- Не вызывать `env()` вне `config/`; настройки через `config('polaris.…')`.
- Livewire 3: namespace `App\Livewire`; события через `$this->dispatch()`; `wire:model.live` для реактивности.
- Eloquent casts на L10 — через `protected $casts`, не метод `casts()`.
- **Коммиты делает пользователь сам** (стоячее предпочтение проекта — без per-step коммитов). В шагах ниже коммит-команд нет; после каждой задачи останавливаемся на ревью, пользователь коммитит когда захочет.
- Создавать файлы через `php artisan make:* --no-interaction` где применимо.

---

## File Structure

**Изменяется:**
- `app/Livewire/Client/MainActionsComponent.php` — добавляются `gestionData()`, `buildPreformText()`, `buildCommunicationDto()`, `validationErrorsFor()`; `rules()`/`messages()` переписываются на payload; `save()` → `save(array $payload)`; удаляются серверные каскад-хуки (`updatedContactId`, `updatedResultId`, `updateResults`, каскадная часть `updated()`, серверная логика `setAction`).
- `resources/views/livewire/main-actions.blade.php` — корень оборачивается в `x-data="gestionForm(...)"`; серверные `@if($actionId)`/`@if($selectedTab)` переключатели вида → `x-show`; добавляется инлайн `Alpine.data('gestionForm', ...)`; блок формы гестии заменяется на `@include` нового partial.
- `resources/views/livewire/actions-wrapper.blade.php` — `wire:click="setTab(...)"` → Alpine `@click`.
- `resources/views/livewire/client/phones.blade.php` — `wire:click="setAction(...)"` → Alpine `@click`.
- `resources/views/livewire/client/emails.blade.php` — `wire:click="setEmailAction(5/12, id)"` (гестия) → Alpine `@click`; кнопка «Enviar Email» (`setEmailAction(5,id,true)`) остаётся на `wire:click` (форма отправки вне охвата).

**Создаётся:**
- `resources/views/livewire/client/gestion-form.blade.php` — partial формы «Agregar gestión» на Alpine.
- `tests/Unit/Livewire/MainActionsComponentTest.php` — DB-free тесты для `validationErrorsFor`, `buildCommunicationDto`, `buildPreformText`.

---

## Task 1: `gestionData()` — JSON-контракт справочников

**Files:**
- Modify: `app/Livewire/Client/MainActionsComponent.php`
- Test: `tests/Unit/Livewire/MainActionsComponentTest.php`

**Interfaces:**
- Produces: `public function gestionData(): array` — возвращает массив с ключами `graph` (list of `['a'=>int,'c'=>int,'r'=>int]`), `actions` (`[id => ['descripcion','guion']]`), `contacts` (`[id => ['descripcion','guion','idgrupo']]`), `results` (`[id => ['descripcion','guion','valor'=>bool,'fecha'=>bool]]`), `reasons` (`[['id','descripcion']]`), `strategies` (`[['id','descripcion']]`), `phones` (`[id => telefono]`), `emails` (`[id => email]`), `clientPopup` (int), `hideChannels` (bool), `defaultChannels` (array).

- [ ] **Step 1: Создать тестовый файл и каталог**

Run: `docker exec -i polaris-service-new php artisan make:test --unit Livewire/MainActionsComponentTest --no-interaction`
Expected: `Test [tests/Unit/Livewire/MainActionsComponentTest.php] created successfully.`

- [ ] **Step 2: Заменить содержимое теста заглушкой структуры под весь набор тестов**

Заменить весь файл `tests/Unit/Livewire/MainActionsComponentTest.php` на:

```php
<?php

namespace Tests\Unit\Livewire;

use App\Http\Requests\Promise\DTO\CommunicationDTO;
use App\Livewire\Client\MainActionsComponent;
use Carbon\Carbon;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;

class MainActionsComponentTest extends TestCase
{
    private function component(): MainActionsComponent
    {
        return new MainActionsComponent();
    }

    private function dictionaries(): array
    {
        return [
            'actions'  => [1 => ['descripcion' => 'Llamada saliente', 'guion' => 'A1']],
            'contacts' => [
                1 => ['descripcion' => 'Titular', 'guion' => 'C1', 'idgrupo' => 1],
                2 => ['descripcion' => 'Tercero', 'guion' => 'C2', 'idgrupo' => 2],
            ],
            'results'  => [
                10 => ['descripcion' => 'Acuerdo', 'guion' => 'R10', 'valor' => true, 'fecha' => true],
                11 => ['descripcion' => 'No contesta', 'guion' => 'R11', 'valor' => false, 'fecha' => false],
            ],
            'reasons'  => [['id' => 3, 'descripcion' => 'Olvido']],
        ];
    }
}
```

This is intentionally minimal scaffolding; later tasks add real test methods to this file.

- [ ] **Step 3: Написать падающий тест на форму `gestionData()`**

Этот тест требует реальной БД (`listCached`/`CodeRelation`), поэтому помечаем его как требующий БД и пропускаем в окружении без неё. Добавить метод в класс теста:

```php
    public function test_gestion_data_shape_when_database_available(): void
    {
        try {
            $data = $this->component()->gestionData();
        } catch (\Throwable $e) {
            $this->markTestSkipped('Requires collection DB: ' . $e->getMessage());
        }

        $this->assertArrayHasKey('graph', $data);
        $this->assertArrayHasKey('actions', $data);
        $this->assertArrayHasKey('contacts', $data);
        $this->assertArrayHasKey('results', $data);
        $this->assertArrayHasKey('clientPopup', $data);
        $this->assertArrayHasKey('hideChannels', $data);
        $this->assertArrayHasKey('defaultChannels', $data);

        if (! empty($data['results'])) {
            $first = reset($data['results']);
            $this->assertArrayHasKey('valor', $first);
            $this->assertArrayHasKey('fecha', $first);
        }
    }
```

- [ ] **Step 4: Запустить — убедиться, что падает (метод не существует)**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=test_gestion_data_shape_when_database_available`
Expected: FAIL — `Call to undefined method App\Livewire\Client\MainActionsComponent::gestionData()`.

- [ ] **Step 5: Реализовать `gestionData()`**

В `app/Livewire/Client/MainActionsComponent.php` добавить импорты (если отсутствуют) и метод. Импорты в шапке файла:

```php
use App\Models\Collection\Dictionary\Contact;
use App\Models\Collection\Dictionary\Result;
```

Метод (поставить рядом с `render()`):

```php
    public function gestionData(): array
    {
        $graph = CodeRelation::query()
            ->whereHas('contact')
            ->whereHas('result')
            ->get(['idaccion', 'idcontacto', 'idresultado'])
            ->map(fn (CodeRelation $relation): array => [
                'a' => (int) $relation->idaccion,
                'c' => (int) $relation->idcontacto,
                'r' => (int) $relation->idresultado,
            ])
            ->values()
            ->all();

        $actions = $this->actions->mapWithKeys(fn ($action): array => [
            $action->id => ['descripcion' => $action->descripcion, 'guion' => $action->guion],
        ])->all();

        $contacts = Contact::listCached()->mapWithKeys(fn ($contact): array => [
            $contact->id => [
                'descripcion' => $contact->descripcion,
                'guion'       => $contact->guion,
                'idgrupo'     => (int) $contact->idgrupo,
            ],
        ])->all();

        $results = Result::listCached()->mapWithKeys(fn ($result): array => [
            $result->id => [
                'descripcion' => $result->descripcion,
                'guion'       => $result->guion,
                'valor'       => (bool) $result->valor,
                'fecha'       => (bool) $result->fecha,
            ],
        ])->all();

        $reasons = collect($this->nonPaymentReasons)
            ->map(fn (array $reason): array => ['id' => $reason['id'], 'descripcion' => $reason['descripcion']])
            ->values()
            ->all();

        $strategies = collect($this->strategies)
            ->map(fn (array $strategy): array => ['id' => $strategy['id'], 'descripcion' => $strategy['descripcion']])
            ->values()
            ->all();

        return [
            'graph'           => $graph,
            'actions'         => $actions,
            'contacts'        => $contacts,
            'results'         => $results,
            'reasons'         => $reasons,
            'strategies'      => $strategies,
            'phones'          => $this->phones->mapWithKeys(fn ($phone): array => [$phone->id => $phone->telefono])->all(),
            'emails'          => $this->emails->mapWithKeys(fn ($email): array => [$email->id => $email->email])->all(),
            'clientPopup'     => (int) $this->client->popup,
            'hideChannels'    => (bool) config('polaris.client.hide_channels'),
            'defaultChannels' => (array) config('polaris.client.default_channels'),
        ];
    }
```

- [ ] **Step 6: Запустить — тест проходит или пропускается (если нет БД)**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=test_gestion_data_shape_when_database_available`
Expected: PASS (или `SKIPPED` с сообщением `Requires collection DB`, если коннекта к БД нет).

---

## Task 2: Валидация на payload — `rules()`, `messages()`, `validationErrorsFor()`

**Files:**
- Modify: `app/Livewire/Client/MainActionsComponent.php:160-200` (методы `rules`/`messages`)
- Test: `tests/Unit/Livewire/MainActionsComponentTest.php`

**Interfaces:**
- Consumes: payload-массив с ключами `resultId, contactGroupId, strategyId, nonPaymentReasonId, value, date, showValueInput, showDateInput`.
- Produces: `public function validationErrorsFor(array $payload): array` — пустой массив при валидном payload, иначе `['field' => ['message', ...]]`.

- [ ] **Step 1: Написать падающие тесты валидации**

Добавить в `MainActionsComponentTest`:

```php
    public function test_validation_passes_for_minimal_valid_payload(): void
    {
        $payload = [
            'resultId' => 11, 'contactGroupId' => 2,
            'showValueInput' => false, 'showDateInput' => false,
        ];

        $this->assertSame([], $this->component()->validationErrorsFor($payload));
    }

    public function test_validation_requires_result(): void
    {
        $payload = ['resultId' => null, 'contactGroupId' => 2, 'showValueInput' => false, 'showDateInput' => false];

        $this->assertArrayHasKey('resultId', $this->component()->validationErrorsFor($payload));
    }

    public function test_group_one_requires_reason_and_strategy(): void
    {
        $payload = [
            'resultId' => 11, 'contactGroupId' => 1,
            'nonPaymentReasonId' => null, 'strategyId' => null,
            'showValueInput' => false, 'showDateInput' => false,
        ];

        $errors = $this->component()->validationErrorsFor($payload);
        $this->assertArrayHasKey('nonPaymentReasonId', $errors);
        $this->assertArrayHasKey('strategyId', $errors);
    }

    public function test_value_required_and_positive_when_shown(): void
    {
        $payload = [
            'resultId' => 10, 'contactGroupId' => 2,
            'showValueInput' => true, 'value' => 0, 'showDateInput' => false,
        ];

        $this->assertArrayHasKey('value', $this->component()->validationErrorsFor($payload));
    }

    public function test_value_ignored_when_hidden(): void
    {
        $payload = [
            'resultId' => 11, 'contactGroupId' => 2,
            'showValueInput' => false, 'value' => 0, 'showDateInput' => false,
        ];

        $this->assertArrayNotHasKey('value', $this->component()->validationErrorsFor($payload));
    }

    public function test_date_must_not_be_in_past_when_shown(): void
    {
        $payload = [
            'resultId' => 10, 'contactGroupId' => 2,
            'showValueInput' => false,
            'showDateInput' => true, 'date' => Carbon::yesterday()->toDateString(),
        ];

        $this->assertArrayHasKey('date', $this->component()->validationErrorsFor($payload));
    }
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=MainActionsComponentTest`
Expected: FAIL — `Call to undefined method ...::validationErrorsFor()`.

- [ ] **Step 3: Переписать `rules()`/`messages()` и добавить `validationErrorsFor()`**

Заменить методы `rules()` и `messages()` (текущие строки ~160-200) на:

```php
    public function rules(): array
    {
        return [
            'resultId'           => ['required'],
            'nonPaymentReasonId' => ['required_if:contactGroupId,1'],
            'strategyId'         => ['required_if:contactGroupId,1'],
            'date'               => [
                'exclude_unless:showDateInput,true',
                'required',
                function ($attribute, $value, $fail): void {
                    if ($value && Carbon::parse($value)->lt(Carbon::today())) {
                        $fail(__('La fecha no puede ser anterior a hoy'));
                    }
                },
            ],
            'value'              => [
                'exclude_unless:showValueInput,true',
                'required',
                'numeric',
                'min:0.01',
            ],
        ];
    }

    public function messages(): array
    {
        return [
            'nonPaymentReasonId.required_if' => __('El campo de motivo de impago es obligatorio cuando el id de grupo de contacto es 1.'),
            'strategyId.required_if'         => __('El campo de estrategia es obligatorio cuando el id de grupo de contactos es 1.'),
            'resultId.required'              => __('El campo de resultado es obligatorio.'),
            'value.required'                 => __('El campo valor es obligatorio cuando el resultado es Acuerdo de Pago o Negociacion Ext.'),
            'value.numeric'                  => __('El valor debe ser un número.'),
            'value.min'                      => __('La cantidad a pagar no debe ser igual a cero.'),
            'date.required'                  => __('El campo fecha es obligatorio cuando el resultado es Acuerdo de Pago o Negociacion Ext.'),
        ];
    }

    public function validationErrorsFor(array $payload): array
    {
        return Validator::make($payload, $this->rules(), $this->messages())
            ->errors()
            ->toArray();
    }
```

Добавить импорт в шапку (если отсутствует): `use Illuminate\Support\Facades\Validator;` (импорт `Illuminate\Validation\Rule` можно удалить — больше не используется).

- [ ] **Step 4: Запустить — тесты валидации проходят**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=MainActionsComponentTest`
Expected: PASS для всех `test_validation_*`, `test_group_one_*`, `test_value_*`, `test_date_*`.

---

## Task 3: Сборка preform-текста и DTO — `buildPreformText()`, `buildCommunicationDto()`

**Files:**
- Modify: `app/Livewire/Client/MainActionsComponent.php`
- Test: `tests/Unit/Livewire/MainActionsComponentTest.php`

**Interfaces:**
- Produces:
  - `public function buildPreformText(array $payload, array $dict, string $identifier): string` — повторяет логику текущего `getPreformText()`, но из payload + словарей (`$dict` — подмассив формы `gestionData()`: ключи `actions/contacts/results/reasons`). `$identifier` — телефон или email.
  - `public function buildCommunicationDto(array $payload, ?string $strategyDescription, string $phoneNumber, ?string $email, string $text): CommunicationDTO` — маппинг payload → `CommunicationDTO`. Для email-веток (`actionId` ∈ {5,12}) `contactId` = `payload['contactGroupId']`, `resultId` = `payload['resultId'] ?? 19`, `phoneNumber=''`; иначе `contactId = payload['contactId']`, `resultId = payload['resultId']`.

- [ ] **Step 1: Написать падающие тесты**

Добавить в `MainActionsComponentTest`:

```php
    public function test_build_preform_text_group_one(): void
    {
        $payload = ['actionId' => 1, 'contactId' => 1, 'contactGroupId' => 1, 'nonPaymentReasonId' => 3, 'resultId' => 10];

        $text = $this->component()->buildPreformText($payload, $this->dictionaries(), '999111222');

        $this->assertSame('A1 999111222 C1 Olvido R10', $text);
    }

    public function test_build_preform_text_non_group_one_skips_reason(): void
    {
        $payload = ['actionId' => 1, 'contactId' => 2, 'contactGroupId' => 2, 'nonPaymentReasonId' => 3, 'resultId' => 11];

        $text = $this->component()->buildPreformText($payload, $this->dictionaries(), 'mail@x.com');

        $this->assertSame('A1 mail@x.com C2 R11', $text);
    }

    public function test_build_dto_maps_phone_branch(): void
    {
        $payload = [
            'actionId' => 1, 'phoneId' => 5, 'contactId' => 1, 'contactGroupId' => 1,
            'resultId' => 10, 'nonPaymentReasonId' => 3, 'value' => 150.5,
            'date' => '2030-01-01', 'time' => '00:01:02', 'userId' => 7,
            'externalCommunicationId' => null,
        ];

        $dto = $this->component()->buildCommunicationDto($payload, 'Estrategia X', '999111222', null, 'algun texto');

        $this->assertInstanceOf(CommunicationDTO::class, $dto);
        $this->assertSame(1, $dto->actionId);
        $this->assertSame(1, $dto->contactId);
        $this->assertSame(10, $dto->resultId);
        $this->assertSame(3, $dto->reasonId);
        $this->assertSame(150.5, $dto->value);
        $this->assertSame('999111222', $dto->phoneNumber);
        $this->assertSame('algun texto', $dto->text);
        $this->assertSame('00:01:02', $dto->time);
    }

    public function test_build_dto_email_branch_uses_group_as_contact(): void
    {
        $payload = [
            'actionId' => 5, 'phoneId' => null, 'contactId' => 1, 'contactGroupId' => 2,
            'resultId' => null, 'nonPaymentReasonId' => null, 'value' => null,
            'date' => null, 'time' => '00:00:01', 'userId' => 7,
        ];

        $dto = $this->component()->buildCommunicationDto($payload, null, '', 'mail@x.com', 'texto email');

        $this->assertSame(2, $dto->contactId);   // contactGroupId
        $this->assertSame(19, $dto->resultId);    // default
        $this->assertSame('', $dto->phoneNumber);
        $this->assertSame('mail@x.com', $dto->email);
    }
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=MainActionsComponentTest`
Expected: FAIL — `Call to undefined method ...::buildPreformText()`.

- [ ] **Step 3: Реализовать методы**

Добавить в `app/Livewire/Client/MainActionsComponent.php` (рядом с существующим `getPreformText`):

```php
    public function buildPreformText(array $payload, array $dict, string $identifier): string
    {
        $actionId = $payload['actionId'] ?? null;

        if (is_null($actionId)) {
            return '';
        }

        $text = trim(($dict['actions'][$actionId]['guion'] ?? '') . ' ' . $identifier);

        $contactId = $payload['contactId'] ?? null;
        if (! is_null($contactId) && isset($dict['contacts'][$contactId])) {
            $text .= ' ' . $dict['contacts'][$contactId]['guion'];
        }

        if (($payload['contactGroupId'] ?? null) == 1 && ! is_null($payload['nonPaymentReasonId'] ?? null)) {
            $reason = collect($dict['reasons'] ?? [])->firstWhere('id', $payload['nonPaymentReasonId']);
            if ($reason) {
                $text .= ' ' . $reason['descripcion'];
            }
        }

        $resultId = $payload['resultId'] ?? null;
        if (! is_null($resultId) && isset($dict['results'][$resultId])) {
            $text .= ' ' . $dict['results'][$resultId]['guion'];
        }

        return $text;
    }

    public function buildCommunicationDto(
        array $payload,
        ?string $strategyDescription,
        string $phoneNumber,
        ?string $email,
        string $text
    ): CommunicationDTO {
        $isEmail = in_array($payload['actionId'] ?? null, [5, 12], true);

        return new CommunicationDTO(
            document: $this->client->documento,
            promiseDate: ! empty($payload['date']) ? Carbon::parse($payload['date']) : null,
            value: $payload['value'] ?? null,
            strategy: $strategyDescription,
            phoneNumber: $isEmail ? '' : $phoneNumber,
            text: $text,
            actionId: (int) $payload['actionId'],
            contactId: (int) ($isEmail ? ($payload['contactGroupId'] ?? 0) : $payload['contactId']),
            resultId: (int) ($isEmail ? ($payload['resultId'] ?? 19) : $payload['resultId']),
            reasonId: $payload['nonPaymentReasonId'] ?? 0,
            userId: $this->userId,
            time: $payload['time'] ?? '00:00:01',
            communicationDate: now(),
            externalCommunicationId: $payload['externalCommunicationId'] ?? null,
            email: $email,
        );
    }
```

> Примечание: `buildCommunicationDto` читает `$this->client` и `$this->userId`. В юнит-тесте они не заданы (`new MainActionsComponent()`), но тестам выше эти поля не нужны для проверяемых ассертов — `document` и `userId` не ассертятся. Если PHP выдаст ошибку обращения к неинициализированному типизированному свойству `$client`, в тестовом методе перед вызовом присвоить заглушку: `$c = $this->component(); $c->client = new \App\Models\Collection\Client\Client(); $c->userId = 7;`. Добавить это в оба `test_build_dto_*`.

- [ ] **Step 4: Запустить — тесты проходят**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=MainActionsComponentTest`
Expected: PASS для `test_build_preform_text_*` и `test_build_dto_*`. Если падает на доступе к `$client`/`$userId` — применить заглушки из примечания и перезапустить.

---

## Task 4: Переписать `save(array $payload)` и удалить серверные каскад-хуки

**Files:**
- Modify: `app/Livewire/Client/MainActionsComponent.php` — `save()` (строки ~324-354), удаление `updated()` каскадной части (~279-286), `updateResults()` (~288-298), `updatedContactId()` (~780-793), `updatedResultId()` (~845-865), серверной логики `setAction()` (~675-696). `saveCommunication()`/`saveCommunicationForEmail()` упрощаются до тонких врапперов над новыми методами.

**Interfaces:**
- Consumes: `validationErrorsFor()`, `buildPreformText()`, `buildCommunicationDto()` из задач 2–3; `gestionData()` из задачи 1.
- Produces: `public function save(array $payload): void`. При невалидном payload диспатчит `showValidationErrors` с массивом ошибок и выходит, не сохраняя. При валидном — сохраняет через `CommunicationService` и диспатчит `saveSuccess`/`refreshCommunications`/`refreshHeader`.

- [ ] **Step 1: Заменить `save()` на payload-версию**

Заменить метод `save()` на:

```php
    public function save(array $payload): void
    {
        $errors = $this->validationErrorsFor($payload);

        if (! empty($errors)) {
            $this->dispatch('showValidationErrors', errors: $errors);

            return;
        }

        try {
            CommunicationService::saveCommunication($this->buildDtoFromPayload($payload));
        } catch (\Throwable $exception) {
            $this->dispatch('showError', message: $exception->getMessage());
            Log::channel('sentry')->error($exception);

            return;
        }

        $this->phoneRelations = PhoneRelation::listCached()->keyBy('alias');

        if (session()->has('current_task')) {
            $filledClients   = session()->get('filled_clients') ?? [];
            $filledClients[] = $this->client->id;
            session()->put('filled_clients', $filledClients);
        }

        $this->dispatch('saveSuccess');
        $this->resetState();
        $this->dispatch('refreshCommunications');
        $this->dispatch('refreshHeader');
    }

    protected function buildDtoFromPayload(array $payload): CommunicationDTO
    {
        $isEmail = in_array($payload['actionId'] ?? null, [5, 12], true);

        $strategyDescription = collect($this->strategies)->firstWhere('id', $payload['strategyId'] ?? null)['descripcion'] ?? null;
        $phoneNumber         = $this->phones->firstWhere('id', $payload['phoneId'] ?? null)?->telefono ?? '';
        $email               = $this->emails->firstWhere('id', $payload['emailId'] ?? null)?->email;

        $identifier = $isEmail ? ($email ?? '') : $phoneNumber;
        $text       = trim($this->buildPreformText($payload, $this->gestionData(), $identifier) . ' ' . ($payload['comment'] ?? ''));

        $dto = $this->buildCommunicationDto($payload, $strategyDescription, $phoneNumber, $email, $text);

        $dto->communicationMetadata = new CommunicationMetadataDTO(
            userId: $this->userId,
            channel: CommunicationMetadataDTO::CHANNEL_UI,
            method: currentMethod(),
        );

        return $dto;
    }
```

- [ ] **Step 2: Удалить устаревшие серверные методы каскада**

Удалить целиком методы `updateResults()`, `updatedContactId()`, `updatedResultId()`, `saveCommunication()`, `saveCommunicationForEmail()`. В методе `updated()` удалить блок, вызывающий `updateResults()` (строки с проверкой `strategyId`/`nonPaymentReasonId`), оставив только `$this->resetErrorBag($name);`. Метод `setAction()` оставить только если он ещё используется server-side для чего-то кроме каскада — в данном плане клиентский `setAction` его заменяет, поэтому удалить `setAction()` и `setEmailAction()` серверные версии (их вызовы переносятся в Alpine в задачах 6–7). Метод `getPreformText()` оставить — он используется в blade-превью только если останется server-side; в нашем дизайне превью на клиенте, поэтому `getPreformText()` тоже можно удалить (его заменяет `buildPreformText`). `getSideActions()`, `getSelectedPhone()`, `getSelectedEmail()`, `setChannels()`, `addPhone()`, `addEmail()`, `togglePhoneStatus()`, `toggleEmailStatus()`, `initWebitelCall()`, `sendSMS()`, `sendEmail()`, `saveCallSchedule()`, SMS/email-хелперы — **оставить без изменений** (вне охвата).

> Важно: убедиться, что `setChannels(array $channels)` остаётся публичным — он вызывается из Alpine через `$wire.setChannels(...)`.

- [ ] **Step 3: Прогнать pint и весь набор юнит-тестов**

Run: `docker exec -i polaris-service-new vendor/bin/pint --dirty`
Then: `docker exec -i polaris-service-new php artisan test --compact --filter=MainActionsComponentTest`
Expected: pint — `PASS`/без ошибок; тесты — все зелёные (DB-тест может быть SKIPPED).

- [ ] **Step 4: Статическая проверка отсутствия ссылок на удалённые методы**

Run: `grep -rn "updateResults\|updatedContactId\|updatedResultId\|getPreformText\|->saveCommunication(" app resources/views/livewire/main-actions.blade.php resources/views/livewire/client`
Expected: совпадений в blade-файлах формы гестии нет (кроме новых Alpine-эквивалентов, которые появятся в задачах 5–7). Если `getPreformText` встречается в `main-actions.blade.php:402` — это место заменяется на Alpine в задаче 5; зафиксировать для следующей задачи.

---

## Task 5: Создать Alpine partial формы гестии `gestion-form.blade.php`

**Files:**
- Create: `resources/views/livewire/client/gestion-form.blade.php`

**Interfaces:**
- Consumes: Alpine-состояние компонента `gestionForm` (регистрируется в задаче 6): свойства `view, actionId, contactId, nonPaymentReasonId, strategyId, resultId, value, date, comment, errors`; геттеры `availableContacts, availableResults, contactGroupId, showReasonStrategy, showValueInput, showDateInput, preformText, canSubmit`; методы `onContactChange(), onResultChange(), submit(), resetForm()`.

- [ ] **Step 1: Создать partial с формой на Alpine**

Создать `resources/views/livewire/client/gestion-form.blade.php`:

```blade
<div x-show="view === 'form' && actionId !== 3 && !(actionId === 6 && !showDefaultFormFlag) && !(actionId === 5 && showEmailSendFormFlag)">
    <h4 class="card-title mb-2 fs-15">{{ __('views.main_actions.management_entry') }}</h4>
    <div class="timer" id="timer">00:00:00</div>

    <form @submit.prevent="submit()" class="card-body px-0">
        <div class="row">
            <div class="col-md-12 row">
                <div class="col-md-4">
                    <div class="mb-3">
                        <label class="form-label">{{ __('views.main_actions.contact') }}</label>
                        <select x-model.number="contactId" @change="onContactChange()"
                                class="form-select" :class="{ 'form-disabled': !contactId }">
                            <option value="">{{ __('views.main_actions.select_contact') }}</option>
                            <template x-for="c in availableContacts" :key="c.id">
                                <option :value="c.id" x-text="c.descripcion"></option>
                            </template>
                        </select>
                    </div>
                </div>

                <template x-if="showReasonStrategy">
                    <div class="col-md-8 row g-0">
                        <div class="col-md-6 pe-md-2">
                            <div class="mb-3">
                                <label class="form-label">{{ __('views.main_actions.non_payment_reasons') }}</label>
                                <select x-model.number="nonPaymentReasonId"
                                        class="form-select" :class="{ 'form-disabled': !contactId, 'is-invalid': errors.nonPaymentReasonId }">
                                    <option value="">{{ __('views.main_actions.select_reason') }}</option>
                                    @foreach($nonPaymentReasons as $reason)
                                        <option value="{{ $reason['id'] }}">{{ $reason['descripcion'] }}</option>
                                    @endforeach
                                </select>
                                <div class="invalid-feedback d-block" x-show="errors.nonPaymentReasonId" x-text="errors.nonPaymentReasonId?.[0]"></div>
                            </div>
                        </div>
                        <div class="col-md-6 ps-md-2">
                            <div class="mb-3">
                                <label class="form-label">{{ __('views.main_actions.payment_strategy') }}</label>
                                <select x-model.number="strategyId"
                                        class="form-select" :class="{ 'form-disabled': !contactId, 'is-invalid': errors.strategyId }">
                                    <option value="">{{ __('views.main_actions.select_strategy') }}</option>
                                    @foreach($strategies as $strategy)
                                        <option value="{{ $strategy['id'] }}">{{ $strategy['descripcion'] }}</option>
                                    @endforeach
                                </select>
                                <div class="invalid-feedback d-block" x-show="errors.strategyId" x-text="errors.strategyId?.[0]"></div>
                            </div>
                        </div>
                    </div>
                </template>

                <div class="col-md-4">
                    <div class="mb-3">
                        <label class="form-label">{{ __('views.main_actions.result') }}</label>
                        <select x-model.number="resultId" @change="onResultChange()"
                                class="form-select" :class="{ 'form-disabled': availableResults.length === 0, 'is-invalid': errors.resultId }">
                            <option value="">{{ __('views.main_actions.select_result') }}</option>
                            <template x-for="r in availableResults" :key="r.id">
                                <option :value="r.id" x-text="r.descripcion"></option>
                            </template>
                        </select>
                        <div class="invalid-feedback d-block" x-show="errors.resultId" x-text="errors.resultId?.[0]"></div>
                    </div>
                </div>

                <div class="col-md-4" x-show="showValueInput">
                    <div class="mb-3">
                        <label class="form-label">{{ __('views.main_actions.value') }}</label>
                        <div class="input-group">
                            <span class="input-group-text"><i class="las la-dollar-sign"></i></span>
                            <input x-model.number="value" type="number" class="form-control" :class="{ 'is-invalid': errors.value }">
                            <div class="invalid-feedback d-block" x-show="errors.value" x-text="errors.value?.[0]"></div>
                        </div>
                    </div>
                </div>

                <div class="col-md-4" x-show="showDateInput">
                    <div class="mb-3">
                        <label class="form-label">{{ __('views.main_actions.date') }}</label>
                        <div class="input-group">
                            <span class="input-group-text"><i class="las la-calendar"></i></span>
                            <input x-model="date" class="form-control datepicker-input" :class="{ 'is-invalid': errors.date }"
                                   type="text" id="datepicker" readonly>
                            <div class="invalid-feedback d-block" x-show="errors.date" x-text="errors.date?.[0]"></div>
                        </div>
                    </div>
                </div>
            </div>

            <div class="col-md-12">
                <div class="mb-3">
                    <label class="form-label" x-text="preformText"></label>
                    <textarea x-model="comment" class="form-control" rows="3"></textarea>
                </div>
            </div>
        </div>

        <div class="row">
            <div class="col-sm-12 text-end">
                <button @click="resetForm()" type="button" class="btn btn-danger">{{ __('views.main_actions.cancel') }}</button>
                <button type="submit" class="btn btn-primary px-4" :class="{ disabled: !canSubmit }">{{ __('views.main_actions.save') }}</button>
            </div>
        </div>
    </form>
</div>
```

> Примечание: `showDefaultFormFlag` и `showEmailSendFormFlag` — Alpine-зеркала для SMS-формы (action 6) и Email-формы отправки (action 5 + showSendForm), которые остаются на Livewire. Они нужны только чтобы НЕ показывать форму гестии, когда открыта SMS/Email-форма отправки. Их объявление — в задаче 6.

- [ ] **Step 2: Ручная проверка верстки (после задачи 6)**

Эта задача проверяется совместно с задачей 6 (без зарегистрированного Alpine-компонента partial не отрисуется корректно). Зафиксировать: файл создан, синтаксис Blade валиден.

Run: `docker exec -i polaris-service-new php artisan view:cache && docker exec -i polaris-service-new php artisan view:clear`
Expected: компиляция без ошибок Blade.

---

## Task 6: Зарегистрировать Alpine `gestionForm` и перевести `main-actions.blade.php` на `x-data`/`x-show`

**Files:**
- Modify: `resources/views/livewire/main-actions.blade.php`

**Interfaces:**
- Produces: глобально зарегистрированный `Alpine.data('gestionForm', ...)` со всеми свойствами/геттерами/методами, перечисленными в задаче 5, плюс `setAction(actionId, phoneId, emailId)`, `setTab(tab)`, `currentTimerString()`, таймер и счётчик SMS.

- [ ] **Step 1: Обернуть корневой `<div>` в `x-data` и перевести переключатели вида на `x-show`**

В `resources/views/livewire/main-actions.blade.php`:

1. Заменить открывающий `<div>` (строка 2) на:
   ```blade
   <div x-data="gestionForm(@js($this->gestionData()))" x-cloak>
   ```
2. Список (телефоны/почты/прочее/расписание) — обернуть в `<div x-show="view === 'list'">` вместо `@if(!$actionId)`. Закрывающий `@endif` заменить на `</div>`.
3. Блок формы (`@if($actionId)` … терминальные ветки) обернуть в `<div x-show="view === 'form'">`. Внутри:
   - ветку `@elseif($actionId !== 3)` (форма гестии, строки 313-415) **заменить** на `@include('livewire.client.gestion-form')`.
   - ветки SMS (`actionId === 6`), Email-форма отправки (`actionId == 5 && showEmailSendForm`), расписание (`actionId === 3`) и `isCloseCommunication` — **оставить на Livewire** как есть, но управление видимостью перевести с server-`@if` на `x-show` по соответствующим Alpine-флагам (`actionId`, `showDefaultFormFlag`, `showEmailSendFormFlag`). Для веток, которые ещё ходят на сервер (SMS/email-отправка), сохранить их `wire:model.live` внутри — это вне охвата.

> Поскольку SMS/Email-формы остаются на Livewire, переход вида список↔SMS-форма для них всё ещё инициируется сервером (`setAction(6,...)`/`setEmailAction(5,...,true)` остаются `wire:click` для этих кнопок — см. задачу 7). Alpine-`view` синхронизируется с серверным `$actionId` через `x-init`/`$wire.entangle` ниже.

- [ ] **Step 2: Добавить регистрацию Alpine-компонента в инлайн `<script>`**

В блок `<script>` (перед `document.addEventListener("DOMContentLoaded", ...)`) добавить:

```javascript
document.addEventListener('alpine:init', () => {
    Alpine.data('gestionForm', (data) => ({
        graph: data.graph,
        actions: data.actions,
        contacts: data.contacts,
        results: data.results,
        reasons: data.reasons,
        strategies: data.strategies,
        phones: data.phones,
        emails: data.emails,
        clientPopup: data.clientPopup,
        hideChannels: data.hideChannels,
        defaultChannels: data.defaultChannels,

        view: 'list',
        selectedTab: 'phone',
        actionId: null,
        phoneId: null,
        emailId: null,
        contactId: null,
        nonPaymentReasonId: null,
        strategyId: null,
        resultId: null,
        value: null,
        date: null,
        comment: null,
        time: '00:00:01',
        externalCommunicationId: null,
        errors: {},
        showDefaultFormFlag: false,
        showEmailSendFormFlag: false,

        timer: { h: 0, m: 0, s: 0, interval: null },

        init() {
            this.$wire.on('showValidationErrors', (event) => {
                this.errors = event.errors ?? event[0]?.errors ?? {};
            });
            this.$wire.on('saveSuccess', () => this.resetForm());
        },

        setTab(tab) { this.selectedTab = tab; },

        setAction(actionId, phoneId = null, emailId = null) {
            this.resetFields();
            this.actionId = actionId;
            this.phoneId = phoneId;
            this.emailId = emailId;
            this.view = 'form';
            this.startTimer();
        },

        resetFields() {
            this.contactId = this.nonPaymentReasonId = this.strategyId = this.resultId = null;
            this.value = this.date = this.comment = null;
            this.errors = {};
        },

        resetForm() {
            this.resetFields();
            this.stopTimer();
            this.view = 'list';
            this.actionId = this.phoneId = this.emailId = null;
            $('.gestiones-tab-wrapper .nav').show();
        },

        get availableContacts() {
            if (!this.actionId) return [];
            const ids = [...new Set(this.graph.filter(g => g.a == this.actionId).map(g => g.c))];
            return ids.map(id => ({ id, descripcion: this.contacts[id]?.descripcion ?? '' }));
        },
        get availableResults() {
            if (!this.actionId || !this.contactId) return [];
            const ids = [...new Set(this.graph.filter(g => g.a == this.actionId && g.c == this.contactId).map(g => g.r))];
            return ids.map(id => ({ id, descripcion: this.results[id]?.descripcion ?? '' }));
        },
        get contactGroupId() {
            return this.contactId ? (this.contacts[this.contactId]?.idgrupo ?? null) : null;
        },
        get showReasonStrategy() {
            return this.contactGroupId === null || this.contactGroupId == 1;
        },
        get showValueInput() {
            return this.resultId ? !!this.results[this.resultId]?.valor : false;
        },
        get showDateInput() {
            return this.resultId ? !!this.results[this.resultId]?.fecha : false;
        },
        get identifier() {
            if (this.phoneId) return this.phones[this.phoneId] ?? '';
            if (this.emailId) return this.emails[this.emailId] ?? '';
            return '';
        },
        get preformText() {
            if (!this.actionId) return '';
            let t = ((this.actions[this.actionId]?.guion ?? '') + ' ' + this.identifier).trim();
            if (this.contactId && this.contacts[this.contactId]) t += ' ' + this.contacts[this.contactId].guion;
            if (this.contactGroupId == 1 && this.nonPaymentReasonId) {
                const reason = this.reasons.find(r => r.id == this.nonPaymentReasonId);
                if (reason) t += ' ' + reason.descripcion;
            }
            if (this.resultId && this.results[this.resultId]) t += ' ' + this.results[this.resultId].guion;
            return t;
        },
        get canSubmit() {
            if (!this.resultId) return false;
            if (this.contactGroupId == 1 && (!this.nonPaymentReasonId || !this.strategyId)) return false;
            if (this.showValueInput && !(this.value > 0)) return false;
            if (this.showDateInput && !this.date) return false;
            return true;
        },

        onContactChange() {
            this.resultId = null;
            this.value = this.date = null;
        },

        onResultChange() {
            this.errors = {};
            if (this.contactGroupId == 1 && this.clientPopup == 0 && this.resultId) {
                if (this.hideChannels) {
                    this.$wire.setChannels(this.defaultChannels);
                } else {
                    showNormatividad();
                }
                this.clientPopup = 1;
            }
            if (this.showDateInput) {
                this.$nextTick(() => window.dispatchEvent(new CustomEvent('build-gestion-datepicker')));
            }
        },

        async submit() {
            if (!this.canSubmit) return;
            this.time = this.currentTimerString();
            this.errors = {};
            await this.$wire.save({
                actionId: this.actionId,
                phoneId: this.phoneId,
                emailId: this.emailId,
                contactId: this.contactId,
                contactGroupId: this.contactGroupId,
                nonPaymentReasonId: this.nonPaymentReasonId,
                strategyId: this.strategyId,
                resultId: this.resultId,
                value: this.value,
                date: this.date,
                comment: this.comment,
                time: this.time,
                externalCommunicationId: this.externalCommunicationId,
                showValueInput: this.showValueInput,
                showDateInput: this.showDateInput,
            });
        },

        startTimer() {
            this.timer.h = this.timer.m = this.timer.s = 0;
            this.stopTimer();
            this.timer.interval = setInterval(() => {
                this.timer.s++;
                if (this.timer.s === 60) { this.timer.s = 0; this.timer.m++; }
                if (this.timer.m === 60) { this.timer.m = 0; this.timer.h++; }
                const el = document.getElementById('timer');
                if (el) el.innerText = this.currentTimerString();
            }, 1000);
        },
        stopTimer() {
            if (this.timer.interval) { clearInterval(this.timer.interval); this.timer.interval = null; }
        },
        currentTimerString() {
            const p = (n) => (n < 10 ? '0' + n : n);
            return `${p(this.timer.h)}:${p(this.timer.m)}:${p(this.timer.s)}`;
        },
    }));
});
```

> Удалить из инлайн-скрипта старые глобальные `updateTimer/setTimer/pad` и таймерный `setInterval` в `DOMContentLoaded` — их заменяют методы Alpine. Обработчики `Livewire.on('saveSuccess'|'showError'|'showInfo'|'openLink'|'openEmailPreviewModal')` (Swal/Bootstrap) — **оставить**. Заменить `Livewire.on('buildDatepicker')` на слушатель `window.addEventListener('build-gestion-datepicker', ...)` с тем же телом flatpickr.

- [ ] **Step 3: Скомпилировать views и проверить отсутствие ошибок Blade**

Run: `docker exec -i polaris-service-new php artisan view:clear`
Expected: без ошибок.

- [ ] **Step 4: Ручная проверка в браузере (Network открыт)**

Открыть карточку клиента → вкладка Gestiones:
1. Клик «добавить гестию» на телефоне (после задачи 7) или временно через консоль — форма открывается, таймер идёт, **без XHR**.
2. Выбор Contacto → Resultado → появление value/date — **без XHR**.
3. preform-лейбл обновляется реактивно.
Expected: сетевых запросов при шагах каскада нет; единственный запрос — при «Guardar».

---

## Task 7: Перевести клики `setAction`/`setTab`/`setEmailAction`(гестия) на Alpine

**Files:**
- Modify: `resources/views/livewire/actions-wrapper.blade.php`
- Modify: `resources/views/livewire/client/phones.blade.php`
- Modify: `resources/views/livewire/client/emails.blade.php`
- Modify: `resources/views/livewire/main-actions.blade.php` (блок «прочее»/side-actions, строки 22-23)

**Interfaces:**
- Consumes: Alpine-методы `setTab(tab)`, `setAction(actionId, phoneId, emailId)` из задачи 6.

- [ ] **Step 1: actions-wrapper — вкладки на Alpine**

В `resources/views/livewire/actions-wrapper.blade.php` заменить все 4 `wire:click="setTab('...')"` на `@click="setTab('{{ self::TAB_PHONE }}')"` (и соответственно EMAIL/ANOTHER/SCHEDULE), а серверные `@if($selectedTab === ...)` для класса `active` — на `:class="{ active: selectedTab === '{{ self::TAB_PHONE }}' }"`.

Пример для первой вкладки:
```blade
<li @click="setTab('{{ self::TAB_PHONE }}')" class="nav-item waves-effect waves-light" role="presentation">
    <a class="nav-link" :class="{ active: selectedTab === '{{ self::TAB_PHONE }}' }" data-bs-toggle="tab" role="tab">
        <i class="icofont-phone-circle d-block"></i>
        {{ __('views.actions_wrapper.tab_phones') }}</a>
</li>
```

Также в `main-actions.blade.php` панели списка обернуть содержимое вкладок в `x-show` по `selectedTab` вместо `@elseif($selectedTab === ...)`.

- [ ] **Step 2: phones.blade — клики гестии/звонка/SMS на Alpine**

В `resources/views/livewire/client/phones.blade.php` заменить:
- `wire:click="setAction(2, {{$phone->id}})"` → `@click="setAction(2, {{ $phone->id }})"` (входящий звонок)
- `wire:click="setAction(1, {{$phone->id}})"` → `@click="setAction(1, {{ $phone->id }})"` (исходящий)
- `wire:click="setAction(7, {{$phone->id}})"` → `@click="setAction(7, {{ $phone->id }})"`
- `wire:click="setAction(8, {{$phone->id}})"` → `@click="setAction(8, {{ $phone->id }})"`
- `wire:click="setAction(3, {{$phone->id}})"` → `@click="setAction(3, {{ $phone->id }})"` (расписание; ветка `actionId===3` остаётся серверной для самого сохранения, но открытие формы — клиентское)
- `wire:click="setAction(6, {{$phone->id}})"` (SMS) → **оставить решение**: SMS-форма на Livewire. Чтобы открыть её, нужен серверный `setAction`. Заменить на комбинированный: `@click="setAction(6, {{ $phone->id }}); $wire.setAction(6, {{ $phone->id }}, false)"` — Alpine переключает `view`, Livewire подгружает данные SMS-формы. (SMS вне охвата — допускаем 1 round-trip здесь.)

> `togglePhoneStatus` и звонки (`doCallPeru/doCallColombia`) — без изменений.

- [ ] **Step 3: emails.blade — гестия по email на Alpine, отправка остаётся на Livewire**

В `resources/views/livewire/client/emails.blade.php`:
- Кнопка «Enviar Email» (share-square, `wire:click="setEmailAction(5, {{$email->id}}, true)"`) — **оставить** `wire:click` (форма отправки на Livewire), но добавить Alpine-переключение вида: `@click="view = 'form'; showEmailSendFormFlag = true; actionId = 5; emailId = {{ $email->id }}"`.
- Дропдаун-пункты «Email Saliente» (`setEmailAction(5, {{$email->id}})`) и «Email Entrante» (`setEmailAction(12, {{$email->id}})`) — это **гестия по email** (в охвате). Заменить на `@click="setAction(5, null, {{ $email->id }})"` и `@click="setAction(12, null, {{ $email->id }})"` соответственно.
- `toggleEmailStatus` — без изменений.

- [ ] **Step 4: main-actions side-actions (вкладка «прочее») — на Alpine**

В `main-actions.blade.php` строка 23 заменить `wire:click="setAction({{$action->id}}, null, true)"` на `@click="setAction({{ $action->id }}, null, null); showDefaultFormFlag = true"`.

- [ ] **Step 5: Полная ручная проверка (чек-лист)**

Run: `docker exec -i polaris-service-new php artisan view:clear`
Затем в браузере (Network открыт), карточка клиента → Gestiones:

1. ☐ «Добавить гестию» (исходящий звонок) → форма открывается **без XHR**.
2. ☐ Contacto → Resultado → value/date — переключения **без XHR**; «Guardar» — ровно 1 XHR.
3. ☐ Группа 1 (Titular): показываются Motivo+Estrategia; при выборе результата срабатывает Normatividad-модалка / `setChannels` как раньше.
4. ☐ Группа ≠ 1: блок Motivo/Estrategia скрыт, результаты доступны сразу.
5. ☐ Сохранение валидной гестии → Swal «успех», список обновился, форма закрылась.
6. ☐ Невалидная (пустой результат / группа 1 без motivo) → ошибки под полями (из `showValidationErrors`).
7. ☐ Переключение вкладок Phones/Emails/Otros/Schedule — **без XHR**.
8. ☐ Счётчик символов SMS (если включён в форме) — без XHR на ввод (если переносился; иначе SMS-форма как раньше).
9. ☐ Email-гестия (дропдаун «Email Saliente/Entrante») сохраняется; «Enviar Email» (форма отправки) открывается как раньше.
10. ☐ Расписание звонка (календарь) сохраняется как раньше.

Expected: все пункты выполняются; промежуточных XHR в каскаде нет.

---

## Task 8: Финализация — pint, тесты, очистка

**Files:**
- Modify: любые затронутые (форматирование)

- [ ] **Step 1: Pint на изменённых файлах**

Run: `docker exec -i polaris-service-new vendor/bin/pint --dirty`
Expected: все файлы отформатированы, без ошибок.

- [ ] **Step 2: Прогнать юнит-тесты**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=MainActionsComponentTest`
Expected: все зелёные (DB-тест может быть SKIPPED).

- [ ] **Step 3: Прогнать весь набор (по согласованию)**

Run: `docker exec -i polaris-service-new php artisan test --compact`
Expected: без новых падений относительно базовой ветки.

- [ ] **Step 4: Проверить отсутствие мёртвых ссылок**

Run: `grep -rn "wire:model.live=\"contactId\"\|wire:model.live=\"resultId\"\|wire:model.live=\"nonPaymentReasonId\"\|wire:model.live=\"strategyId\"" resources/views`
Expected: совпадений нет (каскад полностью на Alpine).

- [ ] **Step 5: Остановиться на ревью**

Сообщить пользователю: изменения готовы к ревью; коммит — за пользователем (per project convention).

---

## Self-Review

**Spec coverage:**
- Контракт `gestionData()` → Task 1 ✓
- Клиентский каскад (contacts/results/group/value/date/preform) → Tasks 5, 6 ✓
- `setAction` полностью на клиент → Tasks 6, 7 ✓
- Терминальный `save(payload)` + сохранение серверной бизнес-логики → Task 4 ✓
- Валидация (server source of truth + client `canSubmit`) → Tasks 2, 5, 6 ✓
- Normatividad/`setChannels` побочный эффект → Task 6 (`onResultChange`), Task 4 (метод оставлен) ✓
- flatpickr/таймер → Task 6 ✓
- Вкладки + счётчик SMS бонусы → Tasks 6, 7 ✓
- Email-гестия (action 5/12) в охвате, отправка email/SMS вне → Tasks 3, 4, 7 ✓
- Тесты на save (DTO/валидация/preform) + ручная → Tasks 1-3, 7 ✓

**Placeholder scan:** код приведён полностью в каждом шаге; «оставить как есть» относится к существующему, неизменяемому коду (вне охвата) — это не плейсхолдер, а явное указание не трогать.

**Type consistency:** `gestionData()` ключи (`graph/actions/contacts/results/reasons/strategies/phones/emails/clientPopup/hideChannels/defaultChannels`) совпадают в Task 1 (PHP), Task 6 (Alpine), Task 3 (`$dict` подмножество). Сигнатуры `validationErrorsFor(array): array`, `buildPreformText(array, array, string): string`, `buildCommunicationDto(array, ?string, string, ?string, string): CommunicationDTO` идентичны в определении (Tasks 2-3) и использовании (Task 4). Payload-ключи в Alpine `submit()` (Task 6) ⊇ ключи, читаемые в `rules()`/`buildDtoFromPayload()` (Tasks 2, 4). `setChannels` остаётся публичным (Task 4 Step 2) и вызывается в Alpine (Task 6).

**Открытый риск для исполнителя:** синхронизация Alpine-`view` с серверным `$actionId` для оставшихся на Livewire SMS/Email-форм (Task 6 Step 1 / Task 7 Steps 2-3). Если возникнут расхождения отображения SMS/Email-форм — допустимо оставить эти конкретные кнопки полностью на старом серверном пути (`@if($actionId)` для их веток), т.к. они вне охвата; форма гестии при этом остаётся клиентской.