# Редизайн страницы «Сделки» — план реализации

> **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:** Превратить страницу «Сделки» из CRM с ручным созданием/корзиной в реестр лидов, поставленных crm.bp: новые колонки, фильтры, экспорт по диапазону дат, пагинация, master-detail панель; воронку статусов 14 → 5.

**Architecture:** Две фазы. **Фаза A** — редизайн страницы (frontend + `DealController@index` + `DealExportController`), работает на текущем наборе из 14 статусов. **Фаза B** — схлопывание воронки 14 → 5 (схема + миграция + Канбан + маппинг импорта). Фаза B стартует ПОСЛЕ гейта подтверждения slug'ов. Фильтрация и пагинация — server-side через `GET /api/deals`. Панель деталей — общий `DealDetailBody.vue`, обёрнутый либо в overlay-дровер (Канбан), либо в inline-`<aside>` (Сделки).

**Tech Stack:** Laravel 13 / PHP 8.3, Vue 3 + Vuetify 3, PostgreSQL 16 (партиционированная `deals`), Pest 4, Vitest, OpenSpout (экспорт).

**Спека:** `docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md`.

---

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

- **Laravel-корень вложен** — приложение в `app/app/...`, тесты в `app/tests/...`, фронт в `app/resources/js/...`.
- **`MockDeal`** (`composables/mockDeals.ts`) используется и страницей «Сделки», и Канбаном — новые поля добавляются **опциональными**, чтобы не ломать Канбан.
- **Backend `store`/`destroy`/`restore`** (ручное создание, soft-delete) — **остаются в коде** (`DealController`, `DealBulkActionController`, маршруты, тесты `DealCreate/Destroy/Restore`). Из UI они убираются; удаление самих endpoint'ов — вне scope (отдельная чистка).
- **Колонка «Город»** — рендерится `—`, фильтр-селект пуст/disabled. Источник данных — открытый вопрос §4 спеки, вне реализации.
- **Без push.** Коммитить только атомарно по задачам. Хуки не обходить.
- TDD: failing-тест → запуск (RED) → реализация → запуск (GREEN) → коммит. После логического блока — relevant Vitest/Pest; перед коммитом — pre-commit.

## Структура файлов

**Фаза A — изменяются:**

- `app/resources/js/composables/mockDeals.ts` — расширить `MockDeal` (city/comment/signalType/receivedAt/nextReminderAt); удалить `DEALS_TABS`/`DealsTab` (после A6+A10).
- `app/resources/js/api/deals.ts` — `ApiDeal` +4 поля, `ListDealsParams` +date-range, `exportDealsByRange()`.
- `app/resources/js/composables/dealsApiMapper.ts` — `mapApiDeal` заполняет новые поля.
- `app/app/Http/Controllers/Api/DealController.php` — `index`: фильтр `received_at` BETWEEN, в payload `comment`/`city`/`project_signal_type`/`next_reminder_at`.
- `app/app/Http/Controllers/Api/DealExportController.php` — экспорт по диапазону дат вместо `ids`.
- `app/resources/js/components/deals/DealsFilters.vue` — переписать (поиск по телефону, Статус, Проект, Город).
- `app/resources/js/components/deals/DealsTable.vue` — новый набор колонок.
- `app/resources/js/components/deals/DealsBulkBar.vue` — только смена статуса.
- `app/resources/js/components/deals/DealDetailDrawer.vue` — тонкая обёртка (overlay/inline).
- `app/resources/js/views/DealsView.vue` — полный переписанный экран.

**Фаза A — создаются:**

- `app/resources/js/components/deals/DealDetailBody.vue` — извлечённое тело панели сделки.
- `app/tests/Feature/DealExportTest.php` — тесты экспорта по диапазону дат.

**Фаза B — изменяются:**

- `db/schema.sql` (seed `lead_statuses`) + `db/CHANGELOG_schema.md`.
- `app/database/migrations/2026_05_17_120000_deals_funnel_14_to_5_statuses.php` (новый).
- `app/resources/js/composables/leadStatuses.ts` — `LEAD_STATUSES` 14 → 5.
- `app/resources/js/composables/useStatusPill.ts` — +`viewed`/`lost`.
- `app/app/Services/Import/StatusRuToSlugMapper.php` — маппинг под 5.
- `app/resources/js/composables/mockDeals.ts` (`MOCK_DEALS` slug'и), `app/database/factories/DealFactory.php`, тесты со старыми slug'ами.

---

## ФАЗА A — редизайн страницы

### Task A1: Расширить тип `MockDeal`

**Files:**

- Modify: `app/resources/js/composables/mockDeals.ts:10-19`
- Test: `app/tests/Frontend/dealsApiMapper.spec.ts`

- [ ] **Step 1: Расширить интерфейс `MockDeal`**

В `mockDeals.ts` заменить блок интерфейса (строки 10-19) на:

```ts
export interface MockDeal {
    id: number;
    name: string;
    phone: string;
    statusSlug: LeadStatus['slug'];
    project: string;
    manager: { initials: string; name: string };
    cost: number;
    receivedMinutesAgo: number;
    // Редизайн «Сделки» (2026-05-17). Опциональны — Канбан/MOCK_DEALS не трогаем.
    signalType?: 'call' | 'site' | 'sms' | null;
    city?: string | null;
    comment?: string | null;
    receivedAt?: string | null; // ISO — колонка «Поставлен»
    nextReminderAt?: string | null; // ISO — колонка «Напоминание»
}
```

`DEALS_TABS`, `DealsTab`, `MOCK_DEALS`, `MOCK_PROJECTS`, `MOCK_MANAGERS` — НЕ трогать в этой задаче.

- [ ] **Step 2: Проверить компиляцию типов**

Run: `cd app; npx vue-tsc --noEmit -p tsconfig.json`
Expected: 0 ошибок (поля опциональны — существующий код валиден).

- [ ] **Step 3: Запустить Vitest по затронутому**

Run: `cd app; npx vitest run tests/Frontend/dealsApiMapper.spec.ts tests/Frontend/DealsTable.spec.ts`
Expected: PASS (аддитивное изменение).

- [ ] **Step 4: Commit**

```bash
git add app/resources/js/composables/mockDeals.ts
git commit -m "feat(deals): extend MockDeal with city/comment/signalType/receivedAt/nextReminderAt"
```

### Task A2: Расширить `api/deals.ts`

**Files:**

- Modify: `app/resources/js/api/deals.ts`
- Test: `app/tests/Frontend/deals-api.spec.ts`

- [ ] **Step 1: Расширить `ApiDeal`**

В `api/deals.ts` в интерфейс `ApiDeal` (строки 133-145) добавить 4 поля перед закрывающей `}`:

```ts
    comment: string | null;
    city: string | null;
    project_signal_type: string | null;
    next_reminder_at: string | null;
```

- [ ] **Step 2: Расширить `ListDealsParams`**

В `ListDealsParams` (строки 172-182) добавить после `search?: string;`:

```ts
    /** Диапазон дат поставки (received_at). ISO-дата 'YYYY-MM-DD'. */
    receivedFrom?: string;
    receivedTo?: string;
```

В `listDeals()` (строки 191-205) в объект `params` добавить:

```ts
            received_from: params.receivedFrom,
            received_to: params.receivedTo,
```

- [ ] **Step 3: Добавить `exportDealsByRange()`**

После функции `exportDealsXlsx` (после строки 131) добавить:

```ts
export interface ExportDealsByRangePayload {
    tenant_id: number;
    received_from?: string;
    received_to?: string;
    format: 'csv' | 'xlsx';
}

/**
 * Экспорт сделок по диапазону дат поставки. format='xlsx' → Blob, 'csv' → строка.
 */
export async function exportDealsByRange(payload: ExportDealsByRangePayload): Promise<Blob | string> {
    await ensureCsrfCookie();
    if (payload.format === 'xlsx') {
        const { data } = await apiClient.post<Blob>('/api/deals/export', payload, { responseType: 'blob' });
        return data;
    }
    const { data } = await apiClient.post<string>('/api/deals/export', payload, { responseType: 'text' });
    return data;
}
```

Старые `exportDeals`/`exportDealsXlsx` пока **оставить** (удаляются в Task A10).

- [ ] **Step 4: Проверить типы и тесты**

Run: `cd app; npx vue-tsc --noEmit -p tsconfig.json; npx vitest run tests/Frontend/deals-api.spec.ts`
Expected: 0 ошибок типов; PASS (аддитивно).

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/api/deals.ts
git commit -m "feat(deals): api/deals — ApiDeal +4 fields, date-range list params, exportDealsByRange"
```

### Task A3: `mapApiDeal` заполняет новые поля

**Files:**

- Modify: `app/resources/js/composables/dealsApiMapper.ts:60-77`
- Test: `app/tests/Frontend/dealsApiMapper.spec.ts`

- [ ] **Step 1: Failing-тест**

В `tests/Frontend/dealsApiMapper.spec.ts` добавить тест (в конец describe-блока для `mapApiDeal`; если файл — плоский набор `test()`, добавить в конец файла):

```ts
test('mapApiDeal переносит city/comment/signalType/receivedAt/nextReminderAt', () => {
    const iso = '2026-05-15T09:00:00+00:00';
    const result = mapApiDeal({
        id: 7,
        tenant_id: 1,
        project_id: 2,
        project_name: 'Окна',
        phone: '+7 999 000-00-00',
        contact_name: null,
        status: 'new',
        manager_id: null,
        manager_name: null,
        manager_initials: null,
        received_at: iso,
        comment: 'звонил клиент',
        city: 'Москва',
        project_signal_type: 'call',
        next_reminder_at: iso,
    });
    expect(result.city).toBe('Москва');
    expect(result.comment).toBe('звонил клиент');
    expect(result.signalType).toBe('call');
    expect(result.receivedAt).toBe(iso);
    expect(result.nextReminderAt).toBe(iso);
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; npx vitest run tests/Frontend/dealsApiMapper.spec.ts`
Expected: FAIL — `city`/`comment`/`signalType` undefined.

- [ ] **Step 3: Реализация**

В `dealsApiMapper.ts` в `mapApiDeal` (return-объект, строки 64-76) добавить перед закрывающей `};` после `receivedMinutesAgo`:

```ts
        signalType: (api.project_signal_type as MockDeal['signalType']) ?? null,
        city: api.city,
        comment: api.comment,
        receivedAt: api.received_at,
        nextReminderAt: api.next_reminder_at,
```

- [ ] **Step 4: Запустить — GREEN**

Run: `cd app; npx vitest run tests/Frontend/dealsApiMapper.spec.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/composables/dealsApiMapper.ts app/tests/Frontend/dealsApiMapper.spec.ts
git commit -m "feat(deals): mapApiDeal maps city/comment/signalType/receivedAt/nextReminderAt"
```

### Task A4: `DealController@index` — фильтр по диапазону дат + новые поля payload

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealController.php`
- Test: `app/tests/Feature/DealIndexTest.php`

- [ ] **Step 1: Failing-тесты**

В `tests/Feature/DealIndexTest.php` добавить в конец файла:

```php
test('GET /api/deals фильтрует по received_from/received_to', function () {
    Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-10 12:00:00']);
    Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 12:00:00']);
    Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-20 12:00:00']);

    $r = $this->getJson('/api/deals?received_from=2026-05-12&received_to=2026-05-16');

    expect($r->json('total'))->toBe(1);
});

test('GET /api/deals received_to включает весь день (конец дня)', function () {
    Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-16 23:30:00']);

    expect($this->getJson('/api/deals?received_to=2026-05-16')->json('total'))->toBe(1);
});

test('GET /api/deals возвращает comment/city/project_signal_type/next_reminder_at', function () {
    $this->project->update(['signal_type' => 'call', 'signal_identifier' => '79990001122']);
    $deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
        'comment' => 'перезвонить',
        'city' => 'Казань',
    ]);

    $r = $this->getJson('/api/deals');

    expect($r->json('deals.0.comment'))->toBe('перезвонить');
    expect($r->json('deals.0.city'))->toBe('Казань');
    expect($r->json('deals.0.project_signal_type'))->toBe('call');
    expect($r->json('deals.0'))->toHaveKey('next_reminder_at');
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; php artisan test --filter=DealIndexTest`
Expected: FAIL — date-фильтр игнорируется, ключей `comment`/`city`/`project_signal_type` нет.

- [ ] **Step 3: Реализация — импорт Carbon**

В `DealController.php` после `use Illuminate\Support\Facades\DB;` (строка 16) добавить:

```php
use Illuminate\Support\Carbon;
```

- [ ] **Step 4: Реализация — чтение date-параметров**

В `index()` после строки `$cursorRaw = (string) $request->query('cursor', '');` (строка 66) добавить:

```php
        $receivedFrom = trim((string) $request->query('received_from', ''));
        $receivedTo = trim((string) $request->query('received_to', ''));
```

Расширить сигнатуру замыкания `DB::transaction(function () use (...))` (строка 84) — добавить `$receivedFrom, $receivedTo` в `use(...)`.

- [ ] **Step 5: Реализация — select с subquery + eager-load signal_type**

В замыкании заменить блок построения `$query` (строки 94-96) на:

```php
            $query = Deal::query()
                ->select('deals.*')
                ->addSelect(['next_reminder_at' => DB::table('reminders')
                    ->select('remind_at')
                    ->whereColumn('reminders.deal_id', 'deals.id')
                    ->whereNull('reminders.completed_at')
                    ->orderBy('remind_at')
                    ->limit(1),
                ])
                ->where('tenant_id', $tenantId)
                ->with(['project:id,name,signal_type', 'manager:id,email,first_name,last_name']);
```

- [ ] **Step 6: Реализация — применить date-фильтр**

После блока `if ($search !== '') { ... }` (после строки 117) добавить:

```php
            if ($receivedFrom !== '') {
                $query->where('received_at', '>=', Carbon::parse($receivedFrom)->startOfDay());
            }
            if ($receivedTo !== '') {
                // received_to включительно — до конца дня (+1 день, строгое <).
                $query->where('received_at', '<', Carbon::parse($receivedTo)->addDay()->startOfDay());
            }
```

- [ ] **Step 7: Реализация — новые поля в payload**

В `$payload['deals']` mapping (строки 174-190) добавить после `'received_at' => $d->received_at?->toIso8601String(),`:

```php
                'comment' => $d->comment,
                'city' => $d->city,
                'project_signal_type' => $d->project?->signal_type,
                'next_reminder_at' => $d->next_reminder_at
                    ? Carbon::parse($d->next_reminder_at)->toIso8601String()
                    : null,
```

- [ ] **Step 8: Запустить — GREEN**

Run: `cd app; php artisan test --filter=DealIndexTest`
Expected: PASS (все, включая прежние тесты).

- [ ] **Step 9: Commit**

```bash
git add app/app/Http/Controllers/Api/DealController.php app/tests/Feature/DealIndexTest.php
git commit -m "feat(deals): DealController@index — received_at date-range filter + comment/city/signal_type/next_reminder_at"
```

### Task A5: `DealExportController` — экспорт по диапазону дат

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealExportController.php`
- Create: `app/tests/Feature/DealExportTest.php`

- [ ] **Step 1: Failing-тест — создать `DealExportTest.php`**

```php
<?php

declare(strict_types=1);

use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    $this->user = User::factory()->for($this->tenant)->create();
    $this->actingAs($this->user);
    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
    $this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
});

