# Client Gestión Block: Livewire → Blade + AJAX 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:** Replace the hybrid Livewire+Alpine `MainActionsComponent` (client "Gestión" tab) with plain server-rendered Blade + a vanilla-JS controller talking to JSON AJAX endpoints, preserving behavior exactly.

**Architecture:** Move `mount()` data-loading into a read presenter and all write/action methods into a service. A single thin `ClientActionsController` exposes the actions as `POST admin/client/{clientId}/actions/{verb}` JSON endpoints. A single `resources/js/client-actions.js` module reproduces the Alpine form-flow client-side and calls the endpoints via `fetch`. Sibling Livewire components (`HeaderComponent`, `CommunicationsComponent`) stay and are refreshed via `Livewire.dispatch()` from JS.

**Tech Stack:** PHP 8.3 · Laravel 10 · Livewire 3 (siblings only) · Vite · Bootstrap 5 · SweetAlert2 · flatpickr · IMask · PHPUnit 10.

## Global Constraints

- Run all Artisan/Composer/PHPUnit/Pint inside the container: `docker exec -i polaris-service-new <cmd>`.
- Run `docker exec -i polaris-service-new vendor/bin/pint --dirty` before every commit; never `pint --test`.
- Curly braces on all control structures; explicit return types; PHP 8 constructor property promotion; PHPDoc over inline comments.
- No `env()` outside `config/`; read via `config('polaris.…')`.
- No `DB::` raw queries; use Eloquent `Model::query()` on the correct connection.
- Validation via Form Request classes with **array-based** rules (repo convention), placed under `App\Admin\Requests\Client`.
- Create files with `docker exec -i polaris-service-new php artisan make:* --no-interaction`.
- Livewire 3 namespace is `App\Livewire`; dispatch from JS with `Livewire.dispatch('event', {..})`.
- Instance-conditional: keep existing `isPeru()`/`isColombia()`/`isMexico()` branches intact when moving code.
- CSRF: send header `X-CSRF-TOKEN` from `<meta name="csrf-token">` (already in `resources/views/layouts/admin.blade.php`).
- Frontend build: `docker exec -i polaris-service-new npm run build` (or user runs `npm run dev`).

## Behavior parity reference (do not change semantics)

Source of truth for moved logic: `app/Livewire/Client/MainActionsComponent.php` and `resources/views/livewire/main-actions.blade.php`. Action-id meanings used across the UI:
- `1` outgoing call, `2` incoming call, `3` schedule call, `5` email outgoing, `6` SMS, `7`/`8` WhatsApp, `12` email incoming.
- Email actions = `in_array(actionId, [5,12], true)`.
- Side actions = `actions.id > 5 && id != 7 && id != 8`.
- `contactGroupId == 1` ⇒ reason + strategy required; result flags `valor`/`fecha` ⇒ show value/date inputs.

## File Structure

**Backend (create):**
- `app/Services/Client/ClientGestionPresenter.php` — builds initial view data + `gestionData()` payload (moved from `mount()`/`gestionData()`).
- `app/Services/Client/ClientActionsService.php` — write/action logic moved from the component (communication save, schedule, SMS, email, phone/email CRUD, channels), returning plain result arrays / throwing, no `dispatch()`.
- `app/Admin/Controllers/Client/ClientActionsController.php` — thin controller, one method per endpoint, returns `response()->json(...)`.
- `app/Admin/Requests/Client/SaveCommunicationRequest.php`
- `app/Admin/Requests/Client/ScheduleCallRequest.php`
- `app/Admin/Requests/Client/InitWebitelCallRequest.php`
- `app/Admin/Requests/Client/SendSmsRequest.php`
- `app/Admin/Requests/Client/SmsTextRequest.php`
- `app/Admin/Requests/Client/SendEmailRequest.php`
- `app/Admin/Requests/Client/EmailTemplateFieldsRequest.php`
- `app/Admin/Requests/Client/EmailPreviewRequest.php`
- `app/Admin/Requests/Client/AddClientEmailRequest.php`
- `app/Admin/Requests/Client/TogglePhoneStatusRequest.php`
- `app/Admin/Requests/Client/ToggleEmailStatusRequest.php`
- `app/Admin/Requests/Client/SetChannelsRequest.php`
- (reuse existing `app/Admin/Requests/Client/AddClientPhoneRequest.php`)

**Frontend (create):**
- `resources/views/admin/client/main-actions.blade.php` — root partial (list/form shell + JSON payload + JS mount hook).
- `resources/views/admin/client/actions-wrapper.blade.php`
- `resources/views/admin/client/phones.blade.php`
- `resources/views/admin/client/emails.blade.php`
- `resources/views/admin/client/gestion-form.blade.php`
- `resources/views/admin/client/sms-form.blade.php`
- `resources/views/admin/client/email-form.blade.php`
- `resources/views/admin/client/schedule.blade.php`
- `resources/views/admin/client/modal/add-phone-form.blade.php`
- `resources/views/admin/client/modal/add-email-form.blade.php`
- `resources/views/admin/client/normatividad.blade.php`
- `resources/js/client-actions.js`

**Modify:**
- `app/Admin/routes.php` — add `{clientId}/actions/*` route group.
- `app/Admin/Controllers/Client/ClientController.php` — build + pass gestión view data in `show()`.
- `resources/views/admin/client/show.blade.php` — `@livewire(MainActionsComponent)` → `@include('admin.client.main-actions', …)`.
- `resources/js/app.js` (or the Vite entry that admin uses) — import `client-actions.js`.
- `vite.config.js` — add entry if a dedicated bundle is preferred (else import from existing entry).

**Delete (final cutover only):**
- `app/Livewire/Client/MainActionsComponent.php`
- `resources/views/livewire/main-actions.blade.php`
- `resources/views/livewire/actions-wrapper.blade.php`
- `resources/views/livewire/client/phones.blade.php`
- `resources/views/livewire/client/emails.blade.php`
- `resources/views/livewire/client/gestion-form.blade.php`
- `resources/views/livewire/client/modal/add-phone-form.blade.php`
- `resources/views/livewire/client/modal/add-email-form.blade.php`
- `resources/views/livewire/normatividad.blade.php`

---

## Task 1: Route group + controller skeleton + test harness

**Files:**
- Create: `app/Admin/Controllers/Client/ClientActionsController.php`
- Modify: `app/Admin/routes.php` (client group, ~lines 208-212)
- Test: `tests/Feature/Admin/Client/ClientActionsRoutingTest.php`

**Interfaces:**
- Produces: routes named `admin-client.actions.*`; controller class `App\Admin\Controllers\Client\ClientActionsController` with a `ping(int $clientId)` method returning `{ ok: true }` (temporary, removed in Task 12 once real endpoints exist).

- [ ] **Step 1: Create the controller skeleton**

Run: `docker exec -i polaris-service-new php artisan make:class Admin/Controllers/Client/ClientActionsController --no-interaction`

Then set contents:

```php
<?php

namespace App\Admin\Controllers\Client;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;

class ClientActionsController extends Controller
{
    public function ping(int $clientId): JsonResponse
    {
        return response()->json(['ok' => true, 'clientId' => $clientId]);
    }
}
```

- [ ] **Step 2: Add the route group**

In `app/Admin/routes.php`, replace the existing client group:

```php
    Route::prefix('client')
        ->name('admin-client')
        ->group(function () {
            Route::get('{clientId}', [\App\Admin\Controllers\Client\ClientController::class, 'show']);
        });
```

with:

```php
    Route::prefix('client')
        ->name('admin-client')
        ->group(function () {
            Route::get('{clientId}', [\App\Admin\Controllers\Client\ClientController::class, 'show']);

            Route::prefix('{clientId}/actions')
                ->name('.actions.')
                ->controller(\App\Admin\Controllers\Client\ClientActionsController::class)
                ->group(function () {
                    Route::post('ping', 'ping')->name('ping');
                });
        });
```

- [ ] **Step 3: Write the routing test**

Run: `docker exec -i polaris-service-new php artisan make:test Admin/Client/ClientActionsRoutingTest --no-interaction`

```php
<?php

namespace Tests\Feature\Admin\Client;

use Tests\TestCase;

class ClientActionsRoutingTest extends TestCase
{
    public function test_ping_route_is_registered(): void
    {
        $url = route('admin.admin-client.actions.ping', ['clientId' => 1]);

        $this->assertStringContainsString('/client/1/actions/ping', $url);
    }
}
```

Note: the route-name prefix is `config('admin.route.prefix') . '.'` (the outer group's `as`). Confirm the exact registered name first:

Run: `docker exec -i polaris-service-new php artisan route:list --path=client/1/actions --json`

Adjust the `route()` name in the test to the value shown under `name` if it differs from `admin.admin-client.actions.ping`.

- [ ] **Step 4: Run the test**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=ClientActionsRoutingTest`
Expected: PASS.

- [ ] **Step 5: Pint + commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add app/Admin/Controllers/Client/ClientActionsController.php app/Admin/routes.php tests/Feature/Admin/Client/ClientActionsRoutingTest.php
git commit -m "POL-352 Add client actions route group + controller skeleton

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 2: Shared test base for authenticated admin + client fixture

**Files:**
- Test: `tests/Feature/Admin/Client/ClientActionsTestCase.php`

**Interfaces:**
- Produces: `ClientActionsTestCase extends TestCase` with:
  - `protected User $actingAdmin;`
  - `protected Client $client;`
  - `protected function actingAsAdmin(): static` — logs in on the `admin` guard.
  - `protected function actionUrl(string $verb): string` — builds `/admin/client/{client}/actions/{verb}` using `$this->client`.

- [ ] **Step 1: Inspect how existing tests authenticate (if any) and how the admin guard resolves**

Run: `docker exec -i polaris-service-new php artisan tinker --execute="echo config('auth.defaults.guard'); echo PHP_EOL; echo config('admin.auth.guard') ?? 'n/a';"`

Record the admin guard name (used below as `'admin'`; change if the output differs).

- [ ] **Step 2: Write the base test case**

Create `tests/Feature/Admin/Client/ClientActionsTestCase.php`:

```php
<?php

namespace Tests\Feature\Admin\Client;

use App\Models\Collection\Client\Client;
use App\Models\User\User;
use Tests\TestCase;

abstract class ClientActionsTestCase extends TestCase
{
    protected User $actingAdmin;

    protected Client $client;

    protected function setUp(): void
    {
        parent::setUp();

        $this->actingAdmin = User::query()->firstOrFail();
        $this->client = Client::query()->firstOrFail();
    }

    protected function actingAsAdmin(): static
    {
        $this->actingAs($this->actingAdmin, 'admin');

        return $this;
    }

    protected function actionUrl(string $verb): string
    {
        return "/admin/client/{$this->client->id}/actions/{$verb}";
    }
}
```

Rationale: the app has no factories/seeders for these external-schema models and DB is not sqlite-friendly; tests run against the configured Postgres connections and use existing rows. If the suite is configured with a dedicated test DB later, swap `firstOrFail()` for factory calls. Confirm at least one `User` and `Client` row exist:

Run: `docker exec -i polaris-service-new php artisan tinker --execute="echo App\Models\User\User::query()->count(); echo '|'; echo App\Models\Collection\Client\Client::query()->count();"`

Expected: two non-zero numbers. If zero, stop and ask the user how tests should seed data.

- [ ] **Step 3: Sanity test through the base case**

Append to `ClientActionsRoutingTest` a second test that extends behavior is not required; instead add a smoke test file `tests/Feature/Admin/Client/ClientActionsPingTest.php`:

```php
<?php

namespace Tests\Feature\Admin\Client;

class ClientActionsPingTest extends ClientActionsTestCase
{
    public function test_ping_requires_auth_and_returns_ok(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('ping'))
            ->assertOk()
            ->assertJson(['ok' => true]);
    }
}
```

- [ ] **Step 4: Run it**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=ClientActionsPingTest`
Expected: PASS. If it fails on middleware (`admin.panel`/`admin.user.rights`), record the failure and, if it is an authorization redirect, adjust `actingAsAdmin()` to also set any required session/permission state the middleware checks (inspect `app/Http/Middleware` for the `admin.user.rights` class). Do not weaken the middleware.

- [ ] **Step 5: Commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add tests/Feature/Admin/Client/ClientActionsTestCase.php tests/Feature/Admin/Client/ClientActionsPingTest.php
git commit -m "POL-352 Add authenticated admin test base for client actions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 3: `ClientGestionPresenter` — move `mount()` + `gestionData()`

**Files:**
- Create: `app/Services/Client/ClientGestionPresenter.php`
- Test: `tests/Feature/Admin/Client/ClientGestionPresenterTest.php`

**Interfaces:**
- Produces:
  - `ClientGestionPresenter::__construct(ReadPhonesRepository, ReadEmailsRepository, ReadCallScheduleRepository, ReadLoansRepository)`
  - `public function build(Client $client, User $user): array` returning keys:
    `phones` (Collection), `emails` (Collection), `callSchedules` (Collection), `currentLoan` (?Loan), `actions` (Collection), `nonPaymentReasons` (array), `phoneRelations` (Collection keyed by alias), `strategies` (array), `smsCompanies` (Collection), `programmedActions` (Collection), `programmedActionId` (int|string), `isCloseCommunication` (bool), `userId` (int), `webitelId` (?string), `gestionData` (array).
  - `public function gestionData(Client $client, Collection $phones, Collection $emails, array $nonPaymentReasons, array $strategies, Collection $actions): array` — same shape the component's `gestionData()` returned.

- [ ] **Step 1: Write the failing test**

Run: `docker exec -i polaris-service-new php artisan make:test Admin/Client/ClientGestionPresenterTest --no-interaction`

```php
<?php

namespace Tests\Feature\Admin\Client;

use App\Models\Collection\Client\Client;
use App\Models\User\User;
use App\Services\Client\ClientGestionPresenter;
use Tests\TestCase;

class ClientGestionPresenterTest extends TestCase
{
    public function test_build_returns_expected_keys(): void
    {
        $client = Client::query()->firstOrFail();
        $user = User::query()->firstOrFail();

        $data = app(ClientGestionPresenter::class)->build($client, $user);

        foreach ([
            'phones', 'emails', 'callSchedules', 'currentLoan', 'actions',
            'nonPaymentReasons', 'phoneRelations', 'strategies', 'smsCompanies',
            'programmedActions', 'programmedActionId', 'isCloseCommunication',
            'userId', 'webitelId', 'gestionData',
        ] as $key) {
            $this->assertArrayHasKey($key, $data);
        }

        foreach (['graph', 'actions', 'contacts', 'results', 'reasons', 'strategies', 'phones', 'emails', 'clientPopup', 'hideChannels', 'defaultChannels'] as $key) {
            $this->assertArrayHasKey($key, $data['gestionData']);
        }
    }
}
```

- [ ] **Step 2: Run to confirm it fails**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=ClientGestionPresenterTest`
Expected: FAIL — class `ClientGestionPresenter` not found.

- [ ] **Step 3: Create the presenter**

Run: `docker exec -i polaris-service-new php artisan make:class Services/Client/ClientGestionPresenter --no-interaction`

Contents (move the bodies of `MainActionsComponent::mount()` lines 219-260 and `gestionData()` lines 285-343 verbatim, adapting `$this->client`→`$client`, `$user`→param, removing the assignments to `$this->…` in favor of local vars, and returning an array):

```php
<?php

namespace App\Services\Client;

use App\Helpers\CasaHelper;
use App\Models\Collection\Client\Client;
use App\Models\Collection\CodeRelation;
use App\Models\Collection\Dictionary\Action;
use App\Models\Collection\Dictionary\Contact;
use App\Models\Collection\Dictionary\NonPaymentReason;
use App\Models\Collection\Dictionary\PhoneRelation;
use App\Models\Collection\Dictionary\Result;
use App\Models\Collection\Dictionary\Strategy;
use App\Models\Collection\ProgrammedActionType;
use App\Models\Sms\Company;
use App\Models\User\User;
use App\Repositories\Collection\ReadCallScheduleRepository;
use App\Repositories\Collection\ReadEmailsRepository;
use App\Repositories\Collection\ReadLoansRepository;
use App\Repositories\Collection\ReadPhonesRepository;
use Illuminate\Support\Collection;

class ClientGestionPresenter
{
    public function __construct(
        private readonly ReadPhonesRepository $readPhonesRepository,
        private readonly ReadEmailsRepository $readEmailsRepository,
        private readonly ReadCallScheduleRepository $readCallScheduleRepository,
        private readonly ReadLoansRepository $readLoansRepository,
    ) {
    }

    public function build(Client $client, User $user): array
    {
        $phones = $this->readPhonesRepository->getPhonesByDocument($client->documento);
        $emails = $this->readEmailsRepository->getEmailsByDocument($client->documento);
        $callSchedules = $this->readCallScheduleRepository->getSchedulesByDocument($client->documento);
        $currentLoan = $this->readLoansRepository->getCurrentLoan($client->documento)
            ?? $this->readLoansRepository->getLatestLoan($client->documento);

        $isCloseCommunication = ! $this->readLoansRepository->isClientHaveLoansWithCasa($client->documento, $user->idcasa)
            && ! CasaHelper::hasFullAccess($user)
            && ! CasaHelper::isExcludedCasa($user->house->casa);

        $actions = Action::listCached();
        $nonPaymentReasons = NonPaymentReason::listCached()->where('novacion', 0)->toArray();
        $phoneRelations = PhoneRelation::listCached()->keyBy('alias');
        $strategies = Strategy::listCached()->toArray();
        $smsCompanies = Company::listCached();
        $programmedActions = ProgrammedActionType::listCached()->where('is_active', true);

        return [
            'phones' => $phones,
            'emails' => $emails,
            'callSchedules' => $callSchedules,
            'currentLoan' => $currentLoan,
            'actions' => $actions,
            'nonPaymentReasons' => $nonPaymentReasons,
            'phoneRelations' => $phoneRelations,
            'strategies' => $strategies,
            'smsCompanies' => $smsCompanies,
            'programmedActions' => $programmedActions,
            'programmedActionId' => $programmedActions->first()->id,
            'isCloseCommunication' => $isCloseCommunication,
            'userId' => $user->getAuthIdentifier(),
            'webitelId' => $user->webitel_id,
            'gestionData' => $this->gestionData($client, $phones, $emails, $nonPaymentReasons, $strategies, $actions),
        ];
    }