test('POST /api/deals/export требует auth', function () {
    auth()->logout();
    $this->postJson('/api/deals/export', ['format' => 'csv'])->assertStatus(401);
});

test('POST /api/deals/export csv возвращает сделки в диапазоне дат', function () {
    Deal::factory()->for($this->tenant)->for($this->project)->create([
        'phone' => '+7 999 111-11-11', 'received_at' => '2026-05-15 10:00:00',
    ]);
    Deal::factory()->for($this->tenant)->for($this->project)->create([
        'phone' => '+7 999 222-22-22', 'received_at' => '2026-05-25 10:00:00',
    ]);

    $r = $this->post('/api/deals/export', [
        'received_from' => '2026-05-14', 'received_to' => '2026-05-16', 'format' => 'csv',
    ]);

    $r->assertStatus(200);
    $r->assertHeader('content-type', 'text/csv; charset=utf-8');
    $body = $r->streamedContent();
    expect($body)->toContain('+7 999 111-11-11');
    expect($body)->not->toContain('+7 999 222-22-22');
});

test('POST /api/deals/export xlsx отдаёт spreadsheet content-type', function () {
    Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 10:00:00']);

    $r = $this->post('/api/deals/export', ['format' => 'xlsx']);

    $r->assertStatus(200);
    $r->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
});

test('POST /api/deals/export не экспортирует чужой tenant (RLS)', function () {
    $other = Tenant::factory()->create();
    DB::statement('SET app.current_tenant_id = '.$other->id);
    $foreignProject = Project::factory()->for($other)->create();
    Deal::factory()->for($other)->for($foreignProject)->create(['phone' => '+7 900 000-00-00']);

    DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
    $r = $this->post('/api/deals/export', ['format' => 'csv']);

    expect($r->streamedContent())->not->toContain('+7 900 000-00-00');
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; php artisan test --filter=DealExportTest`
Expected: FAIL — контроллер требует `ids`, валидация падает (422) / содержимое не совпадает.

- [ ] **Step 3: Реализация — переписать `DealExportController.php`**

Полностью заменить файл на:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Deal;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Style;
use OpenSpout\Writer\CSV\Options as CsvOptions;
use OpenSpout\Writer\CSV\Writer as CsvWriter;
use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
use Symfony\Component\HttpFoundation\StreamedResponse;

/**
 * Экспорт сделок в CSV / XLSX через OpenSpout streaming.
 *
 * Редизайн «Сделки» (2026-05-17): экспорт по ДИАПАЗОНУ ДАТ поставки
 * (received_at), не по списку id. Окно задаётся received_from/received_to;
 * оба опциональны (пусто = весь период). Колонки соответствуют таблице
 * страницы (без чекбокса и без «Напоминание» — экспорт = дамп лидов).
 *
 * RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe).
 */
class DealExportController extends Controller
{
    /** Заголовки — общие для CSV и XLSX. */
    private const HEADERS = ['Телефон', 'Источник', 'Город', 'Статус', 'Комментарий', 'Поставлен'];

    /** signal_type → русская метка для колонки «Источник». */
    private const SIGNAL_LABELS = ['call' => 'Звонки', 'site' => 'Сайт', 'sms' => 'СМС'];

    public function export(Request $request): StreamedResponse
    {
        $validated = $request->validate([
            'received_from' => 'nullable|date',
            'received_to' => 'nullable|date',
            'format' => 'nullable|string|in:csv,xlsx',
        ]);

        $tenantId = (int) $request->user()->tenant_id;
        $format = $validated['format'] ?? 'csv';
        $from = isset($validated['received_from']) && $validated['received_from'] !== ''
            ? Carbon::parse($validated['received_from'])->startOfDay() : null;
        $to = isset($validated['received_to']) && $validated['received_to'] !== ''
            ? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;

        $filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
        $headers = $format === 'xlsx'
            ? [
                'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
                'Content-Disposition' => 'attachment; filename="'.$filename.'"',
            ]
            : [
                'Content-Type' => 'text/csv; charset=utf-8',
                'Content-Disposition' => 'attachment; filename="'.$filename.'"',
            ];

        return new StreamedResponse(function () use ($tenantId, $format, $from, $to) {
            DB::transaction(function () use ($tenantId, $format, $from, $to) {
                DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);

                $statusNames = DB::table('lead_statuses')->pluck('name_ru', 'slug');

                $writer = $this->openWriter($format);
                $writer->openToFile('php://output');

                if ($format === 'xlsx') {
                    /** @var XlsxWriter $writer */
                    $writer->getCurrentSheet()->setName('Сделки');
                    $writer->addRow(Row::fromValuesWithStyle(self::HEADERS, (new Style)->withFontBold(true)));
                } else {
                    $writer->addRow(Row::fromValues(self::HEADERS));
                }

                $query = Deal::query()
                    ->where('tenant_id', $tenantId)
                    ->with('project:id,name,signal_type')
                    ->orderByDesc('received_at');
                if ($from !== null) {
                    $query->where('received_at', '>=', $from);
                }
                if ($to !== null) {
                    $query->where('received_at', '<', $to);
                }

                $query->chunkById(500, function ($deals) use ($writer, $statusNames) {
                    foreach ($deals as $deal) {
                        /** @var Deal $deal */
                        $signal = $deal->project?->signal_type;
                        $source = trim(($deal->project?->name ?? '—').' · '
                            .(self::SIGNAL_LABELS[$signal] ?? '—'));
                        $writer->addRow(Row::fromValues([
                            (string) $deal->phone,
                            $source,
                            (string) ($deal->city ?? ''),
                            (string) ($statusNames[$deal->status] ?? $deal->status),
                            (string) ($deal->comment ?? ''),
                            $deal->received_at?->toDateTimeString() ?? '',
                        ]));
                    }
                }, 'id');

                $writer->close();
            });
        }, 200, $headers);
    }

    private function openWriter(string $format): CsvWriter|XlsxWriter
    {
        if ($format === 'xlsx') {
            return new XlsxWriter;
        }

        return new CsvWriter(new CsvOptions(
            FIELD_DELIMITER: ';',
            FIELD_ENCLOSURE: '"',
            SHOULD_ADD_BOM: true,
        ));
    }
}
```

- [ ] **Step 4: Запустить — GREEN**

Run: `cd app; php artisan test --filter=DealExportTest`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Http/Controllers/Api/DealExportController.php app/tests/Feature/DealExportTest.php
git commit -m "feat(deals): DealExportController — export by delivery-date range, lead-registry columns"
```

### Task A6: `DealsFilters.vue` — переписать (телефон, Статус, Проект, Город)

**Files:**

- Modify: `app/resources/js/components/deals/DealsFilters.vue` (полная замена)
- Test: `app/tests/Frontend/DealsFilters.spec.ts` (создать)

Контракт: stateless presentation, состояние держит родитель через `v-model:*`. Все четыре фильтра — single-select (`string|number|null`). Проект — `id`, опции `{id,name}`. Город — `string`, опции из `availableCities` (сейчас пусто → select disabled).

- [ ] **Step 1: Failing-тест — создать `DealsFilters.spec.ts`**

```ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DealsFilters from '../../resources/js/components/deals/DealsFilters.vue';

const vuetify = createVuetify();
const baseProps = {
    searchPhone: '',
    filterStatus: null,
    filterProject: null,
    filterCity: null,
    leadStatuses: [{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#000' }],
    availableProjects: [{ id: 1, name: 'Окна' }],
    availableCities: [] as string[],
};

describe('DealsFilters', () => {
    it('рендерит поле поиска по телефону', () => {
        const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
        expect(w.find('[data-testid="filter-search-phone"]').exists()).toBe(true);
    });

    it('эмитит update:searchPhone при вводе', async () => {
        const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
        await w.find('[data-testid="filter-search-phone"] input').setValue('999');
        expect(w.emitted('update:searchPhone')?.at(-1)).toEqual(['999']);
    });

    it('город-селект disabled при пустом availableCities', () => {
        const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
        expect(w.find('[data-testid="filter-city"]').classes()).toContain('v-input--disabled');
    });

    it('кнопка сброса видна когда есть активный фильтр', () => {
        const w = mount(DealsFilters, {
            props: { ...baseProps, filterStatus: 'new' },
            global: { plugins: [vuetify] },
        });
        expect(w.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
    });
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; npx vitest run tests/Frontend/DealsFilters.spec.ts`
Expected: FAIL — компонент ещё старый (нет `filter-search-phone`).

- [ ] **Step 3: Реализация — переписать `DealsFilters.vue`**

```vue
<script setup lang="ts">
/**
 * Фильтр-бар реестра «Сделки»: поиск по телефону + 3 select'а (Статус, Проект,
 * Город). Состояние держит родитель через v-model:*. Город — пока без данных
 * (источник §4 спеки не определён): select disabled при пустом availableCities.
 */
import type { LeadStatus } from '../../composables/leadStatuses';

const props = defineProps<{
    searchPhone: string;
    filterStatus: string | null;
    filterProject: number | null;
    filterCity: string | null;
    leadStatuses: LeadStatus[];
    availableProjects: { id: number; name: string }[];
    availableCities: string[];
}>();

defineEmits<{
    'update:searchPhone': [value: string];
    'update:filterStatus': [value: string | null];
    'update:filterProject': [value: number | null];
    'update:filterCity': [value: string | null];
    'clear-filters': [];
}>();

const hasActiveFilter = () =>
    props.filterStatus !== null || props.filterProject !== null || props.filterCity !== null;
</script>

<template>
    <div class="deals-filters">
        <v-text-field
            :model-value="searchPhone"
            placeholder="Поиск по телефону…"
            prepend-inner-icon="mdi-magnify"
            variant="outlined"
            density="compact"
            hide-details
            clearable
            class="filters-search"
            data-testid="filter-search-phone"
            @update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
        />
        <v-select
            :model-value="filterStatus"
            :items="leadStatuses"
            item-title="nameRu"
            item-value="slug"
            label="Статус"
            variant="outlined"
            density="compact"
            hide-details
            clearable
            class="filters-select"
            data-testid="filter-status"
            @update:model-value="(v: string | null) => $emit('update:filterStatus', v ?? null)"
        />
        <v-select
            :model-value="filterProject"
            :items="availableProjects"
            item-title="name"
            item-value="id"
            label="Проект"
            variant="outlined"
            density="compact"
            hide-details
            clearable
            class="filters-select"
            data-testid="filter-project"
            @update:model-value="(v: number | null) => $emit('update:filterProject', v ?? null)"
        />
        <v-select
            :model-value="filterCity"
            :items="availableCities"
            label="Город"
            variant="outlined"
            density="compact"
            hide-details
            clearable
            :disabled="availableCities.length === 0"
            class="filters-select"
            data-testid="filter-city"
            @update:model-value="(v: string | null) => $emit('update:filterCity', v ?? null)"
        />
        <v-btn
            v-if="hasActiveFilter()"
            variant="text"
            size="small"
            prepend-icon="mdi-filter-off"
            data-testid="clear-filters-btn"
            @click="$emit('clear-filters')"
        >
            Сбросить
        </v-btn>
    </div>
</template>

<style scoped>
.deals-filters {
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
}
.filters-search {
    flex: 1 1 240px;
    max-width: 320px;
}
.filters-select {
    min-width: 170px;
    max-width: 220px;
}
</style>
```

- [ ] **Step 4: Запустить — GREEN**

Run: `cd app; npx vitest run tests/Frontend/DealsFilters.spec.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/components/deals/DealsFilters.vue app/tests/Frontend/DealsFilters.spec.ts
git commit -m "feat(deals): DealsFilters — phone search + Status/Project/City selects"
```

### Task A7: `DealsTable.vue` — новый набор колонок

**Files:**

- Modify: `app/resources/js/components/deals/DealsTable.vue` (полная замена)
- Test: `app/tests/Frontend/DealsTable.spec.ts`

Колонки: чекбокс · Телефон · Источник · Город · Статус · Напоминание · Комментарий · Поставлен. Клик по строке → `row-click`. Активная строка (открыта панель) подсвечивается через `activeDealId`.

- [ ] **Step 1: Обновить тест `DealsTable.spec.ts`**

Полностью заменить файл:

```ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';

const vuetify = createVuetify();

const sampleDeals: MockDeal[] = [
    {
        id: 1, name: '+7 (916) 100-00-01', phone: '+7 (916) 100-00-01', statusSlug: 'new',
        project: 'Окна', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
        signalType: 'call', city: 'Москва', comment: 'звонил', receivedAt: '2026-05-15T09:00:00+00:00',
        nextReminderAt: '2026-05-18T07:00:00+00:00',
    },
    {
        id: 2, name: '+7 (916) 100-00-02', phone: '+7 (916) 100-00-02', statusSlug: 'new',
        project: 'Двери', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 30,
        signalType: 'site', city: null, comment: null, receivedAt: '2026-05-14T09:00:00+00:00',
        nextReminderAt: null,
    },
];

describe('DealsTable', () => {
    it('рендерит колонки реестра лидов', () => {
        const w = mount(DealsTable, {
            props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
            global: { plugins: [vuetify] },
        });
        const headers = w.findAll('thead th').map((h) => h.text());
        ['Телефон', 'Источник', 'Город', 'Статус', 'Напоминание', 'Комментарий', 'Поставлен'].forEach((label) => {
            expect(headers.some((h) => h.includes(label))).toBe(true);
        });
    });

    it('город без значения рендерится как «—»', () => {
        const w = mount(DealsTable, {
            props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
            global: { plugins: [vuetify] },
        });
        expect(w.text()).toContain('—');
    });

    it('select-all чекбокс имеет aria-label', () => {
        const w = mount(DealsTable, {
            props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
            global: { plugins: [vuetify] },
        });
        expect(
            w.find('th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]').exists(),
        ).toBe(true);
    });

    it('клик по строке эмитит row-click с deal', async () => {
        const w = mount(DealsTable, {
            props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
            global: { plugins: [vuetify] },
        });
        await w.find('tbody tr').trigger('click');
        expect(w.emitted('row-click')?.[0]?.[0]).toMatchObject({ id: 1 });
    });
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; npx vitest run tests/Frontend/DealsTable.spec.ts`
Expected: FAIL — старые колонки (Лид/Менеджер/Стоимость/Время).

- [ ] **Step 3: Реализация — переписать `DealsTable.vue`**

```vue
<script setup lang="ts">
/**
 * Таблица реестра лидов «Сделки» (редизайн 2026-05-17).
 * Колонки: чекбокс · Телефон · Источник · Город · Статус · Напоминание ·
 * Комментарий · Поставлен. Напоминание/Комментарий — read-only.
 */
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import StatusPill from '../ui/StatusPill.vue';

const props = withDefaults(
    defineProps<{
        deals: MockDeal[];
        selectedIds: number[];
        statusBySlug: Map<string, LeadStatus>;
        activeDealId?: number | null;
    }>(),
    { activeDealId: null },
);

const emit = defineEmits<{
    'update:selectedIds': [value: number[]];
    'row-click': [deal: MockDeal];
}>();

const SIGNAL_LABELS: Record<string, string> = { call: 'Звонки', site: 'Сайт', sms: 'СМС' };

function signalLabel(t: MockDeal['signalType']): string {
    return t ? (SIGNAL_LABELS[t] ?? '') : '';
}

function formatDateTime(iso: string | null | undefined): string {
    if (!iso) return '—';
    const d = new Date(iso);
    return new Intl.DateTimeFormat('ru-RU', {
        day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
    }).format(d);
}

function rowProps(deal: MockDeal): Record<string, unknown> {
    return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
}
</script>

<template>
    <v-card variant="outlined" class="deals-table-card">
        <v-data-table
            :model-value="selectedIds"
            :items="deals"
            :headers="[
                { title: 'Телефон', key: 'phone', sortable: true },
                { title: 'Источник', key: 'project', sortable: false },
                { title: 'Город', key: 'city', sortable: false },
                { title: 'Статус', key: 'statusSlug', sortable: false },
                { title: 'Напоминание', key: 'nextReminderAt', sortable: true },
                { title: 'Комментарий', key: 'comment', sortable: false },
                { title: 'Поставлен', key: 'receivedAt', align: 'end', sortable: true },
            ]"
            show-select
            item-value="id"
            items-per-page="-1"
            hide-default-footer
            hover
            :row-props="(p: { item: MockDeal }) => rowProps(p.item)"
            @update:model-value="(v: number[]) => emit('update:selectedIds', v)"
            @click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
        >
            <template #[`item.phone`]="{ item }: { item: MockDeal }">
                <span class="num ld-mono">{{ item.phone }}</span>
            </template>

            <template #[`item.project`]="{ item }: { item: MockDeal }">
                <div class="cell-source">
                    <span class="source-project">{{ item.project }}</span>
                    <span v-if="signalLabel(item.signalType)" class="source-signal">{{
                        signalLabel(item.signalType)
                    }}</span>
                </div>
            </template>

            <template #[`item.city`]="{ item }: { item: MockDeal }">
                <span :class="{ 'text-medium-emphasis': !item.city }">{{ item.city || '—' }}</span>
            </template>

            <template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
                <StatusPill
                    :slug="item.statusSlug"
                    :label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
                />
            </template>

            <template #[`item.nextReminderAt`]="{ item }: { item: MockDeal }">
                <span class="num ld-mono-s" :class="{ 'text-medium-emphasis': !item.nextReminderAt }">{{
                    formatDateTime(item.nextReminderAt)
                }}</span>
            </template>

            <template #[`item.comment`]="{ item }: { item: MockDeal }">
                <span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
                    item.comment || '—'
                }}</span>
            </template>

            <template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
                <span class="num ld-mono-s">{{ formatDateTime(item.receivedAt) }}</span>
            </template>

            <template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
                <v-checkbox-btn
                    :model-value="allSelected"
                    :indeterminate="someSelected && !allSelected"
                    aria-label="Выбрать все сделки"
                    @update:model-value="selectAll"
                />
            </template>

            <template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
                <v-checkbox-btn
                    :model-value="isSelected(internalItem)"
                    :aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
                    @update:model-value="() => toggleSelect(internalItem)"
                />
            </template>
        </v-data-table>

        <div v-if="deals.length === 0" class="empty-state pa-8 text-center text-medium-emphasis">
            Нет сделок по выбранным фильтрам
        </div>
    </v-card>
</template>

<style scoped>
.deals-table-card {
    background: #fff;
}
.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
}
.cell-source {
    display: flex;
    flex-direction: column;
    line-height: 1.3;
}
.source-project {
    font-weight: 500;
    color: #081319;
}
.source-signal {
    font-size: 11px;
    color: #6b6356;
}
.cell-comment {
    display: inline-block;
    max-width: 240px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    vertical-align: bottom;
}
:deep(.deals-row-active) {
    background: rgba(15, 110, 86, 0.07);
}
</style>
```

- [ ] **Step 4: Запустить — GREEN**

Run: `cd app; npx vitest run tests/Frontend/DealsTable.spec.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/components/deals/DealsTable.vue app/tests/Frontend/DealsTable.spec.ts
git commit -m "feat(deals): DealsTable — lead-registry columns (Телефон/Источник/Город/Статус/Напоминание/Комментарий/Поставлен)"
```

### Task A8: `DealsBulkBar.vue` — только смена статуса

**Files:**

- Modify: `app/resources/js/components/deals/DealsBulkBar.vue` (полная замена)
- Test: `app/tests/Frontend/DealsBulkBar.spec.ts` (создать)

Убираем Экспорт/Удалить/Восстановить и `trashMode`. Остаётся: «Сменить статус» (меню) + ✕.

- [ ] **Step 1: Failing-тест — создать `DealsBulkBar.spec.ts`**

```ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DealsBulkBar from '../../resources/js/components/deals/DealsBulkBar.vue';

const vuetify = createVuetify();
const leadStatuses = [
    { slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
    { slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#8B5CF6' },
];

describe('DealsBulkBar', () => {
    it('скрыт при selectedCount=0', () => {
        const w = mount(DealsBulkBar, {
            props: { selectedCount: 0, statusMenuOpen: false, leadStatuses },
            global: { plugins: [vuetify] },
        });
        expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false);
    });

    it('виден при selectedCount>0 и показывает количество', () => {
        const w = mount(DealsBulkBar, {
            props: { selectedCount: 3, statusMenuOpen: false, leadStatuses },
            global: { plugins: [vuetify] },
        });
        const bar = w.find('[data-testid="bulk-bar"]');
        expect(bar.exists()).toBe(true);
        expect(bar.text()).toContain('3');
    });

    it('НЕ содержит кнопок Экспорт/Удалить', () => {
        const w = mount(DealsBulkBar, {
            props: { selectedCount: 2, statusMenuOpen: false, leadStatuses },
            global: { plugins: [vuetify] },
        });
        expect(w.find('[data-testid="bulk-export-btn"]').exists()).toBe(false);
        expect(w.find('[data-testid="bulk-delete-btn"]').exists()).toBe(false);
    });

    it('✕ эмитит clear-selected', async () => {
        const w = mount(DealsBulkBar, {
            props: { selectedCount: 2, statusMenuOpen: false, leadStatuses },
            global: { plugins: [vuetify] },
        });
        await w.find('[data-testid="bulk-clear-btn"]').trigger('click');
        expect(w.emitted('clear-selected')).toBeTruthy();
    });
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; npx vitest run tests/Frontend/DealsBulkBar.spec.ts`
Expected: FAIL — компонент требует prop `trashMode` / содержит лишние кнопки.

- [ ] **Step 3: Реализация — переписать `DealsBulkBar.vue`**

```vue
<script setup lang="ts">
/**
 * Sticky-bar массовой смены статуса для выбранных сделок (редизайн 2026-05-17).
 * Только смена статуса — корзина/экспорт убраны (экспорт — панель по датам).
 */
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';

defineProps<{
    selectedCount: number;
    statusMenuOpen: boolean;
    leadStatuses: LeadStatus[];
}>();

defineEmits<{
    'update:statusMenuOpen': [value: boolean];
    'apply-status': [slug: MockDeal['statusSlug']];
    'clear-selected': [];
}>();
</script>

<template>
    <v-card
        v-if="selectedCount > 0"
        class="bulk-bar mt-3"
        color="secondary"
        theme="dark"
        variant="flat"
        data-testid="bulk-bar"
    >
        <div class="bulk-bar-inner">
            <span class="bulk-count">Выбрано <span class="num">{{ selectedCount }}</span></span>
            <v-spacer />
            <v-menu
                :model-value="statusMenuOpen"
                :close-on-content-click="false"
                @update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
            >
                <template #activator="{ props: menuProps }">
                    <v-btn
                        v-bind="menuProps"
                        variant="tonal"
                        size="small"
                        prepend-icon="mdi-tag-arrow-right"
                        data-testid="bulk-status-btn"
                    >
                        Сменить статус
                    </v-btn>
                </template>
                <v-list density="compact" max-height="320" min-width="240">
                    <v-list-item
                        v-for="s in leadStatuses"
                        :key="s.slug"
                        :data-testid="`bulk-status-item-${s.slug}`"
                        @click="$emit('apply-status', s.slug)"
                    >
                        <template #prepend>
                            <span class="status-dot" :style="{ background: s.colorHex }" />
                        </template>
                        <v-list-item-title>{{ s.nameRu }}</v-list-item-title>
                    </v-list-item>
                </v-list>
            </v-menu>
            <v-btn
                icon="mdi-close"
                variant="text"
                size="small"
                data-testid="bulk-clear-btn"
                @click="$emit('clear-selected')"
            />
        </div>
    </v-card>
</template>

<style scoped>
.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
    font-weight: 500;
}
.status-dot {
    display: inline-block;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    margin-right: 6px;
}
.bulk-bar {
    position: sticky;
    top: 0;
    z-index: 4;
}
.bulk-bar-inner {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 12px;
}
.bulk-count {
    color: #f6f3ec;
    font-size: 13px;
}
</style>
```

- [ ] **Step 4: Запустить — GREEN**

Run: `cd app; npx vitest run tests/Frontend/DealsBulkBar.spec.ts`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/resources/js/components/deals/DealsBulkBar.vue app/tests/Frontend/DealsBulkBar.spec.ts
git commit -m "feat(deals): DealsBulkBar — status-change only (drop export/delete/trash)"
```

### Task A9: Извлечь `DealDetailBody.vue` + обёртка `DealDetailDrawer.vue` (overlay/inline)

**Files:**

- Create: `app/resources/js/components/deals/DealDetailBody.vue`
- Modify: `app/resources/js/components/deals/DealDetailDrawer.vue` (полная замена — тонкая обёртка)
- Test: `app/tests/Frontend/DealDetailDrawer.spec.ts`, `app/tests/Frontend/DealDetailDrawerApi.spec.ts`

Тело панели (hero/параметры/комментарий/напоминания/timeline + загрузка events/reminders) → `DealDetailBody.vue`. `DealDetailDrawer.vue` → обёртка: prop `inline` выбирает корень — `<v-navigation-drawer>` (overlay, для Канбана) или `<aside>` (inline, master-detail для «Сделок»).

- [ ] **Step 1: Failing-тест — `DealDetailDrawer.spec.ts`**

Заменить `tests/Frontend/DealDetailDrawer.spec.ts` на:

```ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';

const vuetify = createVuetify();
const deal: MockDeal = {
    id: 1, name: '+7 999', phone: '+7 999', statusSlug: 'new', project: 'Окна',
    manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
};

function mountDrawer(props: Record<string, unknown>) {
    setActivePinia(createPinia());
    return mount(DealDetailDrawer, {
        props: { open: true, deal, ...props },
        global: { plugins: [vuetify, createPinia()], stubs: { DealDetailBody: true } },
    });
}

describe('DealDetailDrawer wrapper', () => {
    it('inline=true рендерит <aside> (master-detail панель)', () => {
        const w = mountDrawer({ inline: true });
        expect(w.find('aside.deal-detail-inline').exists()).toBe(true);
    });

    it('inline=false (по умолчанию) рендерит overlay v-navigation-drawer', () => {
        const w = mountDrawer({});
        expect(w.find('aside.deal-detail-inline').exists()).toBe(false);
    });

    it('inline-панель содержит DealDetailBody', () => {
        const w = mountDrawer({ inline: true });
        expect(w.findComponent({ name: 'DealDetailBody' }).exists()).toBe(true);
    });
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; npx vitest run tests/Frontend/DealDetailDrawer.spec.ts`
Expected: FAIL — нет `inline`-режима, нет `<aside.deal-detail-inline>`.

- [ ] **Step 3: Создать `DealDetailBody.vue`**

Перенести в новый файл всё содержимое текущего `DealDetailDrawer.vue` КРОМЕ `<v-navigation-drawer>`-обёртки. Изменения против оригинала: prop `open` убран; `drawerOpen` computed убран; `watch` слушает `[props.deal?.id, props.tenantId]`; hero `@close` ре-эмитит `close`.

```vue
<script setup lang="ts">
/**
 * Тело панели деталей сделки (hero + параметры + комментарий + напоминания +
 * timeline). Извлечено из DealDetailDrawer (редизайн 2026-05-17) — общее тело
 * для overlay-дровера (Канбан) и inline-панели master-detail («Сделки»).
 *
 * Backend: GET /api/deals/{id}, PATCH /api/deals/{id}, GET /api/deals/{id}/events.
 */
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import { type DealEvent } from '../../composables/mockDealEvents';
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
import * as dealsApi from '../../api/deals';
import * as remindersApi from '../../api/reminders';
import type { ApiReminder } from '../../api/reminders';
import { useLeadStatusesStore } from '../../stores/leadStatuses';
import DealDetailHero from './DealDetailHero.vue';
import DealDetailTimeline from './DealDetailTimeline.vue';
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));

const leadStatusesStore = useLeadStatusesStore();

const props = defineProps<{
    deal: MockDeal | null;
    tenantId?: number;
}>();

const emit = defineEmits<{ close: [] }>();

const status = computed(() => {
    if (!props.deal) return null;
    return leadStatusesStore.findBySlug(props.deal.statusSlug);
});

function formatCost(cost: number): string {
    return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
}

const events = ref<DealEvent[]>([]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);

const commentDraft = ref<string>('');
const commentSaving = ref(false);
const commentSaveError = ref(false);
const commentToastOpen = ref(false);
const commentToastText = ref('');

const reminders = ref<ApiReminder[]>([]);
const remindersLoading = ref(false);
const reminderDialogOpen = ref(false);

async function loadReminders() {
    if (!props.deal || !props.tenantId) {
        reminders.value = [];
        return;
    }
    remindersLoading.value = true;
    try {
        const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
        reminders.value = res.items;
    } catch {
        reminders.value = [];
    } finally {
        remindersLoading.value = false;
    }
}

async function completeReminderInDrawer(id: number) {
    try {
        await remindersApi.completeReminder(id);
        reminders.value = reminders.value.filter((r) => r.id !== id);
    } catch {
        /* silent */
    }
}

function onReminderSaved() {
    void loadReminders();
}

function formatReminderTime(iso: string | null): string {
    if (!iso) return '—';
    const ms = new Date(iso).getTime() - Date.now();
    const min = Math.round(Math.abs(ms) / 60_000);
    const future = ms > 0;
    if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
    const hr = Math.round(min / 60);
    if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
    const days = Math.round(hr / 24);
    return future ? `через ${days} д` : `${days} д назад`;
}

async function loadEvents() {
    if (!props.deal || !props.tenantId) {
        events.value = [];
        commentDraft.value = '';
        return;
    }
    eventsLoading.value = true;
    eventsFetchError.value = false;
    try {
        const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
        events.value = res.events.map((e) => mapApiDealEvent(e));
        commentDraft.value = res.deal.comment ?? '';
    } catch {
        eventsFetchError.value = true;
        events.value = [];
        commentDraft.value = '';
    } finally {
        eventsLoading.value = false;
    }
}

async function saveComment() {
    if (!props.deal || !props.tenantId) return;
    commentSaving.value = true;
    commentSaveError.value = false;
    try {
        await dealsApi.updateDeal(props.deal.id, {
            tenant_id: props.tenantId,
            comment: commentDraft.value || null,
        });
        commentToastText.value = 'Комментарий сохранён.';
        commentToastOpen.value = true;
        await loadEvents();
    } catch {
        commentSaveError.value = true;
        commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
        commentToastOpen.value = true;
    } finally {
        commentSaving.value = false;
    }
}

// Загрузка при появлении/смене сделки (панель монтируется только при deal != null).
watch(
    () => [props.deal?.id, props.tenantId] as const,
    () => {
        if (props.deal) {
            loadEvents();
            void loadReminders();
        }
    },
    { immediate: true },
);

defineExpose({
    events, eventsLoading, eventsFetchError, loadEvents,
    commentDraft, commentSaving, commentSaveError, commentToastOpen, commentToastText, saveComment,
});
</script>

<template>
    <div v-if="deal" class="drawer-content">
        <DealDetailHero :deal="deal" :status="status" @close="emit('close')" />

        <v-divider />

        <section class="section pa-5">
            <h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
            <dl class="params">
                <div class="param">
                    <dt class="text-caption text-medium-emphasis">Проект</dt>
                    <dd class="text-body-2">{{ deal.project }}</dd>
                </div>
                <div class="param">
                    <dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
                    <dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
                </div>
                <div class="param">
                    <dt class="text-caption text-medium-emphasis">Менеджер</dt>
                    <dd class="text-body-2">
                        <v-avatar size="20" color="secondary" class="mr-1">
                            <span class="text-caption">{{ deal.manager.initials }}</span>
                        </v-avatar>
                        {{ deal.manager.name }}
                    </dd>
                </div>
                <div class="param">
                    <dt class="text-caption text-medium-emphasis">Источник</dt>
                    <dd class="text-body-2 link">Я.Директ → landing-1</dd>
                </div>
            </dl>
        </section>

        <v-divider />

        <section v-if="tenantId" class="section pa-5" data-testid="comment-section">
            <h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
            <v-textarea
                v-model="commentDraft"
                placeholder="Заметка менеджера…"
                variant="outlined"
                density="comfortable"
                auto-grow
                rows="3"
                hide-details
                counter="5000"
                data-testid="comment-textarea"
            />
            <div class="d-flex ga-2 mt-2 justify-end">
                <v-btn
                    :loading="commentSaving"
                    color="primary"
                    size="small"
                    prepend-icon="mdi-content-save-outline"
                    data-testid="save-comment-btn"
                    @click="saveComment"
                >
                    Сохранить
                </v-btn>
            </div>
        </section>

        <v-divider v-if="tenantId" />

        <section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
            <div class="d-flex justify-space-between align-center mb-3">
                <h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
                <v-btn
                    size="x-small"
                    variant="text"
                    prepend-icon="mdi-plus"
                    data-testid="add-reminder-btn"
                    @click="reminderDialogOpen = true"
                >
                    Создать
                </v-btn>
            </div>
            <div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
                Нет активных напоминаний.
            </div>
            <ul v-else class="reminders-list">
                <li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
                    <v-btn
                        icon="mdi-check-circle-outline"
                        size="x-small"
                        variant="text"
                        density="comfortable"
                        :data-testid="`drawer-complete-${r.id}`"
                        @click="completeReminderInDrawer(r.id)"
                    />
                    <div class="reminder-body">
                        <div class="reminder-text">{{ r.text || 'Без описания' }}</div>
                        <div class="reminder-meta text-caption text-medium-emphasis">
                            {{ formatReminderTime(r.remind_at) }}
                        </div>
                    </div>
                </li>
            </ul>
        </section>

        <v-divider v-if="tenantId && deal" />

        <DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />

        <v-snackbar
            v-model="commentToastOpen"
            :timeout="3000"
            :color="commentSaveError ? 'warning' : undefined"
            data-testid="comment-toast"
            location="bottom right"
        >
            {{ commentToastText }}
        </v-snackbar>

        <ReminderDialog
            v-if="tenantId && deal"
            v-model="reminderDialogOpen"
            :deal-id="deal.id"
            @saved="onReminderSaved"
        />
    </div>
</template>

<style scoped>
.drawer-content {
    display: flex;
    flex-direction: column;
}
.section-title {
    font-weight: 600;
    color: #081319;
}
.params {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 16px 12px;
    margin: 0;
}
.param dt {
    font-size: 11px;
    margin-bottom: 2px;
}
.param dd {
    margin: 0;
    color: #081319;
}
.param .link {
    color: #0f6e56;
    cursor: pointer;
}
.param .link:hover {
    text-decoration: underline;
}
.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
}
.reminders-list {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: 6px;
}
.reminder-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 8px;
    border: 1px solid #e8e3d6;
    border-radius: 6px;
    background: #fdfaf3;
}
.reminder-body {
    flex: 1;
    min-width: 0;
}
.reminder-text {
    font-size: 13px;
    line-height: 1.4;
    color: #081319;
}
.reminder-meta {
    margin-top: 2px;
}
</style>
```

- [ ] **Step 4: Переписать `DealDetailDrawer.vue` как обёртку**

```vue
<script setup lang="ts">
/**
 * Обёртка панели деталей сделки. `inline=false` (по умолчанию) — overlay
 * v-navigation-drawer (Канбан). `inline=true` — боковая панель master-detail
 * для страницы «Сделки» (список сжимается, панель встаёт рядом, не перекрывает).
 * Тело — общий DealDetailBody.vue.
 */
import { computed } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import DealDetailBody from './DealDetailBody.vue';

const props = withDefaults(
    defineProps<{
        open: boolean;
        deal: MockDeal | null;
        tenantId?: number;
        inline?: boolean;
    }>(),
    { inline: false },
);

const emit = defineEmits<{ 'update:open': [value: boolean] }>();

const drawerOpen = computed({
    get: () => props.open,
    set: (v) => emit('update:open', v),
});

function close() {
    emit('update:open', false);
}
</script>

<template>
    <aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
        <DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
    </aside>
    <v-navigation-drawer
        v-else
        v-model="drawerOpen"
        location="right"
        temporary
        :width="480"
        class="deal-drawer"
    >
        <DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
    </v-navigation-drawer>
</template>

<style scoped>
.deal-drawer {
    background: #fff;
}
.deal-detail-inline {
    flex: 0 0 400px;
    width: 400px;
    background: #fff;
    border: 1px solid #e8e3d6;
    border-radius: 8px;
    overflow-y: auto;
    align-self: flex-start;
    max-height: calc(100vh - 160px);
    position: sticky;
    top: 16px;
}
</style>
```

- [ ] **Step 5: Обновить `DealDetailDrawerApi.spec.ts`**

`defineExpose` (events/loadEvents/saveComment/...) переехал в `DealDetailBody`. В `DealDetailDrawerApi.spec.ts` заменить импорт и mount-цель с `DealDetailDrawer` на `DealDetailBody` (`import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue'`), убрать prop `open` из props, заменить проверку открытия на наличие `deal`. Остальная логика теста (мок `dealsApi.getDeal`, проверки `vm.events`/`vm.saveComment`) — без изменений.

- [ ] **Step 6: Запустить — GREEN**

Run: `cd app; npx vitest run tests/Frontend/DealDetailDrawer.spec.ts tests/Frontend/DealDetailDrawerApi.spec.ts`
Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add app/resources/js/components/deals/DealDetailBody.vue app/resources/js/components/deals/DealDetailDrawer.vue app/tests/Frontend/DealDetailDrawer.spec.ts app/tests/Frontend/DealDetailDrawerApi.spec.ts
git commit -m "refactor(deals): extract DealDetailBody; DealDetailDrawer = overlay/inline wrapper"
```

### Task A10: `DealsView.vue` — полный переписанный экран

**Files:**

- Modify: `app/resources/js/views/DealsView.vue` (полная замена)
- Test: `app/tests/Frontend/DealsView.spec.ts` (переписывается в A11)

Реестр лидов: server-side фильтрация + пагинация. Убраны trash-режим, `NewDealDialog`, «Новая сделка», `.ld-filterbar`/`FilterChip`/`DensityToggle`/status-legend, табы. Добавлены: панель экспорта (диапазон дат + Excel/CSV), селектор строк 10/20/50, master-detail панель (`DealDetailDrawer inline`).

- [ ] **Step 1: Реализация — переписать `DealsView.vue`**

```vue
<script setup lang="ts">
/**
 * Страница «Сделки» — реестр лидов, поставленных crm.bp (редизайн 2026-05-17).
 *
 * Лиды поступают ТОЛЬКО от поставщика — ручного создания и корзины нет.
 * Фильтрация (телефон/Статус/Проект + диапазон дат поставки) и пагинация —
 * server-side через GET /api/deals. Клик по строке открывает master-detail
 * панель справа (список сжимается, панель не перекрывает таблицу).
 *
 * Спека: docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md
 */
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import type { MockDeal } from '../composables/mockDeals';
import { mapApiDeal } from '../composables/dealsApiMapper';
import { usePolling } from '../composables/usePolling';
import DealsFilters from '../components/deals/DealsFilters.vue';
import DealsBulkBar from '../components/deals/DealsBulkBar.vue';
import DealsTable from '../components/deals/DealsTable.vue';
import DealDetailDrawer from '../components/deals/DealDetailDrawer.vue';
import { useAuthStore } from '../stores/auth';
import { useLeadStatusesStore } from '../stores/leadStatuses';
import * as dealsApi from '../api/deals';
import { triggerBlobDownload, triggerCsvDownload } from '../composables/useCsvDownload';

const route = useRoute();
const auth = useAuthStore();
const leadStatusesStore = useLeadStatusesStore();

// --- Фильтры (single-select) + диапазон дат поставки ---
const searchPhone = ref('');
const filterStatus = ref<string | null>(null);
const filterProject = ref<number | null>(null);
const filterCity = ref<string | null>(null);
const receivedFrom = ref(''); // 'YYYY-MM-DD'
const receivedTo = ref('');

// --- Пагинация ---
const PER_PAGE_OPTIONS = [10, 20, 50];
const perPage = ref(20);
const page = ref(1);

// --- Данные текущей страницы ---
const dealsState = reactive<MockDeal[]>([]);
const total = ref(0);
const loading = ref(false);
const fetchError = ref(false);
const availableProjects = ref<dealsApi.ApiProject[]>([]);

const leadStatuses = computed(() => leadStatusesStore.statuses);
const statusBySlug = computed(() => leadStatusesStore.bySlug);
const availableCities = computed(() =>
    Array.from(new Set(dealsState.map((d) => d.city).filter((c): c is string => !!c))).sort(),
);
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)));

// --- Master-detail панель ---
const selectedDeal = ref<MockDeal | null>(null);
const panelOpen = ref(false);

// --- Bulk-смена статуса ---
const selected = ref<number[]>([]);
const statusMenuOpen = ref(false);

// --- Тосты ---
const exportToastOpen = ref(false);
const exportToastText = ref('');
const statusToastOpen = ref(false);
const statusToastText = ref('');

async function loadDeals() {
    if (!auth.user?.tenant_id) return;
    loading.value = true;
    fetchError.value = false;
    try {
        const res = await dealsApi.listDeals({
            tenantId: auth.user.tenant_id,
            statusIn: filterStatus.value ? [filterStatus.value] : undefined,
            projectId: filterProject.value ?? undefined,
            search: searchPhone.value.trim() || undefined,
            receivedFrom: receivedFrom.value || undefined,
            receivedTo: receivedTo.value || undefined,
            limit: perPage.value,
            offset: (page.value - 1) * perPage.value,
        });
        total.value = res.total;
        dealsState.splice(0, dealsState.length, ...res.deals.map((d) => mapApiDeal(d)));
    } catch {
        fetchError.value = true;
        dealsState.splice(0, dealsState.length);
        total.value = 0;
    } finally {
        loading.value = false;
    }
}

async function loadProjects() {
    if (!auth.user?.tenant_id) return;
    try {
        availableProjects.value = await dealsApi.listProjects(auth.user.tenant_id);
    } catch {
        availableProjects.value = [];
    }
}

// Фильтры (кроме поиска) и perPage → сброс на стр.1 + перезагрузка.
watch([filterStatus, filterProject, receivedFrom, receivedTo, perPage], () => {
    page.value = 1;
    void loadDeals();
});
watch(page, () => void loadDeals());

// Поиск по телефону — debounce 350 мс.
let searchTimer: ReturnType<typeof setTimeout> | undefined;
watch(searchPhone, () => {
    if (searchTimer) clearTimeout(searchTimer);
    searchTimer = setTimeout(() => {
        page.value = 1;
        void loadDeals();
    }, 350);
});

function clearFilters() {
    searchPhone.value = '';
    filterStatus.value = null;
    filterProject.value = null;
    filterCity.value = null;
}

async function applyBulkStatus(slug: MockDeal['statusSlug']) {
    const ids = [...selected.value];
    statusMenuOpen.value = false;
    ids.forEach((id) => {
        const d = dealsState.find((x) => x.id === id);
        if (d) d.statusSlug = slug;
    });
    if (!auth.user?.tenant_id || ids.length === 0) return;
    try {
        const res = await dealsApi.transitionDeals({ tenant_id: auth.user.tenant_id, ids, status: slug });
        statusToastText.value = `Обновлено ${res.updated} из ${res.requested}.`;
    } catch {
        statusToastText.value = 'Не удалось сохранить статус — изменения только локально.';
    }
    statusToastOpen.value = true;
    selected.value = [];
}

async function exportByRange(format: 'xlsx' | 'csv') {
    if (!auth.user?.tenant_id) {
        exportToastText.value = 'Нет данных для экспорта.';
        exportToastOpen.value = true;
        return;
    }
    try {
        const result = await dealsApi.exportDealsByRange({
            tenant_id: auth.user.tenant_id,
            received_from: receivedFrom.value || undefined,
            received_to: receivedTo.value || undefined,
            format,
        });
        const stamp = new Date().toISOString().slice(0, 10);
        if (format === 'xlsx') {
            triggerBlobDownload(result as Blob, `deals_export_${stamp}.xlsx`);
            exportToastText.value = 'Экспорт XLSX сформирован.';
        } else {
            triggerCsvDownload(result as string, `deals_export_${stamp}.csv`);
            exportToastText.value = 'Экспорт CSV сформирован.';
        }
    } catch {
        exportToastText.value = 'Не удалось сформировать экспорт.';
    }
    exportToastOpen.value = true;
}

function openPanel(deal: MockDeal) {
    if (panelOpen.value && selectedDeal.value?.id === deal.id) {
        closePanel();
        return;
    }
    selectedDeal.value = deal;
    panelOpen.value = true;
}

function closePanel() {
    panelOpen.value = false;
}

/** Deep-link /deals?openId= — открыть панель сделки с текущей страницы (audit C8/F3). */
function openDealFromQuery(): void {
    const raw = route.query.openId;
    const id = Number(Array.isArray(raw) ? raw[0] : raw);
    if (!Number.isInteger(id) || id <= 0) return;
    const deal = dealsState.find((d) => d.id === id);
    if (deal) openPanel(deal);
}

onMounted(async () => {
    void leadStatusesStore.load();
    void loadProjects();
    await loadDeals();
    openDealFromQuery();
});

watch(() => route.query.openId, () => openDealFromQuery());

// Polling — авто-refresh текущей страницы (pause при скрытой вкладке).
usePolling(loadDeals);

defineExpose({
    searchPhone, filterStatus, filterProject, filterCity, receivedFrom, receivedTo,
    perPage, page, pageCount, dealsState, total, loading, fetchError, availableProjects,
    selected, selectedDeal, panelOpen, statusMenuOpen,
    loadDeals, clearFilters, applyBulkStatus, exportByRange, openPanel, closePanel,
    exportToastOpen, exportToastText, statusToastOpen, statusToastText,
});
</script>

<template>
    <v-container fluid class="deals pa-6">
        <header class="page-head">
            <div>
                <h1 class="text-h4 mb-2 page-title">Сделки</h1>
                <div class="page-stats text-body-2 text-medium-emphasis">
                    Реестр лидов от crm.bp ·
                    <span class="num">{{ total }}</span> в выборке
                </div>
            </div>
            <v-btn
                variant="outlined"
                prepend-icon="mdi-refresh"
                :loading="loading"
                data-testid="reload-btn"
                @click="loadDeals"
            >
                Обновить
            </v-btn>
        </header>

        <!-- Панель экспорта: диапазон дат поставки + Excel/CSV -->
        <v-card variant="outlined" class="export-panel mt-4">
            <div class="export-panel-inner">
                <span class="export-label text-body-2">Диапазон поставки:</span>
                <v-text-field
                    v-model="receivedFrom"
                    type="date"
                    label="от"
                    variant="outlined"
                    density="compact"
                    hide-details
                    class="date-input"
                    data-testid="export-from"
                />
                <v-text-field
                    v-model="receivedTo"
                    type="date"
                    label="до"
                    variant="outlined"
                    density="compact"
                    hide-details
                    class="date-input"
                    data-testid="export-to"
                />
                <v-spacer />
                <v-btn
                    variant="outlined"
                    prepend-icon="mdi-file-excel-outline"
                    data-testid="export-xlsx-btn"
                    @click="exportByRange('xlsx')"
                >
                    Экспорт в Excel
                </v-btn>
                <v-btn
                    variant="outlined"
                    prepend-icon="mdi-file-delimited-outline"
                    data-testid="export-csv-btn"
                    @click="exportByRange('csv')"
                >
                    Экспорт в CSV
                </v-btn>
            </div>
        </v-card>

        <DealsFilters
            v-model:search-phone="searchPhone"
            v-model:filter-status="filterStatus"
            v-model:filter-project="filterProject"
            v-model:filter-city="filterCity"
            :lead-statuses="leadStatuses"
            :available-projects="availableProjects"
            :available-cities="availableCities"
            class="mt-4"
            @clear-filters="clearFilters"
        />

        <!-- Селектор числа строк — между фильтрами и таблицей -->
        <div class="perpage mt-3">
            <span class="text-body-2 text-medium-emphasis">Показывать по:</span>
            <v-btn-toggle
                v-model="perPage"
                mandatory
                density="comfortable"
                variant="outlined"
                color="primary"
                data-testid="perpage-toggle"
            >
                <v-btn v-for="n in PER_PAGE_OPTIONS" :key="n" :value="n" size="small">{{ n }}</v-btn>
            </v-btn-toggle>
        </div>

        <DealsBulkBar
            v-model:status-menu-open="statusMenuOpen"
            :selected-count="selected.length"
            :lead-statuses="leadStatuses"
            @apply-status="applyBulkStatus"
            @clear-selected="selected = []"
        />

        <v-alert
            v-if="fetchError"
            type="warning"
            variant="tonal"
            density="compact"
            closable
            class="mt-3"
            data-testid="fetch-error-alert"
        >
            Не удалось загрузить сделки. Попробуйте обновить.
        </v-alert>

        <!-- master-detail: список сжимается, панель встаёт рядом (не overlay) -->
        <div class="deals-body mt-4">
            <div class="deals-list">
                <DealsTable
                    :deals="dealsState"
                    :selected-ids="selected"
                    :status-by-slug="statusBySlug"
                    :active-deal-id="panelOpen ? selectedDeal?.id ?? null : null"
                    @update:selected-ids="selected = $event"
                    @row-click="openPanel"
                />
            </div>
            <DealDetailDrawer
                inline
                :open="panelOpen"
                :deal="selectedDeal"
                :tenant-id="auth.user?.tenant_id"
                @update:open="(v: boolean) => (panelOpen = v)"
            />
        </div>

        <!-- Футер: пагинация -->
        <div v-if="pageCount > 1" class="tfoot mt-4">
            <v-pagination
                v-model="page"
                :length="pageCount"
                :total-visible="7"
                density="comfortable"
                data-testid="deals-pagination"
            />
        </div>

        <v-snackbar v-model="exportToastOpen" :timeout="3000" data-testid="export-toast" location="bottom right">
            {{ exportToastText }}
        </v-snackbar>
        <v-snackbar v-model="statusToastOpen" :timeout="3000" data-testid="status-toast" location="bottom right">
            {{ statusToastText }}
        </v-snackbar>
    </v-container>
</template>

<style scoped>
.deals {
    max-width: 1440px;
}
.page-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 16px;
}
.page-title {
    font-variation-settings: 'opsz' 28;
    letter-spacing: -0.018em;
}
.num {
    font-family: 'JetBrains Mono', ui-monospace, monospace;
    font-feature-settings: 'tnum';
    font-weight: 500;
}
.export-panel {
    background: #fff;
}
.export-panel-inner {
    display: flex;
    align-items: center;
    gap: 12px;
    flex-wrap: wrap;
    padding: 12px 16px;
}
.export-label {
    color: #6b6356;
    flex-shrink: 0;
}
.date-input {
    max-width: 170px;
}
.perpage {
    display: flex;
    align-items: center;
    gap: 12px;
}
.deals-body {
    display: flex;
    align-items: flex-start;
    gap: 16px;
}
.deals-list {
    flex: 1 1 auto;
    min-width: 0;
}
.tfoot {
    display: flex;
    justify-content: center;
}
</style>
```

- [ ] **Step 2: Проверить типы**

Run: `cd app; npx vue-tsc --noEmit -p tsconfig.json`
Expected: 0 ошибок (старый `DealsView.spec.ts` ещё на старом контракте — упадёт в Vitest, чинится в A11; типы `.vue`-файла валидны).

- [ ] **Step 3: Commit**

```bash
git add app/resources/js/views/DealsView.vue
git commit -m "feat(deals): DealsView — lead-registry redesign (export panel, per-page, master-detail panel)"
```

### Task A11: Тест-свип + регрессия Фазы A

**Files:**

- Replace: `app/tests/Frontend/DealsView.spec.ts`
- Delete: `app/tests/Frontend/DealsViewRedesign.spec.ts`
- Modify: `app/tests/Frontend/DealsListIntegration.spec.ts`, `app/resources/js/composables/mockDeals.ts`
- Verify: `app/tests/Frontend/deals-api.spec.ts`

`DealsViewRedesign.spec.ts` тестирует удалённый `.ld-filterbar`/`FilterChip`/`DensityToggle` — удаляется целиком. `DealsView.spec.ts` — полностью переписывается под новый контракт.

- [ ] **Step 1: Переписать `DealsView.spec.ts`**

```ts
import { describe, it, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import DealsView from '../../resources/js/views/DealsView.vue';
import * as dealsApi from '../../resources/js/api/deals';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
import type { MockDeal } from '../../resources/js/composables/mockDeals';

function apiDeal(id: number, over: Partial<dealsApi.ApiDeal> = {}): dealsApi.ApiDeal {
    return {
        id, tenant_id: 42, project_id: 1, project_name: 'Окна', phone: `+7 916 000-00-0${id}`,
        contact_name: null, status: 'new', manager_id: null, manager_name: null,
        manager_initials: null, received_at: '2026-05-15T09:00:00+00:00',
        comment: null, city: null, project_signal_type: 'call', next_reminder_at: null,
        ...over,
    };
}

async function mountDeals(deals: dealsApi.ApiDeal[] = [apiDeal(1), apiDeal(2)], total = 2) {
    setActivePinia(createPinia());
    const auth = useAuthStore();
    auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
    vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals, total, limit: 20, offset: 0 });
    vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([
        { id: 1, name: 'Окна', tag: null, type: 'supplier' },
    ]);
    const router = createRouter({
        history: createMemoryHistory(),
        routes: [{ path: '/deals', component: DealsView }],
    });
    await router.push('/deals');
    await router.isReady();
    const wrapper = mount(DealsView, {
        global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true } },
    });
    await flushPromises();
    return wrapper;
}