    public function gestionData(
        Client $client,
        Collection $phones,
        Collection $emails,
        array $nonPaymentReasons,
        array $strategies,
        Collection $actions,
    ): 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();

        $actionsMap = $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($nonPaymentReasons)
            ->map(fn (array $reason): array => ['id' => $reason['id'], 'descripcion' => $reason['descripcion']])
            ->values()
            ->all();

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

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

- [ ] **Step 4: Run the test**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=ClientGestionPresenterTest`
Expected: PASS.

- [ ] **Step 5: Pint + commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add app/Services/Client/ClientGestionPresenter.php tests/Feature/Admin/Client/ClientGestionPresenterTest.php
git commit -m "POL-352 Add ClientGestionPresenter (moves mount + gestionData)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 4: `ClientActionsService` — communication save path

**Files:**
- Create: `app/Services/Client/ClientActionsService.php`
- Create: `app/Admin/Requests/Client/SaveCommunicationRequest.php`
- Test: `tests/Feature/Admin/Client/SaveCommunicationTest.php`

**Interfaces:**
- Consumes: `ClientGestionPresenter::gestionData(...)` (Task 3).
- Produces:
  - `ClientActionsService::__construct(ClientGestionPresenter $presenter)`
  - `public function saveCommunication(Client $client, User $user, array $payload): void` — throws on service failure; assumes validation already done by the Form Request.
  - `SaveCommunicationRequest` with the exact rules/messages copied from `MainActionsComponent::rules()`/`messages()` (lines 164-199), keyed on the JSON payload field names below.

Payload field names (snapshotted from the Alpine `submit()` in `main-actions.blade.php` lines 492-508): `actionId, phoneId, emailId, contactId, contactGroupId, nonPaymentReasonId, strategyId, resultId, value, date, comment, time, externalCommunicationId, showValueInput, showDateInput`.

- [ ] **Step 1: Write the Form Request**

Run: `docker exec -i polaris-service-new php artisan make:request Admin/Requests/Client/SaveCommunicationRequest --no-interaction`

(If `make:request` writes under `app/Http/Requests`, move the file to `app/Admin/Requests/Client/` and set namespace `App\Admin\Requests\Client`.)

```php
<?php

namespace App\Admin\Requests\Client;

use Carbon\Carbon;
use Illuminate\Foundation\Http\FormRequest;

class SaveCommunicationRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    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.'),
        ];
    }
}
```

- [ ] **Step 2: Create the service with the save path**

Run: `docker exec -i polaris-service-new php artisan make:class Services/Client/ClientActionsService --no-interaction`

Move `buildDtoFromPayload()` (424-444), `buildCommunicationDto()` (867-893) and `buildPreformText()` (837-865) from the component into the service, replacing `$this->userId`→`$user->getAuthIdentifier()`, `$this->client`→`$client`, `$this->phones`/`$this->emails`/`$this->strategies` with values pulled from the presenter's data, and `$this->gestionData()`→`$this->presenter->gestionData(...)`:

```php
<?php

namespace App\Services\Client;

use App\Http\Requests\Promise\DTO\CommunicationDTO;
use App\Http\Requests\Promise\DTO\CommunicationMetadataDTO;
use App\Models\Collection\Client\Client;
use App\Models\User\User;
use App\Services\Communication\CommunicationService;
use Carbon\Carbon;

class ClientActionsService
{
    public function __construct(
        private readonly ClientGestionPresenter $presenter,
    ) {
    }

    public function saveCommunication(Client $client, User $user, array $payload): void
    {
        CommunicationService::saveCommunication($this->buildDtoFromPayload($client, $user, $payload));
    }

    private function buildDtoFromPayload(Client $client, User $user, array $payload): CommunicationDTO
    {
        $data = $this->presenter->build($client, $user);
        $phones = $data['phones'];
        $emails = $data['emails'];
        $strategies = $data['strategies'];
        $gestionData = $data['gestionData'];

        $isEmail = in_array($payload['actionId'] ?? null, [5, 12], true);

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

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

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

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

        return $dto;
    }

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

        return new CommunicationDTO(
            document: $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: $user->getAuthIdentifier(),
            time: $payload['time'] ?? '00:00:01',
            communicationDate: now(),
            externalCommunicationId: $payload['externalCommunicationId'] ?? null,
            email: $email,
        );
    }

    private 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;
    }
}
```

- [ ] **Step 3: Wire the controller endpoint**

In `ClientActionsController` add (and add the `saveCommunication` route in Task 12's consolidated block, but for now add it here to test):

In `app/Admin/routes.php` actions group, add:
```php
                    Route::post('save-communication', 'saveCommunication')->name('save-communication');
```

In the controller:
```php
    public function saveCommunication(
        int $clientId,
        \App\Admin\Requests\Client\SaveCommunicationRequest $request,
        \App\Repositories\Collection\ReadClientRepository $clients,
        \App\Services\Client\ClientActionsService $service,
    ): JsonResponse {
        $client = $clients->getClientById($clientId);
        $user = auth('admin')->user();

        $service->saveCommunication($client, $user, $request->all());

        return response()->json(['ok' => true]);
    }
```

Add the `catch` for service failures at the controller layer via a shared helper in Task 12; for now let framework 422 handle validation and let exceptions bubble (the test below only checks happy + validation paths).

- [ ] **Step 4: Write the feature test**

```php
<?php

namespace Tests\Feature\Admin\Client;

class SaveCommunicationTest extends ClientActionsTestCase
{
    public function test_missing_result_returns_422(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('save-communication'), [
                'actionId' => 1,
                'contactGroupId' => 2,
            ])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['resultId']);
    }

    public function test_reason_and_strategy_required_when_contact_group_is_1(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('save-communication'), [
                'actionId' => 1,
                'contactGroupId' => 1,
                'resultId' => 1,
            ])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['nonPaymentReasonId', 'strategyId']);
    }

    public function test_past_date_is_rejected_when_date_input_shown(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('save-communication'), [
                'actionId' => 1,
                'contactGroupId' => 2,
                'resultId' => 1,
                'showDateInput' => true,
                'date' => '2000-01-01',
            ])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['date']);
    }
}
```

- [ ] **Step 5: Run + fix until green**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=SaveCommunicationTest`
Expected: PASS. (Validation happens before the service touches the DB, so no real communication is written by these cases.)

- [ ] **Step 6: Pint + commit**

```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add app/Services/Client/ClientActionsService.php app/Admin/Requests/Client/SaveCommunicationRequest.php app/Admin/Controllers/Client/ClientActionsController.php app/Admin/routes.php tests/Feature/Admin/Client/SaveCommunicationTest.php
git commit -m "POL-352 Add saveCommunication endpoint + validation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 5: Schedule-call endpoint

**Files:**
- Modify: `app/Services/Client/ClientActionsService.php`, `app/Admin/Controllers/Client/ClientActionsController.php`, `app/Admin/routes.php`
- Create: `app/Admin/Requests/Client/ScheduleCallRequest.php`
- Test: `tests/Feature/Admin/Client/ScheduleCallTest.php`

**Interfaces:**
- Produces: `ClientActionsService::scheduleCall(Client $client, User $user, string $date, int|string $programmedActionId): void` — throws `RuntimeException` on duplicate/save failure (caller maps to the existing Spanish error message).

- [ ] **Step 1: Form Request**

Create `app/Admin/Requests/Client/ScheduleCallRequest.php`:
```php
<?php

namespace App\Admin\Requests\Client;

use Illuminate\Foundation\Http\FormRequest;

class ScheduleCallRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'callScheduleDate' => ['required', 'string'],
            'programmedActionId' => ['required'],
        ];
    }

    public function messages(): array
    {
        return [
            'callScheduleDate.required' => 'Seleccione una fecha y hora para programar la llamada',
        ];
    }
}
```

- [ ] **Step 2: Service method** (move `saveCallSchedule()` body, 446-475, dropping the `dispatch`/collection-push, throwing on failure):
```php
    public function scheduleCall(\App\Models\Collection\Client\Client $client, \App\Models\User\User $user, string $date, int|string $programmedActionId): void
    {
        $callSchedule = new \App\Models\Collection\CallSchedule();
        $callSchedule->estado = 0;
        $callSchedule->documento = $client->documento;
        $callSchedule->fecha = $date;
        $callSchedule->idasesor = $user->getAuthIdentifier();
        $callSchedule->programmed_action_id = $programmedActionId;

        try {
            $callSchedule->saveOrFail();
        } catch (\Throwable $exception) {
            \Illuminate\Support\Facades\Log::channel('sentry')->error($exception);

            throw new \RuntimeException('Ya se ha programado una llamada para esta fecha y hora. Seleccione otra fecha y hora para programar una nueva llamada');
        }
    }