describe('DealsView.vue — реестр лидов', () => {
    it('заголовок «Сделки»', async () => {
        expect((await mountDeals()).find('h1').text()).toBe('Сделки');
    });

    it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
        const w = await mountDeals();
        expect(w.find('[data-testid="export-from"]').exists()).toBe(true);
        expect(w.find('[data-testid="export-to"]').exists()).toBe(true);
        expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
        expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
    });

    it('селектор «Показывать по» с вариантами 10/20/50', async () => {
        const w = await mountDeals();
        const toggle = w.find('[data-testid="perpage-toggle"]');
        expect(toggle.exists()).toBe(true);
        ['10', '20', '50'].forEach((n) => expect(toggle.text()).toContain(n));
    });

    it('НЕТ кнопки «Новая сделка» и режима «Корзина»', async () => {
        const w = await mountDeals();
        expect(w.text()).not.toContain('Новая сделка');
        expect(w.text()).not.toContain('Корзина');
    });

    it('загружает сделки в dealsState через API', async () => {
        const w = await mountDeals([apiDeal(1), apiDeal(2), apiDeal(3)], 3);
        const vm = w.vm as unknown as { dealsState: MockDeal[]; total: number };
        expect(vm.dealsState.length).toBe(3);
        expect(vm.total).toBe(3);
    });

    it('openPanel выбирает сделку, повторный клик закрывает', async () => {
        const w = await mountDeals();
        const vm = w.vm as unknown as {
            dealsState: MockDeal[]; panelOpen: boolean; selectedDeal: MockDeal | null;
            openPanel: (d: MockDeal) => void;
        };
        vm.openPanel(vm.dealsState[0]);
        expect(vm.panelOpen).toBe(true);
        expect(vm.selectedDeal?.id).toBe(1);
        vm.openPanel(vm.dealsState[0]);
        expect(vm.panelOpen).toBe(false);
    });

    it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
        const w = await mountDeals();
        const vm = w.vm as unknown as {
            selected: number[]; dealsState: MockDeal[]; applyBulkStatus: (s: string) => Promise<void>;
        };
        vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 2, requested: 2, status: 'viewed' });
        vm.selected = [1, 2];
        await flushPromises();
        expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
        await vm.applyBulkStatus('viewed');
        expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('viewed');
    });

    it('exportByRange xlsx вызывает exportDealsByRange', async () => {
        const w = await mountDeals();
        const spy = vi.spyOn(dealsApi, 'exportDealsByRange').mockResolvedValue(new Blob());
        Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:m'), configurable: true });
        Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true });
        vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
        const vm = w.vm as unknown as { exportByRange: (f: string) => Promise<void>; exportToastOpen: boolean };
        await vm.exportByRange('xlsx');
        expect(spy).toHaveBeenCalledOnce();
        expect(vm.exportToastOpen).toBe(true);
    });

    it('loadDeals reject → dealsState пустой + fetchError', async () => {
        setActivePinia(createPinia());
        const auth = useAuthStore();
        auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
        vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
        vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([]);
        const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
        await router.push('/deals');
        await router.isReady();
        const w = mount(DealsView, {
            global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true } },
        });
        await flushPromises();
        const vm = w.vm as unknown as { dealsState: MockDeal[]; fetchError: boolean };
        expect(vm.dealsState.length).toBe(0);
        expect(vm.fetchError).toBe(true);
    });
});

afterEach(() => vi.restoreAllMocks());
```

- [ ] **Step 2: Удалить `DealsViewRedesign.spec.ts`**

```bash
git rm app/tests/Frontend/DealsViewRedesign.spec.ts
```

- [ ] **Step 3: Удалить `DEALS_TABS`/`DealsTab` из `mockDeals.ts`**

Удалить из `mockDeals.ts` интерфейс `DealsTab` и const `DEALS_TABS` (после A6/A10 их никто не импортирует). `MOCK_DEALS`/`MOCK_PROJECTS`/`MOCK_MANAGERS` — оставить (используются Канбаном/`NewDealDialog`).

- [ ] **Step 4: Прогнать Vitest, починить упавшее**

Run: `cd app; npx vitest run --maxWorkers=2`
Expected: исследовать падения. Ожидаемо требуют правок: `DealsListIntegration.spec.ts` (использует старый контракт DealsView). Починить так же, как `DealsView.spec.ts` (мок `listDeals`/`listProjects`, новый набор testid). Если тест проверял удалённое поведение (trash/new-deal/tabs) — удалить такой тест. `deals-api.spec.ts` — если использует `exportDeals`/`bulkDeleteDeals` — оставить (функции не удалялись). Гнать до 0 fail.

- [ ] **Step 5: Прогнать Pest (backend Фазы A)**

Run: `cd app; php artisan test --filter="DealIndexTest|DealExportTest|DealShowTest|DealUpdateTest"`
Expected: 0 fail.

- [ ] **Step 6: Commit**

```bash
git add app/tests/Frontend/ app/resources/js/composables/mockDeals.ts
git commit -m "test(deals): rewrite DealsView spec for redesign; drop DealsViewRedesign spec + DEALS_TABS"
```

- [ ] **Step 7: Полная регрессия Фазы A**

Run: `cd app; php artisan test; npx vitest run --maxWorkers=2; npx vue-tsc --noEmit -p tsconfig.json`
Expected: 0 fail / 0 ошибок типов. Любые падения — выписать `file:line`, починить, повторить. Это закрывает Фазу A.

---

## ⛔ ГЕЙТ перед Фазой B — подтверждение slug'ов

**Фаза B меняет схему БД (миграция 14 → 5 статусов). Стартовать ТОЛЬКО после явного «да» заказчика на точные slug'и.**

Маппинг §3 спеки заказчик подтвердил («2. вопрос да»). Не подтверждены **точные slug'и** новых 5 статусов. План фиксирует (спека §3, §9 Q2):

| # | slug | name_ru | Старые 14 → сюда |
|---|---|---|---|
| 1 | `new` *(slug сохраняется)* | Новая сделка | `new` |
| 2 | `viewed` *(сохраняется)* | Просмотрено | `viewed` |
| 3 | `in_progress` *(новый)* | В работе | `worked`, `base`, `missed`, `negotiations`, `waiting_payment`, `partnership`, `test_drive`, `hot`, `replacement`, `final_missed` |
| 4 | `won` *(новый)* | Сделка | `paid` |
| 5 | `lost` *(новый)* | Не реализовано | `closed` |

`new`/`viewed` намеренно сохраняют slug — `RouteSupplierLeadJob`, `DealController@store` default'ят `'new'`, миграция их не трогает.

**Действие исполнителя:** показать таблицу заказчику. На «да» — выполнять B1–B8. На правку slug'ов — обновить значения в B1–B6 и продолжить.

## ФАЗА B — воронка 14 → 5

### Task B1: `schema.sql` seed `lead_statuses` + CHANGELOG

**Files:**

- Modify: `db/schema.sql` (seed `lead_statuses`, строки ~2577-2592; комментарии ~318-319, ~2577)
- Modify: `db/CHANGELOG_schema.md`

- [ ] **Step 1: Заменить seed `lead_statuses`**

В `db/schema.sql` блок `INSERT INTO lead_statuses ...` (начинается ~строка 2578) заменить 14 строк значений на 5:

```sql
-- 5 статусов воронки (редизайн «Сделки» 2026-05-17 — было 14)
INSERT INTO lead_statuses (slug, name_ru, is_system, sort_order, color_hex) VALUES
    ('new',         'Новая сделка',     TRUE, 1, '#3B82F6'),
    ('viewed',      'Просмотрено',      TRUE, 2, '#8B5CF6'),
    ('in_progress', 'В работе',         TRUE, 3, '#06B6D4'),
    ('won',         'Сделка',           TRUE, 4, '#10B981'),
    ('lost',        'Не реализовано',   TRUE, 5, '#6B7280');