```

- [ ] **Step 3: Controller endpoint + route**

Route: `Route::post('schedule-call', 'scheduleCall')->name('schedule-call');`

```php
    public function scheduleCall(
        int $clientId,
        \App\Admin\Requests\Client\ScheduleCallRequest $request,
        \App\Repositories\Collection\ReadClientRepository $clients,
        \App\Services\Client\ClientActionsService $service,
    ): JsonResponse {
        $client = $clients->getClientById($clientId);
        $user = auth('admin')->user();

        try {
            $service->scheduleCall($client, $user, $request->input('callScheduleDate'), $request->input('programmedActionId'));
        } catch (\Throwable $e) {
            return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
        }

        return response()->json(['ok' => true, 'message' => 'Gestión guardada']);
    }
```

- [ ] **Step 4: Test**
```php
<?php

namespace Tests\Feature\Admin\Client;

class ScheduleCallTest extends ClientActionsTestCase
{
    public function test_empty_date_is_rejected(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('schedule-call'), ['programmedActionId' => 1])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['callScheduleDate']);
    }
}
```

- [ ] **Step 5: Run**

Run: `docker exec -i polaris-service-new php artisan test --compact --filter=ScheduleCallTest`
Expected: PASS.

- [ ] **Step 6: Pint + commit**
```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add app/Services/Client/ClientActionsService.php app/Admin/Requests/Client/ScheduleCallRequest.php app/Admin/Controllers/Client/ClientActionsController.php app/Admin/routes.php tests/Feature/Admin/Client/ScheduleCallTest.php
git commit -m "POL-352 Add schedule-call endpoint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 6: Webitel call endpoint

**Files:**
- Modify: service, controller, routes
- Create: `app/Admin/Requests/Client/InitWebitelCallRequest.php`
- Test: `tests/Feature/Admin/Client/InitWebitelCallTest.php`

**Interfaces:**
- Produces: `ClientActionsService::initWebitelCall(Client $client, User $user, int $phoneId): string` — returns `externalCommunicationId`; throws on Webitel failure with the provider's message.

- [ ] **Step 1: Form Request** — rule `['phoneId' => ['required', 'integer']]`, `authorize()` true.

- [ ] **Step 2: Service** (move `initWebitelCall()` 477-500, minus `dispatch`/`setAction`; pull the phone from the presenter's `phones`):
```php
    public function initWebitelCall(\App\Models\Collection\Client\Client $client, \App\Models\User\User $user, int $phoneId): string
    {
        $phones = $this->presenter->build($client, $user)['phones'];
        $phone = $phones->firstWhere('id', $phoneId);

        /** @var \App\Integrations\Webitel\Services\WebitelCallService $webitelService */
        $webitelService = resolve(\App\Integrations\Webitel\Services\WebitelCallService::class);

        return $webitelService->call($client, \App\Models\User\User::findCached($user->getAuthIdentifier()), $phone->telefono);
    }
```

- [ ] **Step 3: Controller + route** `Route::post('init-webitel-call', 'initWebitelCall')->name('init-webitel-call');`
```php
    public function initWebitelCall(
        int $clientId,
        \App\Admin\Requests\Client\InitWebitelCallRequest $request,
        \App\Repositories\Collection\ReadClientRepository $clients,
        \App\Services\Client\ClientActionsService $service,
    ): JsonResponse {
        $client = $clients->getClientById($clientId);
        $user = auth('admin')->user();

        try {
            $externalId = $service->initWebitelCall($client, $user, (int) $request->input('phoneId'));
        } catch (\Throwable $e) {
            return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
        }

        return response()->json([
            'ok' => true,
            'data' => ['externalCommunicationId' => $externalId, 'phoneId' => (int) $request->input('phoneId')],
        ]);
    }
```

- [ ] **Step 4: Test** — validation only (calling real Webitel is out of scope for the test):
```php
<?php

namespace Tests\Feature\Admin\Client;

class InitWebitelCallTest extends ClientActionsTestCase
{
    public function test_phone_id_required(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('init-webitel-call'), [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['phoneId']);
    }
}
```

- [ ] **Step 5: Run** — `--filter=InitWebitelCallTest` → PASS.
- [ ] **Step 6: Pint + commit** (`POL-352 Add init-webitel-call endpoint`).

---

## Task 7: SMS endpoints (`sms-text`, `send-sms`)

**Files:**
- Modify: service, controller, routes
- Create: `app/Admin/Requests/Client/SmsTextRequest.php`, `app/Admin/Requests/Client/SendSmsRequest.php`
- Test: `tests/Feature/Admin/Client/SmsEndpointsTest.php`

**Interfaces:**
- Produces:
  - `ClientActionsService::buildSmsText(Client $client, int $companyId, array $customOptions): array` — returns `['text' => string, 'options' => array<int,array{title:string,value:?string}>]`. Consolidates `updatedSendSmsCompanyId`/`updatedSendSmsCustomOptions`/`updateSendSmsText` (905-953).
  - `ClientActionsService::smsVariableValue(Client $client, ?Loan $currentLoan, string $variable): string` — wraps `SmsHelper::getVariableValue` (from `setSMSVariable`, 770-777).
  - `ClientActionsService::sendSms(Client $client, User $user, int $phoneId, ?int $companyId, string $text): array` — returns `['status' => string, 'bucketLink' => string, 'message' => string]`; throws on send failure. Moves `sendSMS()` (502-574) minus `dispatch`.

- [ ] **Step 1: `SmsTextRequest`** rules: `companyId` nullable integer, `customOptions` array; `SendSmsRequest` rules: `phoneId` required int, `companyId` nullable int, `text` required string.

- [ ] **Step 2: Service `buildSmsText`** (move `updateSendSmsText` logic; note the company message + placeholder extraction):
```php
    public function buildSmsText(\App\Models\Collection\Client\Client $client, int $companyId, array $customOptions): array
    {
        /** @var \App\Models\Sms\Company $company */
        $company = \App\Models\Sms\Company::listCached()->firstWhere('id', $companyId);

        if (is_null($company)) {
            return ['text' => '', 'options' => []];
        }

        if (empty($customOptions)) {
            preg_match_all('/<<(\s*opcion\d+\s*)>>/', $company->mensaje ?? '', $matches);
            $customOptions = array_map(static fn ($elem): array => ['title' => $elem, 'value' => null], $matches[1]);
        }

        /** @var \App\Services\CommunicationIntegrations\SMS\SmsService $smsService */
        $smsService = resolve(\App\Services\CommunicationIntegrations\SMS\SmsService::class);

        $text = $smsService->fillMessagePlaceholders(
            $company->mensaje ?? '',
            $client->documento,
            array_filter(array_column($customOptions, 'value')),
            true,
        );

        return ['text' => $text, 'options' => $customOptions];
    }
```

- [ ] **Step 3: Service `smsVariableValue`**:
```php
    public function smsVariableValue(\App\Models\Collection\Client\Client $client, ?\App\Models\Collection\Loan $currentLoan, string $variable): string
    {
        if (is_null($currentLoan)) {
            return '';
        }

        return \App\Services\CommunicationIntegrations\SMS\Helpers\SmsHelper::getVariableValue($variable, $client, $currentLoan);
    }
```

- [ ] **Step 4: Service `sendSms`** (move `sendSMS()`; replace each `dispatch(...)` with a returned status; keep the `Calendar::isTodayNonWorking()` guard as a thrown exception; keep the `openLink` bucket URL in the return):
```php
    public function sendSms(\App\Models\Collection\Client\Client $client, \App\Models\User\User $user, int $phoneId, ?int $companyId, string $text): array
    {
        if (\App\Models\Collection\Calendar::isTodayNonWorking()) {
            throw new \RuntimeException('No es posible realizar el envío. ¡En la configuración está establecido un día festivo!');
        }

        $phone = $this->presenter->build($client, $user)['phones']->firstWhere('id', $phoneId);

        /** @var \App\Services\CommunicationIntegrations\SMS\Providers\Abstract\AbstractSmsProvider $smsProvider */
        $smsProvider = resolve(\App\Services\CommunicationIntegrations\SMS\Providers\Abstract\AbstractSmsProvider::class);

        $bucket = \App\Models\Sms\Bucket::createInstance(
            $companyId,
            1,
            $user->getAuthIdentifier(),
            $smsProvider::getProviderName(),
            \App\Models\Sms\Bucket::INITIATOR_CUSTOM,
        );

        $sendSmsDTO = new \App\Services\CommunicationIntegrations\SMS\DTO\SendSmsDTO(
            $bucket->id,
            $companyId,
            $phone->telefono,
            $text,
            $client->documento,
            [],
            22,
            $user->getAuthIdentifier(),
            $companyId ? \App\Services\CommunicationIntegrations\SMS\DTO\SendSmsDTO::TYPE_COMPANY : \App\Services\CommunicationIntegrations\SMS\DTO\SendSmsDTO::TYPE_PERSONAL,
            \App\Http\Requests\Promise\DTO\CommunicationMetadataDTO::CHANNEL_UI,
        );

        /** @var \App\Services\CommunicationIntegrations\SMS\SmsService $smsService */
        $smsService = resolve(\App\Services\CommunicationIntegrations\SMS\SmsService::class);
        $bucketLink = config('app.url') . '/admin/sms/histories?bucket_id=' . $bucket->id;

        try {
            if ($smsProvider instanceof \App\Services\CommunicationIntegrations\SMS\Providers\TelepromSmsProvider) {
                $smsHistory = $smsService->sendTeleprom(collect()->push($sendSmsDTO))->first();
            } else {
                $smsHistory = $smsService->sendSingle($sendSmsDTO);
            }
        } catch (\Throwable $exception) {
            \Illuminate\Support\Facades\Log::channel('sentry')->error($exception);

            throw new \RuntimeException('El mensaje no se ha enviado. No se ha guardado ninguna gestión.', 0, $exception);
        }

        return [
            'status' => $smsHistory->status,
            'bucketLink' => $bucketLink,
            'waiting' => $smsHistory->status === \App\Models\Sms\SmsHistory::STATUS_WAITING,
            'failed' => $smsHistory->status === \App\Models\Sms\SmsHistory::STATUS_FAILED,
        ];
    }
```

- [ ] **Step 5: Controller endpoints + routes**

Routes:
```php
                    Route::post('sms-text', 'smsText')->name('sms-text');
                    Route::post('sms-variable', 'smsVariable')->name('sms-variable');
                    Route::post('send-sms', 'sendSms')->name('send-sms');
```

Controller `smsText`, `smsVariable`, `sendSms`: resolve `$client`, `$user`, call the service; map `waiting`/`failed`/success into `{ ok, message, data:{ bucketLink } }` mirroring the component's `showInfo`/`showError`/`saveSuccess` messages:
```php
    public function sendSms(
        int $clientId,
        \App\Admin\Requests\Client\SendSmsRequest $request,
        \App\Repositories\Collection\ReadClientRepository $clients,
        \App\Services\Client\ClientActionsService $service,
    ): JsonResponse {
        $client = $clients->getClientById($clientId);
        $user = auth('admin')->user();

        try {
            $result = $service->sendSms($client, $user, (int) $request->input('phoneId'), $request->input('companyId') ? (int) $request->input('companyId') : null, (string) $request->input('text'));
        } catch (\Throwable $e) {
            return response()->json(['ok' => false, 'message' => $e->getMessage()], 422);
        }

        if ($result['waiting']) {
            return response()->json(['ok' => true, 'status' => 'waiting', 'message' => 'Su mensaje está siendo procesado. El estado se actualizará cuando recibamos confirmación de la entrega. Tenga en cuenta que el registro de la gestión sólo se guardará cuando la entrega se haya realizado correctamente.', 'data' => ['bucketLink' => $result['bucketLink']]]);
        }

        if ($result['failed']) {
            return response()->json(['ok' => false, 'message' => 'El mensaje no se ha enviado. No se ha guardado ninguna gestión.', 'data' => ['bucketLink' => $result['bucketLink']]], 422);
        }

        return response()->json(['ok' => true, 'message' => 'El mensaje se ha enviado correctamente, la gestión se ha guardado.', 'data' => ['bucketLink' => $result['bucketLink']]]);
    }
```
`smsText` returns `{ ok:true, data: service.buildSmsText(...) }`; `smsVariable` returns `{ ok:true, data:{ value: service.smsVariableValue(...) } }` (resolve `currentLoan` via the presenter).

- [ ] **Step 6: Test** (validation-level, to avoid hitting the SMS provider):
```php
<?php

namespace Tests\Feature\Admin\Client;

class SmsEndpointsTest extends ClientActionsTestCase
{
    public function test_send_sms_requires_phone_and_text(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('send-sms'), [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['phoneId', 'text']);
    }

    public function test_sms_text_returns_ok_shape(): void
    {
        $response = $this->actingAsAdmin()
            ->postJson($this->actionUrl('sms-text'), ['companyId' => 0, 'customOptions' => []]);

        $response->assertOk()->assertJsonStructure(['ok', 'data' => ['text', 'options']]);
    }
}
```

- [ ] **Step 7: Run** `--filter=SmsEndpointsTest` → PASS.
- [ ] **Step 8: Pint + commit** (`POL-352 Add SMS endpoints`).

---

## Task 8: Email endpoints (`email-template-fields`, `email-preview`, `send-email`)

**Files:**
- Modify: service, controller, routes
- Create: `app/Admin/Requests/Client/EmailTemplateFieldsRequest.php`, `EmailPreviewRequest.php`, `SendEmailRequest.php`
- Test: `tests/Feature/Admin/Client/EmailEndpointsTest.php`

**Interfaces:**
- Produces:
  - `ClientActionsService::emailTemplateFields(int $templateId): array` — `['fields' => array<string>]`. From `updatedSelectedEmailPlantillas`/`extractCustomFieldsFromTemplate` (678-714).
  - `ClientActionsService::emailPreview(Client $client, int $emailId, int $templateId, array $customFields): array` — `['email' => string, 'message' => string]` or `[]`. From `getEmailPreview` (732-760).
  - `ClientActionsService::sendEmail(Client $client, User $user, int $emailId, int $templateId, array $customFields): array` — `['status','waiting','failed']`; throws on failure. From `sendEmail` (576-652).

- [ ] **Step 1: Requests** — `EmailTemplateFieldsRequest`: `templateId` required int. `EmailPreviewRequest`: `emailId` required int, `templateId` required int, `customFields` array. `SendEmailRequest`: same as preview.

- [ ] **Step 2: Service `emailTemplateFields`**:
```php
    public function emailTemplateFields(int $templateId): array
    {
        $template = \App\Models\Email\EmailCompany::find($templateId);

        if (! $template) {
            return ['fields' => []];
        }

        $message = $template->getFullMessage();

        preg_match_all('/<<(\s*opcion\d+\s*)>>/', $message, $m1);
        preg_match_all('/&lt;&lt;(\s*opcion\d+\s*)&gt;&gt;/', $message, $m2);

        $fields = array_map('trim', array_unique(array_merge($m1[1] ?? [], $m2[1] ?? [])));
        sort($fields);

        return ['fields' => array_values($fields)];
    }
```

- [ ] **Step 3: Service `emailPreview`** (move `getEmailPreview`, replacing `$this->selectedEmailPlantillas()` with `EmailCompany::find($templateId)`, `$this->getSelectedEmail()` with a lookup in the presenter's `emails`):
```php
    public function emailPreview(\App\Models\Collection\Client\Client $client, \App\Models\User\User $user, int $emailId, int $templateId, array $customFields): array
    {
        $template = \App\Models\Email\EmailCompany::find($templateId);
        $email = $this->presenter->build($client, $user)['emails']->firstWhere('id', $emailId);

        if (! $template || ! $email) {
            return [];
        }

        /** @var \App\Services\CommunicationIntegrations\Email\Helpers\EmailHelper $emailHelper */
        $emailHelper = app(\App\Services\CommunicationIntegrations\Email\Helpers\EmailHelper::class);

        $message = $emailHelper->fillMessagePlaceholders(
            $template->getFullMessage(),
            $client->documento,
            array_values($customFields),
            false,
        );

        return ['email' => $email->email, 'message' => $message];
    }
```

- [ ] **Step 4: Service `sendEmail`** (move `sendEmail()` minus dispatch, throwing on failure, returning status flags; keep `Calendar::isTodayNonWorking()` guard; keep bucket link):
```php
    public function sendEmail(\App\Models\Collection\Client\Client $client, \App\Models\User\User $user, int $emailId, int $templateId, array $customFields): array
    {
        if (\App\Models\Collection\Calendar::isTodayNonWorking()) {
            throw new \RuntimeException('No es posible realizar el envío. ¡En la configuración está establecido un día festivo!');
        }

        /** @var \App\Services\CommunicationIntegrations\Email\Providers\Abstract\AbstractEmailProvider $emailProvider */
        $emailProvider = resolve(\App\Services\CommunicationIntegrations\Email\Providers\Abstract\AbstractEmailProvider::class);

        $bucket = \App\Models\Email\Bucket::createInstance(
            $templateId,
            1,
            $user->getAuthIdentifier(),
            $emailProvider::getProviderName(),
            \App\Models\Sms\Bucket::INITIATOR_CUSTOM,
        );

        $template = \App\Models\Email\EmailCompany::find($templateId);
        $email = $this->presenter->build($client, $user)['emails']->firstWhere('id', $emailId);

        $sendEmailDTO = new \App\Services\CommunicationIntegrations\Email\DTO\SendEmailDTO(
            bucketId: $bucket->id,
            companyId: $templateId,
            email: $email->email,
            title: $template->title,
            message: $template->getFullMessage(),
            document: $client->documento,
            options: array_values($customFields),
            result: '19',
            senderUserId: $user->getAuthIdentifier(),
            type: \App\Services\CommunicationIntegrations\Email\DTO\SendEmailDTO::TYPE_PERSONAL,
            channel: \App\Http\Requests\Promise\DTO\CommunicationMetadataDTO::CHANNEL_UI,
        );

        /** @var \App\Services\CommunicationIntegrations\Email\EmailService $emailService */
        $emailService = resolve(\App\Services\CommunicationIntegrations\Email\EmailService::class);
        $bucketLink = config('app.url') . '/admin/email/histories?bucket_id=' . $bucket->id;

        try {
            $history = $emailService->sendSingle($sendEmailDTO);
        } catch (\Throwable $exception) {
            \Illuminate\Support\Facades\Log::channel('sentry')->error($exception);

            throw new \RuntimeException('El correo no se ha enviado. No se ha guardado ninguna gestión.', 0, $exception);
        }

        return [
            'status' => $history->status,
            'bucketLink' => $bucketLink,
            'waiting' => $history->status === \App\Models\Email\History::STATUS_WAITING,
            'failed' => $history->status === \App\Models\Email\History::STATUS_FAILED,
        ];
    }
```

- [ ] **Step 5: Controller endpoints + routes**
```php
                    Route::post('email-template-fields', 'emailTemplateFields')->name('email-template-fields');
                    Route::post('email-preview', 'emailPreview')->name('email-preview');
                    Route::post('send-email', 'sendEmail')->name('send-email');
```
`sendEmail` maps waiting/failed/success to the same Spanish messages the component used (632-651).

- [ ] **Step 6: Test**
```php
<?php

namespace Tests\Feature\Admin\Client;

class EmailEndpointsTest extends ClientActionsTestCase
{
    public function test_send_email_requires_email_and_template(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('send-email'), [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['emailId', 'templateId']);
    }

    public function test_template_fields_returns_array(): void
    {
        $templateId = optional(\App\Models\Email\EmailCompany::query()->first())->id ?? 0;

        $this->actingAsAdmin()
            ->postJson($this->actionUrl('email-template-fields'), ['templateId' => $templateId])
            ->assertOk()
            ->assertJsonStructure(['ok', 'data' => ['fields']]);
    }
}
```

- [ ] **Step 7: Run** `--filter=EmailEndpointsTest` → PASS.
- [ ] **Step 8: Pint + commit** (`POL-352 Add email endpoints`).

---

## Task 9: Phone/email CRUD + channels endpoints

**Files:**
- Modify: service, controller, routes
- Create: `app/Admin/Requests/Client/AddClientEmailRequest.php`, `TogglePhoneStatusRequest.php`, `ToggleEmailStatusRequest.php`, `SetChannelsRequest.php` (reuse existing `AddClientPhoneRequest`)
- Test: `tests/Feature/Admin/Client/ClientCrudEndpointsTest.php`

**Interfaces:**
- Produces:
  - `ClientActionsService::addPhone(Client $client, array $payload): void` — from `addPhone()` (963-984).
  - `ClientActionsService::addEmail(Client $client, string $email): void` — from `addEmail()` (986-998).
  - `ClientActionsService::togglePhoneStatus(Client $client, User $user, int $phoneId, int $status): void` — from `togglePhoneStatus()` (1000-1008).
  - `ClientActionsService::toggleEmailStatus(Client $client, User $user, int $emailId): void` — from `toggleEmailStatus()` (1010-1016).
  - `ClientActionsService::setChannels(Client $client, array $channels): void` — from `setChannels()` (955-961).
- Controller endpoints for add-phone/add-email return the re-rendered table HTML (see Task 11 for the partial names): `data.html` = `view('admin.client.phones', …)->render()` (phones) / `view('admin.client.emails', …)` (emails). Because Task 11 creates those partials, this task returns `data.html` = `''` as a placeholder **only if** Task 11 is not yet done; the executor should implement this task after Task 11, or return the freshly-built rows. To avoid ordering hazard, **do Task 11 before wiring the HTML return here** — the service methods and validation in this task do not depend on the partials and can be committed first; wire `data.html` in Task 12.

- [ ] **Step 1: Requests** — `AddClientEmailRequest`: `email` required string with `email`-ish checks matching the JS (`['email' => ['required', 'string']]` + a `regex`/`email` rule). `TogglePhoneStatusRequest`: `phoneId` required int, `status` required boolean. `ToggleEmailStatusRequest`: `emailId` required int. `SetChannelsRequest`: `channels` required array.

- [ ] **Step 2: Service methods** (move bodies verbatim, swapping `$this->client`→`$client`, `$this->phones->push`/`$this->emails->push` dropped since we re-query for the partial, `PhoneRelation::listCached()` refresh dropped):
```php
    public function addPhone(\App\Models\Collection\Client\Client $client, array $payload): void
    {
        $phone = new \App\Models\Collection\Client\Phone();
        /** @var \App\Models\Collection\Dictionary\City $city */
        $city = \App\Models\Collection\Dictionary\City::listCached()->where('id', $payload['city_id'])->first();

        $phone->parentesco = $payload['phone_relation_id'];
        $phone->idciudad = $city->ciudad;
        $phone->telefono = $payload['phone'];
        $phone->documento = $client->documento;
        $phone->nivelcontacto = '99';
        $phone->personacontacto = $payload['contact_person'];
        $phone->agregado = 1;
        $phone->idactivo = 1;
        $phone->intensidad = 0;
        $phone->is_user_add = 1;
        $phone->saveOrFail();
    }

    public function addEmail(\App\Models\Collection\Client\Client $client, string $email): void
    {
        $model = new \App\Models\Collection\Client\Email();
        $model->documento = $client->documento;
        $model->email = $email;
        $model->idactivo = 1;
        $model->agregado = 1;
        $model->saveOrFail();
    }

    public function togglePhoneStatus(\App\Models\Collection\Client\Client $client, \App\Models\User\User $user, int $phoneId, int $status): void
    {
        $phones = $this->presenter->build($client, $user)['phones'];
        /** @var \App\Models\Collection\Client\Phone $phone */
        $phone = $phones->firstWhere('id', $phoneId);
        $phone->idactivo = $status;
        $phone->saveOrFail();
    }

    public function toggleEmailStatus(\App\Models\Collection\Client\Client $client, \App\Models\User\User $user, int $emailId): void
    {
        $emails = $this->presenter->build($client, $user)['emails'];
        /** @var \App\Models\Collection\Client\Email $email */
        $email = $emails->firstWhere('id', $emailId);
        $email->idactivo = ! $email->idactivo;
        $email->saveOrFail();
    }

    public function setChannels(\App\Models\Collection\Client\Client $client, array $channels): void
    {
        $client->canalautorizado = implode(';', $channels);
        $client->popup = 1;
        $client->fechaautorizacioncanal = now()->toDateString();
        $client->saveOrFail();
    }
```

- [ ] **Step 3: Controller endpoints + routes**
```php
                    Route::post('add-phone', 'addPhone')->name('add-phone');
                    Route::post('add-email', 'addEmail')->name('add-email');
                    Route::post('toggle-phone-status', 'togglePhoneStatus')->name('toggle-phone-status');
                    Route::post('toggle-email-status', 'toggleEmailStatus')->name('toggle-email-status');
                    Route::post('set-channels', 'setChannels')->name('set-channels');
```
Each resolves `$client`/`$user`, calls the service, returns `{ ok: true }`. `add-phone`/`add-email` will additionally return `data.html` — wired in Task 12.

- [ ] **Step 4: Test**
```php
<?php

namespace Tests\Feature\Admin\Client;

class ClientCrudEndpointsTest extends ClientActionsTestCase
{
    public function test_toggle_phone_status_requires_fields(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('toggle-phone-status'), [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['phoneId', 'status']);
    }

    public function test_set_channels_requires_array(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('set-channels'), [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['channels']);
    }

    public function test_add_email_requires_email(): void
    {
        $this->actingAsAdmin()
            ->postJson($this->actionUrl('add-email'), [])
            ->assertStatus(422)
            ->assertJsonValidationErrors(['email']);
    }
}
```

- [ ] **Step 5: Run** `--filter=ClientCrudEndpointsTest` → PASS.
- [ ] **Step 6: Pint + commit** (`POL-352 Add phone/email CRUD + channels endpoints`).

---

## Task 10: `client-actions.js` — vanilla form controller

**Files:**
- Create: `resources/js/client-actions.js`
- Modify: the admin Vite entry (`resources/js/app.js` or equivalent) to `import './client-actions.js'`
- Verify: build succeeds.

**Interfaces:**
- Consumes: the JSON payload rendered by the root partial (Task 11) at `#client-actions-root[data-gestion]` and `data-client-id`, `data-endpoints` (route map). All endpoints from Tasks 4-9.
- Produces: a global initializer `window.initClientActions(rootEl)` invoked on `DOMContentLoaded`; replicates the Alpine `gestionForm` getters/methods (main-actions.blade.php 349-529) and the page-level `Livewire.on(...)` handlers as direct responses.

- [ ] **Step 1: Write the module**

Create `resources/js/client-actions.js`. It ports the Alpine object to a class bound to the root element. Key equivalences:
- `graph/actions/contacts/results/reasons/strategies/phones/emails/clientPopup/hideChannels/defaultChannels` ← `JSON.parse(root.dataset.gestion)`.
- `view`/`selectedTab`/`actionId`/... ← instance fields; `view` toggles `.js-view-list`/`.js-view-form` via `classList`.
- getters `availableContacts`, `availableResults`, `contactGroupId`, `showReasonStrategy`, `showValueInput`, `showDateInput`, `identifier`, `preformText`, `canSubmit` ← copy the JS bodies verbatim from lines 422-466 (already plain JS).
- `setTab`, `setAction`, `resetFields`, `resetForm`, `onContactChange`, `onResultChange`, `startTimer`/`stopTimer`/`currentTimerString` ← copy from 395-528, replacing `this.$wire.setChannels(...)` with `postJson(endpoints.setChannels, { channels })` and `this.$nextTick`/`window.dispatchEvent('build-gestion-datepicker')` kept as-is.
- `submit()` ← replace `await this.$wire.save({...})` with:
  ```js
  const res = await postJson(this.endpoints.saveCommunication, payload);
  if (res.status === 422) { this.errors = res.body.errors ?? {}; return; }
  if (!res.body.ok) { swalError(res.body.message); return; }
  swalSuccess(res.body.message);
  this.resetForm();
  Livewire.dispatch('refreshCommunications');
  Livewire.dispatch('refreshHeader');
  ```
- SMS sub-form: `sendSmsTypeId`/`sendSmsCompanyId`/`sendSmsText`/`sendSmsCustomOptions` become instance fields; `updatedSendSmsCompanyId`/`updatedSendSmsCustomOptions` → call `postJson(endpoints.smsText, { companyId, customOptions })` and set `sendSmsText`/`sendSmsCustomOptions` from `data`; `setSMSVariable` → `postJson(endpoints.smsVariable, { variable })` then append `data.value`; send button → `postJson(endpoints.sendSms, { phoneId, companyId, text })` and handle `waiting`(showInfo)/`failed`(showError)/success + open `data.bucketLink` + refresh siblings.
- Email sub-form: `selectedEmailPlantillas` change → `postJson(endpoints.emailTemplateFields, { templateId })` to render custom-field inputs; preview button → `postJson(endpoints.emailPreview, {...})` then open modal with `data.message`; send → `postJson(endpoints.sendEmail, {...})` with same waiting/failed/success handling.
- Schedule-call form: save → `postJson(endpoints.scheduleCall, { callScheduleDate, programmedActionId })`.
- Provide helpers at top of file:
  ```js
  async function postJson(url, body) {
    const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
    const resp = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': token, 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
      body: JSON.stringify(body),
    });
    let data = {};
    try { data = await resp.json(); } catch (e) {}
    return { status: resp.status, body: data };
  }
  function swalSuccess(msg) { Swal.fire({ title: 'Éxito', text: msg || 'Gestión guardada', icon: 'success' }); }
  function swalError(msg) { Swal.fire({ icon: 'error', title: 'Error', text: msg }); }
  function swalInfo(msg) { Swal.fire({ icon: 'info', title: 'Información', text: msg }); }
  ```
  Use `__()`-translated strings by rendering them into `data-*` on the root or a `window.clientActionsI18n` object populated by the blade (Task 11); do not hardcode Spanish where the blade currently uses `__('views.main_actions.*')`.
- Port the global helper functions `addPhone()`, `addEmail()`, `showNormatividad()` (532-714) from the blade into this module, replacing `Livewire.dispatch('addPhone', {payload})` with `postJson(endpoints.addPhone, payload)` then swapping the phones table `innerHTML` with `data.html`; likewise `addEmail`/`setChannels`. Keep IMask/flatpickr/Swal usage intact.
- Port the phone status toggle and `doCallPeru`/`doCallColombia` from `phones.blade.php` (109-166): `togglePhoneStatus` → `postJson(endpoints.togglePhoneStatus, { phoneId, status })`; `doCallPeru` confirm → `postJson(endpoints.initWebitelCall, { phoneId })` then on success call `this.setAction(1, phoneId)` and store `externalCommunicationId`.

Because this module is long, keep it under ~400 lines by grouping into `createGestionController(root)` returning an object, plus the standalone modal/helpers. Reference the exact Alpine source in `resources/views/livewire/main-actions.blade.php` while porting — the getters are already framework-agnostic JS.

- [ ] **Step 2: Register in Vite entry**

Add `import './client-actions.js';` to the admin JS entry (find it: `grep -n "input" vite.config.js`). If a new entry is cleaner, add `resources/js/client-actions.js` to the `input` array in `vite.config.js` and `@vite` it in the layout.

- [ ] **Step 3: Build**

Run: `docker exec -i polaris-service-new npm run build`
Expected: build completes, `client-actions` present in `public/build/manifest.json`.

- [ ] **Step 4: Commit** (no PHP → no Pint needed)
```bash
git add resources/js/client-actions.js resources/js/app.js vite.config.js public/build 2>/dev/null || git add resources/js/client-actions.js
git commit -m "POL-352 Add vanilla client-actions JS controller

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 11: Blade partials (de-Livewired) + root partial + i18n payload

**Files:**
- Create all `resources/views/admin/client/*.blade.php` partials listed in File Structure.

**Interfaces:**
- Consumes: variables passed from `ClientController@show` (Task 12): `client, phones, emails, callSchedules, currentLoan, actions, nonPaymentReasons, phoneRelations, strategies, smsCompanies, programmedActions, programmedActionId, isCloseCommunication, sideActions, gestionData, userId, webitelId`.
- Produces: `#client-actions-root` element with `data-client-id`, `data-gestion='@js($gestionData)'`, `data-endpoints='@js([...route names resolved to urls...])'`, and `window.clientActionsI18n` script block with the translated strings used by the JS.

- [ ] **Step 1: Create `resources/views/admin/client/main-actions.blade.php`**

Port `resources/views/livewire/main-actions.blade.php` structure (the outer `<div>`, list view, form view) but:
- Replace `x-data="gestionForm(...)"` root with `<div id="client-actions-root" data-client-id="{{ $client->id }}" data-gestion='@js($gestionData)' data-endpoints='@js($endpoints)'>` where `$endpoints` is an array of `verb => route('admin.admin-client.actions.<verb>', $client->id)` for every endpoint.
- Replace Alpine `x-show="view === 'list'"`/`'form'` with `<div class="js-view-list">`/`<div class="js-view-form" hidden>`; the JS toggles `hidden`.
- Replace `x-show="selectedTab === '…'"` on tab panes with `class="js-tab-pane" data-tab="phone|email|another|schedule"` and let JS show the active one.
- `@include('admin.client.actions-wrapper')`, `@include('admin.client.phones')`, `@include('admin.client.emails')`, the side-actions loop (use `$sideActions`), `@include('admin.client.schedule')`, `@include('admin.client.gestion-form')`, `@include('admin.client.sms-form')`, `@include('admin.client.email-form')`.
- Drop the `wire:loading` blocks; add a plain `<div class="loading-waiter" hidden>` spinner toggled by JS during `fetch`.
- Move the entire `<script>` block's logic to `client-actions.js`; in the blade keep only a small `window.clientActionsI18n = @js([...])` map (all `__('views.main_actions.*')`, `__('views.client.phones.*')`, normatividad strings currently inlined in the JS), and `document.addEventListener('DOMContentLoaded', () => window.initClientActions(document.getElementById('client-actions-root')));`.

- [ ] **Step 2: Create `actions-wrapper.blade.php`**

Copy `livewire/actions-wrapper.blade.php`; replace `@click="setTab('…')"` with `data-tab-btn="phone|email|another|schedule"` and `:class="{ active: selectedTab === … }"` with a default `active` on phone + JS toggling; use `self::TAB_*` constants replaced by literal strings `'phone'`/`'email'`/`'another'`/`'schedule'` (the component constants disappear).

- [ ] **Step 3: Create `phones.blade.php`**

Copy `livewire/client/phones.blade.php`; wrap the table in `<div id="client-phones-table">…</div>` (so add-phone can swap it). Replace:
- `$this->sortPhones($phones)` → inject a helper: add `sortPhones` as a Blade-usable function. Simplest: move `sortPhones` into `ClientGestionPresenter` as a `public static function sortPhones($phones)` and call `\App\Services\Client\ClientGestionPresenter::sortPhones($phones)` in the view.
- `$this->phoneRelations[...]` → `$phoneRelations[...]` (passed variable).
- `@click="setAction(2, {{ $phone->id }})"` etc → `data-action="setAction" data-args="2,{{ $phone->id }}"` (JS reads dataset and calls the controller method); keep `onclick="doCallPeru(...)"`/`doCallColombia(...)` (now defined in the JS module, exposed on `window`).
- phone status checkbox: keep markup; JS binds `.phone-status-input change` (already ported).
- Remove the inline `<script>` (moved to module).

- [ ] **Step 4: Create `emails.blade.php`**

Copy `livewire/client/emails.blade.php`; wrap in `<div id="client-emails-table">`. Replace `@click="setAction(5,null,{{id}}); …; $wire.setEmailAction(...)"` with `data-action="setEmailAction" data-args="5,{{ $email->id }},true"`; replace `wire:click="toggleEmailStatus({{id}})"` with `data-action="toggleEmailStatus" data-args="{{ $email->id }}"`.

- [ ] **Step 5: Create `gestion-form.blade.php`**

Copy `livewire/client/gestion-form.blade.php`. This one is Alpine-heavy but already client-side. Replace directives:
- `x-show="…"` → keep as Alpine? No — Alpine is removed. Convert `x-show`/`x-model`/`x-for`/`x-text`/`:class` to plain elements the JS renders/toggles. Concretely: give each dynamic node an id/class (`js-contact-select`, `js-result-select`, `js-reason-wrap`, `js-value-wrap`, `js-date-wrap`, `js-preform-label`, `js-submit-btn`, `.js-error[data-field="resultId"]` etc.), leave `<option>` template loops to JS (`availableContacts`/`availableResults` populate `<select>` via JS), and bind `change`/`input` in the controller. Keep the static `@foreach($nonPaymentReasons …)` and `@foreach($strategies …)` server-rendered (they are static lists).
- The timer `<div class="timer" x-text="timer.display">` → `<div class="timer js-timer">00:00:00</div>`.

- [ ] **Step 6: Create `sms-form.blade.php`, `email-form.blade.php`, `schedule.blade.php`**

Extract the SMS block (main-actions 121-221), email block (222-319), and schedule block (52-104 list + 320-340 form) into these partials. Replace `wire:model.live="…"` with plain `name`/`id` inputs the JS reads/writes, `wire:click`/`@click="$wire…"` with `data-action` hooks, and `@if($sendSmsTypeId === …)` server conditionals with JS-driven `hidden` toggles (initial state rendered for the default: personalized SMS type, empty template). Keep the `emailPreviewModal` markup; JS fills `.modal-body` from the preview response and shows it with `bootstrap.Modal`.

- [ ] **Step 7: Create modal + normatividad partials**

Copy `livewire/client/modal/add-phone-form.blade.php`, `add-email-form.blade.php`, `livewire/normatividad.blade.php` into `resources/views/admin/client/modal/` and `resources/views/admin/client/normatividad.blade.php` unchanged (they are static HTML used inside Swal). Update the `view('livewire.client.modal.add-phone-form')` references inside the JS module to `admin.client.modal.add-phone-form` (these are rendered server-side into the JS via the blade in Task 11 Step 1 — expose them as `window.clientActionsTemplates = { addPhone: @js(view('admin.client.modal.add-phone-form')->render()), addEmail: …, normatividad: … }`).

- [ ] **Step 8: Verify partials render in isolation (no runtime yet)**

Run: `docker exec -i polaris-service-new php artisan view:clear && docker exec -i polaris-service-new php -r "require 'vendor/autoload.php';" ` (smoke) — real verification happens in Task 13. For now just ensure no Blade syntax errors:

Run: `docker exec -i polaris-service-new php artisan view:cache` then `docker exec -i polaris-service-new php artisan view:clear`
Expected: `view:cache` completes without a compile error. (It compiles all Blade files; a syntax error in the new partials will surface here.)

- [ ] **Step 9: Commit**
```bash
git add resources/views/admin/client/
git commit -m "POL-352 Add de-Livewired client gestión blade partials

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 12: Cutover — controller wiring, table-HTML returns, route consolidation, delete Livewire

**Files:**
- Modify: `app/Admin/Controllers/Client/ClientController.php` (`show`), `app/Admin/Controllers/Client/ClientActionsController.php` (add-phone/add-email `data.html`), `resources/views/admin/client/show.blade.php`
- Delete: the Livewire component + `livewire/` blades listed in File Structure.

**Interfaces:**
- Consumes: `ClientGestionPresenter::build()` (Task 3), all partials (Task 11), JS (Task 10).

- [ ] **Step 1: Build gestión data in `ClientController@show`**

In the `if (! $isRestricted)` block, add:
```php
        $gestion = app(\App\Services\Client\ClientGestionPresenter::class)->build($client, $authUser);
        $variables = array_merge($variables, $gestion, [
            'sideActions' => $gestion['actions']->where('id', '>', 5)->where('id', '!=', 7)->where('id', '!=', 8),
        ]);
```
(`build()` returns `gestionData` under that key; the partial reads `$gestionData`.)

- [ ] **Step 2: Swap the include in `show.blade.php`**

Replace line 37:
```blade
@livewire(\App\Livewire\Client\MainActionsComponent::class, ['client' => $client], key('client-main-actions-'.$client->id))
```
with:
```blade
@include('admin.client.main-actions')
```
(The `$variables` array is already extracted into the view scope by the layout, so the partial sees `$client`, `$phones`, `$gestionData`, `$endpoints`… — verify the layout `extract()`s `variables`; if it passes them nested, use `@include('admin.client.main-actions', $variables)`.)

- [ ] **Step 3: Wire `data.html` for add-phone/add-email**

In `ClientActionsController::addPhone`, after `$service->addPhone($client, $request->validated())`:
```php
        $data = app(\App\Services\Client\ClientGestionPresenter::class)->build($client, $user);

        return response()->json([
            'ok' => true,
            'data' => ['html' => view('admin.client.phones', $data)->render()],
        ]);
```
Analogously for `addEmail` with `view('admin.client.emails', $data)`.

- [ ] **Step 4: Delete the Livewire component + blades**
```bash
git rm app/Livewire/Client/MainActionsComponent.php \
  resources/views/livewire/main-actions.blade.php \
  resources/views/livewire/actions-wrapper.blade.php \
  resources/views/livewire/client/phones.blade.php \
  resources/views/livewire/client/emails.blade.php \
  resources/views/livewire/client/gestion-form.blade.php \
  resources/views/livewire/client/modal/add-phone-form.blade.php \
  resources/views/livewire/client/modal/add-email-form.blade.php \
  resources/views/livewire/normatividad.blade.php
```

- [ ] **Step 5: Grep for stragglers**

Run: `grep -rn "MainActionsComponent\|livewire.main-actions\|livewire.client.phones\|livewire.client.emails\|livewire.normatividad\|setEmailAction\|gestionForm" app/ resources/`
Expected: no references except inside `client-actions.js` / the new partials. Fix any leftover.

- [ ] **Step 6: Build + view cache smoke**

Run: `docker exec -i polaris-service-new npm run build && docker exec -i polaris-service-new php artisan view:clear`
Expected: both succeed.

- [ ] **Step 7: Pint + commit**
```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add -A
git commit -m "POL-352 Cutover client gestión block to Blade+AJAX; remove Livewire component

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 13: End-to-end manual verification

**Files:** none (verification only). Use the `verify` skill / browser (Playwright MCP or manual) against a real client page.

- [ ] **Step 1: Load the page**

Run: `docker exec -i polaris-service-new php artisan route:list --path=client --json` to confirm all `actions.*` routes exist. Then open `/{admin-prefix}/client/{id}` for a known client (use `get-absolute-url`). Confirm the Gestión tab renders phones/emails/other/schedule with no console errors.

- [ ] **Step 2: Exercise each flow and confirm parity**

Verify against the original behavior:
- Tabs switch; side actions show.
- Phone dropdown → outgoing/incoming call actions open the gestión form; contact→result cascade populates; reason/strategy show only for `contactGroupId==1`; value/date inputs appear per result flags; preform label updates; timer runs.
- Save with missing result → inline error; valid save → success Swal, form resets, communications list + header refresh (confirm `Livewire.dispatch` fired: the communications table updates without full reload).
- Schedule call (action 3) → flatpickr, save → success; duplicate date → error message.
- SMS (action 6): personalized vs template; template select fills text + custom options; variables append; send → info/success/error + bucket link opens; siblings refresh.
- Email (actions 5/12): template select renders custom fields; preview modal shows filled message; send → success/error + bucket link; siblings refresh.
- Add phone (Swal + IMask) → table swaps in the new row; add email → table swaps.
- Toggle phone/email status → toast, persists on reload.
- Normatividad popup fires when `contactGroupId==1 && clientPopup==0` (or `setChannels(defaultChannels)` when `hideChannels`).
- Colombia SIP / Peru Webitel call buttons behave per instance.

- [ ] **Step 3: Run the full backend suite**

Run: `docker exec -i polaris-service-new php artisan test --compact`
Expected: PASS (at least all new `ClientActions*` tests; note pre-existing unrelated failures if any and report them, do not fix out of scope).

- [ ] **Step 4: Final commit if any fixes were needed**
```bash
docker exec -i polaris-service-new vendor/bin/pint --dirty
git add -A
git commit -m "POL-352 Fixes from end-to-end verification

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Self-review notes (coverage map)

- Spec §2 endpoints → Tasks 4-9 (saveCommunication, scheduleCall, initWebitelCall, sendSms, sendEmail, smsText, emailTemplateFields, emailPreview, addPhone, togglePhoneStatus, addEmail, toggleEmailStatus, setChannels). ✔
- Spec §1 sibling refresh via `Livewire.dispatch` → Task 10 submit/sms/email handlers + Task 13 verification. ✔
- Spec §2 `ClientActionsService` extraction + thin controller → Tasks 3-9. ✔
- Spec §3 vanilla-JS form flow (graph/preform/canSubmit/timer/tabs) → Tasks 10-11. ✔
- Spec §4 table re-render via returned partial HTML → Task 9 (service) + Task 12 (`data.html`). ✔
- Spec §5 file moves/deletes → Tasks 11-12. ✔
- Spec §6 feature tests → Tasks 2, 4-9; manual E2E → Task 13. ✔
- Risk (a) mount() move → Task 3. Risk (b) JS parity → Tasks 10, 13. Risk (c) JS→Livewire dispatch → Task 13 Step 2. ✔