```

Комментарий выше INSERT и комментарий над `CREATE TABLE lead_statuses` (~строки 318-319 «14 статусов: 6 системных + 8 настраиваемых») заменить на «5 статусов воронки (все системные)».

- [ ] **Step 2: Запись в `CHANGELOG_schema.md`**

Добавить запись (формат — как у предыдущих): bump версии схемы (следующий за текущим), дата 2026-05-17, описание: «Воронка статусов 14 → 5: seed `lead_statuses` (`new`/`viewed`/`in_progress`/`won`/`lost`). Инкрементальная миграция `2026_05_17_120000_deals_funnel_14_to_5_statuses.php` ремапит `deals.status`, `tenant_status_overrides.status_slug`, `import_unknown_statuses.mapped_to_slug`. Редизайн страницы «Сделки», спека `docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md`.» Версию в шапке `schema.sql` (строка 4) синхронизировать.

- [ ] **Step 3: Проверка — `migrate:fresh` + smoke**

Run: `cd app; php artisan migrate:fresh --seed; php artisan test --filter="RlsSmokeTest"`
Expected: миграция проходит; `lead_statuses` содержит 5 строк; smoke 0 fail.

Run: `cd app; php artisan tinker --execute="echo \App\Models\Tenant::count();"` — sanity-проверка БД жива.

- [ ] **Step 4: Commit**

```bash
git add db/schema.sql db/CHANGELOG_schema.md
git commit -m "feat(deals): schema — lead_statuses funnel 14->5 (new/viewed/in_progress/won/lost)"
```

### Task B2: Инкрементальная миграция 14 → 5

**Files:**

- Create: `app/database/migrations/2026_05_17_120000_deals_funnel_14_to_5_statuses.php`
- Create: `app/tests/Feature/LeadStatusesFunnelTest.php`

`new`/`viewed` сохраняют slug. FK-порядок: сначала INSERT новых slug'ов, затем ремап ссылок (`deals.status`, `import_unknown_statuses.mapped_to_slug`), затем DELETE устаревших `lead_statuses`. `tenant_status_overrides` — обсолетные строки удаляются (кастомные ярлыки схлопнутых статусов теряют смысл; заодно исключает PK-коллизию `(tenant_id, status_slug)` при ремапе двух старых в один новый).

- [ ] **Step 1: Failing-тест — создать `LeadStatusesFunnelTest.php`**

```php
<?php

declare(strict_types=1);

use Illuminate\Support\Facades\DB;

test('lead_statuses содержит ровно 5 статусов воронки', function () {
    $slugs = DB::table('lead_statuses')->orderBy('sort_order')->pluck('slug')->all();
    expect($slugs)->toBe(['new', 'viewed', 'in_progress', 'won', 'lost']);
});

test('новые статусы имеют корректные русские названия', function () {
    $names = DB::table('lead_statuses')->pluck('name_ru', 'slug');
    expect($names['new'])->toBe('Новая сделка');
    expect($names['in_progress'])->toBe('В работе');
    expect($names['won'])->toBe('Сделка');
    expect($names['lost'])->toBe('Не реализовано');
});

test('старых slug-ов воронки в lead_statuses не осталось', function () {
    $obsolete = DB::table('lead_statuses')
        ->whereIn('slug', ['worked', 'paid', 'closed', 'hot', 'negotiations'])
        ->count();
    expect($obsolete)->toBe(0);
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; php artisan test --filter=LeadStatusesFunnelTest`
Expected: FAIL — `schema.sql` уже сеет 5 (после B1), но тест прогоняется ДО создания миграции; если B1 уже в дереве — тест может пройти. В этом случае создать миграцию (Step 3) ради ремапа существующих БД и продолжить.

- [ ] **Step 3: Создать миграцию**

`app/database/migrations/2026_05_17_120000_deals_funnel_14_to_5_statuses.php`:

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;

/**
 * Воронка статусов 14 → 5 (редизайн «Сделки» 2026-05-17).
 *
 * Новые 5: new / viewed / in_progress / won / lost. Slug'и `new` и `viewed`
 * сохраняются (RouteSupplierLeadJob / DealController@store default'ят 'new').
 * Ремап старых 14 → 5 в deals.status и import_unknown_statuses.mapped_to_slug
 * перед DELETE устаревших lead_statuses (FK-safe). tenant_status_overrides
 * со старыми slug'ами удаляются (кастомные ярлыки схлопнутых статусов
 * обсолетны + исключает PK-коллизию при ремапе).
 *
 * На migrate:fresh schema.sql уже сеет 5 — UPDATE/DELETE здесь no-op.
 * down() необратима (схлопывание lossy).
 *
 * Спека: docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md §3.
 */
return new class extends Migration
{
    /** Старый slug → новый. new/viewed не меняются (отсутствуют в карте). */
    private const REMAP = [
        'worked' => 'in_progress', 'base' => 'in_progress', 'missed' => 'in_progress',
        'negotiations' => 'in_progress', 'waiting_payment' => 'in_progress',
        'partnership' => 'in_progress', 'test_drive' => 'in_progress', 'hot' => 'in_progress',
        'replacement' => 'in_progress', 'final_missed' => 'in_progress',
        'paid' => 'won', 'closed' => 'lost',
    ];

    private const KEEP = ['new', 'viewed', 'in_progress', 'won', 'lost'];

    public function up(): void
    {
        DB::transaction(function () {
            // 1) Новые slug'и обязаны существовать до ремапа FK-ссылок.
            DB::table('lead_statuses')->upsert([
                ['slug' => 'new', 'name_ru' => 'Новая сделка', 'is_system' => true, 'sort_order' => 1, 'color_hex' => '#3B82F6'],
                ['slug' => 'viewed', 'name_ru' => 'Просмотрено', 'is_system' => true, 'sort_order' => 2, 'color_hex' => '#8B5CF6'],
                ['slug' => 'in_progress', 'name_ru' => 'В работе', 'is_system' => true, 'sort_order' => 3, 'color_hex' => '#06B6D4'],
                ['slug' => 'won', 'name_ru' => 'Сделка', 'is_system' => true, 'sort_order' => 4, 'color_hex' => '#10B981'],
                ['slug' => 'lost', 'name_ru' => 'Не реализовано', 'is_system' => true, 'sort_order' => 5, 'color_hex' => '#6B7280'],
            ], ['slug'], ['name_ru', 'is_system', 'sort_order', 'color_hex']);

            // 2) Ремап ссылок на старые slug'и.
            foreach (self::REMAP as $old => $new) {
                DB::table('deals')->where('status', $old)->update(['status' => $new]);
                DB::table('import_unknown_statuses')->where('mapped_to_slug', $old)->update(['mapped_to_slug' => $new]);
            }

            // 3) Обсолетные кастомные ярлыки статусов — удалить (FK на lead_statuses).
            DB::table('tenant_status_overrides')->whereNotIn('status_slug', self::KEEP)->delete();

            // 4) Удалить устаревшие статусы (все FK-ссылки перенаправлены).
            DB::table('lead_statuses')->whereNotIn('slug', self::KEEP)->delete();
        });
    }

    public function down(): void
    {
        throw new RuntimeException('Воронка 14→5 необратима (схлопывание статусов lossy).');
    }
};
```

- [ ] **Step 4: db-тест + smoke (обязательно после изменения миграции)**

Run: `cd app; php artisan migrate:fresh --seed; php artisan test --filter="LeadStatusesFunnelTest|RlsSmokeTest"`
Expected: миграция проходит без ошибок; `LeadStatusesFunnelTest` 3/3 PASS; `RlsSmokeTest` 0 fail.

- [ ] **Step 5: Commit**

```bash
git add app/database/migrations/2026_05_17_120000_deals_funnel_14_to_5_statuses.php app/tests/Feature/LeadStatusesFunnelTest.php
git commit -m "feat(deals): migration — remap deals.status + drop obsolete lead_statuses (14->5)"
```

### Task B3: `leadStatuses.ts` — `LEAD_STATUSES` 14 → 5

**Files:**

- Modify: `app/resources/js/composables/leadStatuses.ts`

- [ ] **Step 1: Заменить `LEAD_STATUSES`**

Заменить массив `LEAD_STATUSES` (строки 20-35) на:

```ts
export const LEAD_STATUSES: LeadStatus[] = [
    { slug: 'new', nameRu: 'Новая сделка', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
    { slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#8B5CF6' },
    { slug: 'in_progress', nameRu: 'В работе', isSystem: true, sortOrder: 3, colorHex: '#06B6D4' },
    { slug: 'won', nameRu: 'Сделка', isSystem: true, sortOrder: 4, colorHex: '#10B981' },
    { slug: 'lost', nameRu: 'Не реализовано', isSystem: true, sortOrder: 5, colorHex: '#6B7280' },
];
```

В шапочном комментарии (строки 1-11) «14 системных и пользовательских статусов» → «5 системных статусов воронки (редизайн 2026-05-17)»; ссылку на `db/schema.sql:2130` оставить как ориентир (источник истины — seed `lead_statuses`).

- [ ] **Step 2: Проверить типы**

Run: `cd app; npx vue-tsc --noEmit -p tsconfig.json`
Expected: 0 ошибок.

- [ ] **Step 3: Commit**

```bash
git add app/resources/js/composables/leadStatuses.ts
git commit -m "feat(deals): leadStatuses composable — 5-status funnel snapshot"
```

### Task B4: `useStatusPill.ts` — добавить `viewed`/`lost`

**Files:**

- Modify: `app/resources/js/composables/useStatusPill.ts`
- Test: `app/tests/Frontend/StatusPill.spec.ts` (если есть; иначе пропустить Step 1)

5 slug'ов воронки должны иметь стиль пилюли. `new`/`in_progress`/`won` уже в `STYLES`; добавляем `viewed` и `lost`.

- [ ] **Step 1: Реализация**

В `STATUS_PILL_SLUGS` (строки 14-29) добавить `'viewed'` и `'lost'` (в любую позицию массива). В `STYLES` (строки 33-48) добавить:

```ts
    viewed: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
    lost: { bg: 'rgba(107,99,86,0.18)', color: '#6B6356' },
```

- [ ] **Step 2: Проверить типы + StatusPill-тесты**

Run: `cd app; npx vue-tsc --noEmit -p tsconfig.json; npx vitest run tests/Frontend/StatusPill`
Expected: 0 ошибок типов; StatusPill-тесты PASS (если файл есть).

- [ ] **Step 3: Commit**

```bash
git add app/resources/js/composables/useStatusPill.ts
git commit -m "feat(deals): useStatusPill — add viewed/lost funnel slugs"
```

### Task B5: `StatusRuToSlugMapper` — маппинг под 5

**Files:**

- Modify: `app/app/Services/Import/StatusRuToSlugMapper.php`
- Test: `app/tests/Feature/Import/StatusRuToSlugMapperTest.php` (если есть — обновить; иначе создать)

Исторические CSV поставщика несут СТАРЫЕ 14 русских названий — все должны мапиться в новые 5 slug'ов. Плюс новые 4 русских имени.

- [ ] **Step 1: Failing-тест**

Создать/обновить `tests/Feature/Import/StatusRuToSlugMapperTest.php`:

```php
<?php

declare(strict_types=1);

use App\Services\Import\StatusRuToSlugMapper;

test('старые русские статусы мапятся в 5 новых slug-ов', function () {
    $m = new StatusRuToSlugMapper;
    expect($m->toSlug('Новые'))->toBe('new');
    expect($m->toSlug('Просмотрено'))->toBe('viewed');
    expect($m->toSlug('Проработан'))->toBe('in_progress');
    expect($m->toSlug('Переговоры'))->toBe('in_progress');
    expect($m->toSlug('Конечный недозвон'))->toBe('in_progress');
    expect($m->toSlug('Оплачено'))->toBe('won');
    expect($m->toSlug('Закрыто и не реализовано'))->toBe('lost');
});

test('новые русские названия статусов тоже мапятся', function () {
    $m = new StatusRuToSlugMapper;
    expect($m->toSlug('Новая сделка'))->toBe('new');
    expect($m->toSlug('В работе'))->toBe('in_progress');
    expect($m->toSlug('Сделка'))->toBe('won');
    expect($m->toSlug('Не реализовано'))->toBe('lost');
});

test('неизвестный статус → null', function () {
    expect((new StatusRuToSlugMapper)->toSlug('Абракадабра'))->toBeNull();
});
```

- [ ] **Step 2: Запустить — RED**

Run: `cd app; php artisan test --filter=StatusRuToSlugMapperTest`
Expected: FAIL — старые названия мапятся в старые slug'и.

- [ ] **Step 3: Реализация — заменить `STATUS_RU_TO_SLUG`**

В `StatusRuToSlugMapper.php` заменить константу (строки 15-31):

```php
    /** @var array<string, string> Русские названия → 5 slug'ов воронки (редизайн 2026-05-17). */
    private const STATUS_RU_TO_SLUG = [
        // Новые названия 5-статусной воронки.
        'Новая сделка' => 'new',
        'Просмотрено' => 'viewed',
        'В работе' => 'in_progress',
        'Сделка' => 'won',
        'Не реализовано' => 'lost',
        // Старые 14 названий поставщика → новые slug'и (исторический CSV-импорт).
        'Новые' => 'new',
        'Проработан' => 'in_progress',
        'База' => 'in_progress',
        'Недозвон' => 'in_progress',
        'Переговоры' => 'in_progress',
        'Ожидаем оплаты' => 'in_progress',
        'Партнерка' => 'in_progress',
        'Оплачено' => 'won',
        'Закрыто и не реализовано' => 'lost',
        'Тест драйв' => 'in_progress',
        'Горячий' => 'in_progress',
        'На замену' => 'in_progress',
        'Конечный недозвон' => 'in_progress',
    ];
```

Doc-комментарий класса «14 статусов» → «5 статусов воронки».

- [ ] **Step 4: Запустить — GREEN**

Run: `cd app; php artisan test --filter=StatusRuToSlugMapperTest`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add app/app/Services/Import/StatusRuToSlugMapper.php app/tests/Feature/Import/StatusRuToSlugMapperTest.php
git commit -m "feat(deals): StatusRuToSlugMapper — remap supplier RU statuses to 5-slug funnel"
```

### Task B6: Свип старых slug'ов — `MOCK_DEALS` + тесты

**Files:**

- Modify: `app/resources/js/composables/mockDeals.ts` (`MOCK_DEALS`)
- Modify: тесты, хардкодящие старые slug'и (выявляются grep'ом)

Старые slug'и (`worked`, `base`, `missed`, `negotiations`, `waiting_payment`, `partnership`, `paid`, `closed`, `test_drive`, `hot`, `replacement`, `final_missed`) больше не валидны. `DealFactory` default `'new'` — НЕ требует правки.

- [ ] **Step 1: Найти все вхождения**

Run: `cd app; git grep -n -E "'(worked|base|missed|negotiations|waiting_payment|partnership|paid|closed|test_drive|hot|replacement|final_missed)'" -- resources/js tests`
Изучить вывод. Игнорировать совпадения, не относящиеся к статусам сделок (напр. `closed` в другом контексте).

- [ ] **Step 2: Применить REMAP к `MOCK_DEALS`**

В `mockDeals.ts` в каждом элементе `MOCK_DEALS` заменить `statusSlug` по карте: `worked|base|missed|negotiations|waiting_payment|partnership|test_drive|hot|replacement|final_missed → in_progress`; `paid → won`; `closed → lost`; `new`/`viewed` — без изменений.

- [ ] **Step 3: Применить REMAP к тестам**

В тестовых файлах из Step 1 (Pest: `DealIndexTest`, `DealTransitionTest`, `DealUpdateTest` и др.; Vitest-specs) заменить старые slug-строки по той же карте. Где тест по смыслу проверял конкретный старый статус (напр. transition в `paid`) — заменить на эквивалентный новый (`won`).

- [ ] **Step 4: Прогнать затронутые тесты**

Run: `cd app; npx vitest run --maxWorkers=2; php artisan test`
Expected: разобрать падения, дочинить до 0 fail. Полная регрессия — в B8.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "test(deals): sweep obsolete 14-status slugs to 5-status funnel (MOCK_DEALS + tests)"
```

### Task B7: Канбан — 5 колонок

**Files:**

- Modify: `app/resources/js/views/KanbanView.vue` (комментарий)
- Modify: `app/tests/Frontend/KanbanView.spec.ts` (если ассертит число колонок)

`KanbanView` строит колонки из `leadStatuses` store → после B3 автоматически 5 колонок. Кода-логики менять не нужно — только комментарии/тесты.

- [ ] **Step 1: Прочитать `KanbanView.vue` и `KanbanView.spec.ts`**

Run (для локализации): `cd app; git grep -n "14" -- resources/js/views/KanbanView.vue tests/Frontend/KanbanView.spec.ts`

- [ ] **Step 2: Обновить упоминания «14»**

В `KanbanView.vue` комментарии «14 колонок/статусов» → «5 колонок воронки». В `KanbanView.spec.ts`, если тест ассертит `toHaveLength(14)` для колонок — заменить на `5`; если ассертит конкретные старые slug'и колонок — заменить на `['new','viewed','in_progress','won','lost']`.

- [ ] **Step 3: Прогнать Канбан-тесты**

Run: `cd app; npx vitest run tests/Frontend/KanbanView`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add app/resources/js/views/KanbanView.vue app/tests/Frontend/KanbanView.spec.ts
git commit -m "feat(deals): Kanban — 5-column funnel (comment + test sync)"
```

### Task B8: Полная регрессия Фазы B

**Files:** —

- [ ] **Step 1: db smoke после миграции**

Run: `cd app; php artisan migrate:fresh --seed; php artisan test --filter="RlsSmokeTest|LeadStatusesFunnelTest"`
Expected: 0 fail.

- [ ] **Step 2: Полная регрессия**

Run: `cd app; php artisan test; npx vitest run --maxWorkers=2; npx vue-tsc --noEmit -p tsconfig.json`
Expected: 0 fail / 0 ошибок типов. Падения — выписать `file:line`, починить (типовая причина — пропущенный старый slug в тесте), повторить.

- [ ] **Step 3: Финальный коммит регрессии (если были фиксы)**

```bash
git add -A
git commit -m "test(deals): phase B regression sweep — 5-status funnel green"
```

---

## Self-review (проверка плана против спеки)

**Покрытие спеки:**

- §2 шапка/статистика → A10. Панель экспорта (даты + Excel/CSV) → A5+A10. Фильтры (телефон/Статус/Проект/Город) → A6. Селектор строк 10/20/50 → A10. Колонки таблицы → A7. Master-detail панель → A9+A10. Футер-пагинация → A10. Сайдбар (Импорт/Отчёты) — **уже сделано до плана** (вне scope).
- §3 воронка 14→5 → B1–B8 (схема, миграция, leadStatuses, Канбан, StatusRuToSlugMapper). `RouteSupplierLeadJob` — изменения НЕ требуются (slug `new` сохранён) — зафиксировано в гейте.
- §4 «Город» — колонка рендерится `—`, фильтр пуст/disabled (A6/A7); источник данных вне scope.
- §5 компоненты — все перечисленные затронуты (`mockDeals`/`api/deals`/`dealsApiMapper`/`DealController`/`DealExportController`/`DealsFilters`/`DealsTable`/`DealsBulkBar`/`DealDetailDrawer`/`DealsView`/`leadStatuses`/`StatusRuToSlugMapper`).
- §6 декомпозиция — Фаза A (A1–A11), Фаза B (B1–B8) за гейтом.
- §8 тестирование — TDD в каждой задаче; db-тесты после миграции (B2/B8).

**Расхождения, зафиксированные осознанно:**

- Backend `store`/`destroy`/`restore` + старые `exportDeals`/`bulkDeleteDeals` в `api/deals.ts` — НЕ удаляются (вне scope §5; удаление = отдельная чистка).
- Колонка/фильтр «Город» — UI-каркас есть, данных нет (§4 открыт).
- Тест ремапа `deals.status` на проде — не покрыт unit-тестом (миграция уже применена в `migrate:fresh`); корректность — через ревью SQL + B8. Прод-данных нет (Б-1).

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-05-17-deals-page-redesign.md`.**

Фаза A исполняется сразу. Фаза B — после гейта подтверждения slug'ов заказчиком.

Two execution options:

1. **Subagent-Driven (recommended)** — свежий субагент на задачу, two-stage review между задачами.
2. **Inline Execution** — задачи в текущей сессии, чекпоинты для ревью.

Which approach?
