# Sprint 4 «Audit tail» Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Закрыть 3 оставшихся O-* пункта audit'а (после Sprint 1–3): keyset pagination в `DealController::index` (O-perf-04), split 8 Vue-компонентов >300 строк (O-refactor-04 хвост), dead-code detection через bundle analyzer + удаление unused exports (O-refactor-06).

**Architecture:** 3 фазы / 3 PR-коммита (Phase A backend keyset, Phase B frontend split, Phase C frontend cleanup) + 1 финальный регрессионный коммит. Каждая фаза независима — порядок гибкий, но рекомендация A → B → C ради монотонной валидации.

**Tech Stack:** Laravel 13 + Pest 4 + Vue 3.5 + Vuetify 3.12 + Vite 8 + rollup-plugin-visualizer

**Spec:** [docs/superpowers/specs/2026-05-10-roadmap-to-production-design.md](../specs/2026-05-10-roadmap-to-production-design.md) §3.1 (Sprint 4 содержание)

**Базовый HEAD:** `77b018d` (после commit'а roadmap design'а)

**Pre-requisites:**

- Проект собран (`cd app && composer install && npm install`).
- PG/Redis запущены (Memurai/PostgreSQL service running на dev-стенде).
- `php artisan migrate:fresh --seed` если нужна свежая БД.

---

## Phase A — O-perf-04: keyset pagination в DealController::index

**Контекст:** Текущий `DealController::index` использует OFFSET/LIMIT. На больших offset'ах (deep pagination) это O(N) — PG считает все записи до offset'а, затем выкидывает их. Keyset pagination использует cursor `(received_at, id)` — клиент передаёт «последние видимые значения», PG идёт по индексу с WHERE `(received_at, id) < (cursor_received_at, cursor_id)`. O(1) при любой глубине.

**Решение API:** добавить опциональный query-параметр `cursor` (base64-encoded JSON `{r: timestamp, i: id}`). При передаче cursor — игнорируем offset, используем keyset. Без cursor — текущее поведение OFFSET (для совместимости с frontend).

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealController.php:59-143` (метод `index`)
- Test: `app/tests/Feature/DealIndexTest.php` (добавить 3 теста)
- Modify: `app/resources/js/composables/useDealsList.ts` (если есть; иначе — пометить как Post-Sprint-4 follow-up для frontend integration)

### Task A.1 — Pest tests для keyset (TDD)

- [ ] **Step A.1.1: Открыть `app/tests/Feature/DealIndexTest.php` и добавить 3 failing-теста в конец файла (перед закрывающей `});` если она есть, или просто в конец).**

```php
test('GET /api/deals с cursor возвращает следующую страницу через keyset', function () {
    // Создаём 5 сделок с разными received_at (через 1 минуту).
    $base = now()->subHours(5);
    $ids = [];
    for ($i = 0; $i < 5; $i++) {
        $ids[] = Deal::factory()
            ->for($this->tenant)
            ->for($this->project)
            ->create([
                'status' => 'new',
                'received_at' => $base->copy()->addMinutes($i),
            ])->id;
    }

    // Первая страница без cursor: limit=2 → последние 2 (по received_at DESC).
    $r1 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
    $r1->assertStatus(200);
    expect($r1->json('deals'))->toHaveLength(2);
    expect($r1->json('deals.0.id'))->toBe($ids[4]); // последняя по времени
    expect($r1->json('deals.1.id'))->toBe($ids[3]);

    // Cursor для следующей страницы — последний id и его received_at.
    $cursor = base64_encode(json_encode([
        'r' => $r1->json('deals.1.received_at'),
        'i' => $r1->json('deals.1.id'),
    ]));

    $r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
    $r2->assertStatus(200);
    expect($r2->json('deals'))->toHaveLength(2);
    expect($r2->json('deals.0.id'))->toBe($ids[2]);
    expect($r2->json('deals.1.id'))->toBe($ids[1]);
});

test('GET /api/deals c невалидным cursor возвращает 422', function () {
    $r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&cursor=not-base64-json');
    $r->assertStatus(422);
    expect($r->json('message'))->toBeString();
});

test('GET /api/deals c cursor возвращает next_cursor когда есть ещё страницы', function () {
    $base = now()->subHours(3);
    for ($i = 0; $i < 3; $i++) {
        Deal::factory()->for($this->tenant)->for($this->project)->create([
            'status' => 'new',
            'received_at' => $base->copy()->addMinutes($i),
        ]);
    }

    $r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2');
    $r->assertStatus(200);
    expect($r->json('next_cursor'))->toBeString(); // base64-encoded
    expect($r->json('next_cursor'))->not->toBeEmpty();

    // Последняя страница: next_cursor = null.
    $cursor = $r->json('next_cursor');
    $r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor);
    $r2->assertStatus(200);
    expect($r2->json('next_cursor'))->toBeNull();
});
```

- [ ] **Step A.1.2: Запустить тесты — они должны упасть.**

```bash
cd app && php vendor/bin/pest --filter="DealIndex" --stop-on-failure
```

Expected: 3 новых теста FAIL (либо assertion mismatch на `next_cursor`, либо 422 не вернётся для невалидного cursor — текущий код игнорирует параметр).

### Task A.2 — Реализация keyset

- [ ] **Step A.2.1: Открыть `app/app/Http/Controllers/Api/DealController.php` и заменить метод `index` (строки 59–143).**

Логика:

- Если `cursor` пустой/отсутствует → старое поведение OFFSET (возвращаем `limit/offset/total`).
- Если `cursor` присутствует → декодировать `base64_decode → json_decode` → проверить ключи `r` (timestamp) + `i` (int id). При ошибке — 422.
- При валидном cursor → keyset query: `WHERE (received_at, id) < (cursor.r, cursor.i)` через `whereRaw('(received_at, id) < (?, ?)', [...])` (PG row constructor comparison).
- Возвращать `next_cursor` (base64-encoded `{r, i}` от последней записи в выдаче), `null` если страница неполная (выдано < limit).
- `total` возвращать только при OFFSET-режиме (при keyset — невозможно без COUNT(*) который дорого).

```php
public function index(Request $request): JsonResponse
{
    $tenantId = (int) $request->query('tenant_id', '0');
    if ($tenantId < 1) {
        return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
    }

    $tenant = Tenant::find($tenantId);
    if ($tenant === null) {
        return response()->json(['message' => 'Тенант не найден.'], 404);
    }

    $statuses = (array) $request->query('status_in', []);
    $projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
    $managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
    $search = trim((string) $request->query('search', ''));
    $limit = max(1, min(500, (int) $request->query('limit', '100')));
    $offset = max(0, (int) $request->query('offset', '0'));
    $onlyDeleted = $request->boolean('only_deleted');
    $cursorRaw = (string) $request->query('cursor', '');

    $cursor = null;
    if ($cursorRaw !== '') {
        $decoded = base64_decode($cursorRaw, true);
        if ($decoded === false) {
            return response()->json(['message' => 'Невалидный cursor (не base64).'], 422);
        }
        $parsed = json_decode($decoded, true);
        if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
            return response()->json(['message' => 'Невалидный cursor (нет полей r/i).'], 422);
        }
        $cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
    }

    [$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor) {
        DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);

        $query = Deal::query()
            ->where('tenant_id', $tenantId)
            ->with(['project:id,name', 'manager:id,email,first_name,last_name']);

        if ($onlyDeleted) {
            $query->withTrashed()->whereNotNull('deleted_at');
        }

        if ($statuses !== []) {
            $query->whereIn('status', array_filter($statuses, 'is_string'));
        }
        if ($projectId !== null) {
            $query->where('project_id', $projectId);
        }
        if ($managerId !== null) {
            $query->where('manager_id', $managerId);
        }
        if ($search !== '') {
            $like = '%'.$search.'%';
            $query->where(function ($q) use ($like) {
                $q->where('phone', 'ilike', $like)
                    ->orWhere('contact_name', 'ilike', $like);
            });
        }

        if ($cursor !== null) {
            // Keyset pagination — PG row constructor comparison через индекс
            // (received_at DESC, id DESC). Не считаем total (дорого без COUNT).
            $query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
            $rows = $query->orderByDesc('received_at')->orderByDesc('id')
                ->limit($limit + 1)->get(); // +1 чтобы понять, есть ли следующая страница
            $hasNext = $rows->count() > $limit;
            if ($hasNext) {
                $rows = $rows->slice(0, $limit)->values();
            }
            $next = null;
            if ($hasNext && $rows->isNotEmpty()) {
                $last = $rows->last();
                $next = base64_encode(json_encode([
                    'r' => $last->received_at?->toIso8601String(),
                    'i' => $last->id,
                ]));
            }

            return [$rows, null, $next];
        }

        // Старый OFFSET-путь (backward-compat для frontend).
        $total = (clone $query)->count();
        $rows = $query->orderByDesc('received_at')->orderByDesc('id')
            ->limit($limit + 1)->offset($offset)->get();
        $hasNext = $rows->count() > $limit;
        if ($hasNext) {
            $rows = $rows->slice(0, $limit)->values();
        }
        $next = null;
        if ($hasNext && $rows->isNotEmpty()) {
            $last = $rows->last();
            $next = base64_encode(json_encode([
                'r' => $last->received_at?->toIso8601String(),
                'i' => $last->id,
            ]));
        }

        return [$rows, $total, $next];
    });

    $payload = [
        'deals' => $deals->map(fn (Deal $d) => [
            'id' => $d->id,
            'tenant_id' => $d->tenant_id,
            'project_id' => $d->project_id,
            'project_name' => $d->project?->name,
            'phone' => $d->phone,
            'contact_name' => $d->contact_name,
            'status' => $d->status,
            'manager_id' => $d->manager_id,
            'manager_name' => $d->manager
                ? ManagerController::formatName($d->manager->first_name, $d->manager->last_name, $d->manager->email)
                : null,
            'manager_initials' => $d->manager
                ? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
                : null,
            'received_at' => $d->received_at?->toIso8601String(),
        ]),
        'limit' => $limit,
        'next_cursor' => $nextCursor,
    ];

    if ($cursor === null) {
        $payload['total'] = $total;
        $payload['offset'] = $offset;
    }

    return response()->json($payload);
}
```

- [ ] **Step A.2.2: Запустить тесты — должны пройти.**

```bash
cd app && php vendor/bin/pest --filter="DealIndex"
```

Expected: ВСЕ 3 новых теста PASS, старые тесты `DealIndexTest` тоже PASS (backward-compat сохранён).

- [ ] **Step A.2.3: Запустить полный Pest для регрессии.**

```bash
cd app && composer test
```

Expected: 419+ tests PASS (418 baseline + 3 новых; точное число зависит от других возможных skipped).

- [ ] **Step A.2.4: Larastan + Pint.**

```bash
cd app && composer pint && composer stan
```

Expected: Pint без diff, Larastan 0 errors.

- [ ] **Step A.2.5: Commit.**

```bash
git add app/app/Http/Controllers/Api/DealController.php app/tests/Feature/DealIndexTest.php
git commit -m "$(cat <<'EOF'
feat(backend): Sprint 4 Phase A — keyset pagination в DealController::index (audit O-perf-04)

Добавлен опциональный query-параметр `cursor` (base64-encoded {r:timestamp,i:id}).
При cursor — keyset через PG row constructor `(received_at, id) < (?, ?)`,
O(1) при любой глубине. Без cursor — старое OFFSET-поведение (backward-compat).

Возвращает `next_cursor` в обоих режимах (NULL = последняя страница).
`total` возвращается только при OFFSET (при keyset COUNT(*) дорог).

3 новых Pest-теста: keyset pagination, 422 на невалидный cursor,
next_cursor flow.

Frontend integration в `useDealsList`/`DealsView` — отдельным шагом
(не блокирует backend deploy, OFFSET путь жив).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Phase B — O-refactor-04 хвост: split 8 Vue-компонентов >300 строк

**Контекст:** Top-3 Vue-компонента (DealsView 852, ReportsView 592, DealDetailDrawer 580) split закрыт в Sprint 3 Phase C. Осталось 8 компонентов >300 строк:

| Компонент | Текущий size | Целевой | Стратегия split'а |
|---|---|---|---|
| `layouts/AppLayout.vue` | 466 | <200 | Извлечь `<AppSidebar>` (nav-tree) + `<AppTopbar>` (search/notifications/user-chip) |
| `views/admin/AdminTenantDetailView.vue` | 436 | <200 | Извлечь `<TenantHeader>` (base-info), `<TenantUsersTable>`, `<TenantBalanceHistory>`, `<TenantActivityList>` |
| `views/BillingView.vue` | 416 | <200 | Извлечь `<BalanceCard>`, `<TopupDialog>`, `<TransactionsTable>`, `<InvoicesTable>` |
| `views/admin/AdminTenantsView.vue` | 377 | <200 | Извлечь `<TenantsFilters>`, `<TenantsTable>` (typed VDataTable slots), `<TenantStatusChip>` |
| `views/settings/SecurityTab.vue` | 354 | <200 | Извлечь `<ChangePasswordCard>`, `<TwoFactorCard>`, `<RecoveryCodesCard>`, `<SessionsTable>` |
| `views/RemindersView.vue` | 345 | <200 | Извлечь `<RemindersFilters>`, `<RemindersList>`, `<ReminderForm>` |
| `views/errors/ErrorView.vue` | 320 | <200 | Извлечь `<ErrorIllustration>` (404/403/500 SVG), `<ErrorActions>` (Login/Home/Back buttons) |
| `views/DashboardView.vue` | 302 | <200 | Извлечь `<DashboardKpiRow>`, `<DashboardBalance>`, `<DashboardRecentDeals>` |

**Принцип split'а:**

- Sub-компоненты живут рядом (например, `AppSidebar.vue` и `AppTopbar.vue` в `layouts/parts/` или в `components/layout/`).
- Props down (родитель передаёт data), events up (sub-компонент эмитит actions).
- TypeScript: явные интерфейсы props.
- Vuetify: использовать typed slots для VDataTable где уместно (Sprint 2 Phase B уже начал — продолжать).
- Тесты: если sub-компонент имеет state/logic → отдельный Vitest test file. Чисто-визуальные slots — без tests.
- Story (Histoire): для каждого нового sub-компонента — `.story.vue` если разумно (variants для разных состояний).

**Files:**

- Modify: 8 view-файлов выше
- Create: ~25 новых sub-компонентов (3-4 на каждый split)
- Create: ~10-15 новых Vitest-тестов на sub-компоненты с логикой
- Modify: при необходимости — `composables/` для общих state-helper'ов

### Task B.1 — Группа 1: 3 admin views (AppLayout + AdminTenantDetailView + AdminTenantsView)

- [ ] **Step B.1.1: AppLayout.vue split.**

Создать `app/resources/js/components/layout/AppSidebar.vue` (nav-tree из текущего AppLayout строки ~40–250) с props `:nav-groups`, `:active-route`, events `@navigate`. ImportTL в AppLayout, заменить inline.

Создать `app/resources/js/components/layout/AppTopbar.vue` (topbar из текущего AppLayout строки ~250–420) с props `:user`, `:notifications-count`, events `@logout`, `@open-search`, `@toggle-notifications`. Импортировать в AppLayout.

После split: `wc -l app/resources/js/layouts/AppLayout.vue` должен показать <200.

- [ ] **Step B.1.2: AdminTenantDetailView.vue split.**

Создать в `app/resources/js/components/admin/tenant-detail/`:

- `TenantHeader.vue` (base info card: name, subdomain, status, tariff, mrr_rub, runway_days)
- `TenantUsersTable.vue` (VDataTable + 4 columns)
- `TenantBalanceHistory.vue` (VDataTable + transaction badges)
- `TenantActivityList.vue` (VList + actor + summary)

Каждый принимает `:tenant-detail` (или подмножество) через props, без emits (read-only view).

После split: `wc -l app/resources/js/views/admin/AdminTenantDetailView.vue` <200.

- [ ] **Step B.1.3: AdminTenantsView.vue split.**

Создать в `app/resources/js/components/admin/tenants/`:

- `TenantsFilters.vue` (search + status select + tariff select), props `:filters`, emit `@update:filters`
- `TenantsTable.vue` (VDataTable с typed slots: tenant column, status chip, MRR, last_active), props `:tenants`, emit `@row-click`
- `TenantStatusChip.vue` (chip с цветом по статусу: active/trial/suspended/churned)

После split: `wc -l app/resources/js/views/admin/AdminTenantsView.vue` <200.

- [ ] **Step B.1.4: Vitest для новых компонентов.**

Минимум по 1 test-file на компонент с логикой (TenantsFilters, TenantStatusChip — props/emits; TenantsTable — slot rendering; TenantHeader — computed display).

Шаблон: `tests/Vue/components/admin/tenants/TenantsFilters.test.ts` с `mount(TenantsFilters, { props: {...}})`, `wrapper.find('.v-text-field input').setValue(...)`, `expect(wrapper.emitted('update:filters')).toBeTruthy()`.

- [ ] **Step B.1.5: Регрессия.**

```bash
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build
```

Expected: ESLint 0, vue-tsc 0, Vitest все PASS (включая новые), build successful.

- [ ] **Step B.1.6: Commit.**

```bash
git add app/resources/js/layouts/AppLayout.vue \
        app/resources/js/components/layout/ \
        app/resources/js/views/admin/AdminTenantDetailView.vue \
        app/resources/js/components/admin/tenant-detail/ \
        app/resources/js/views/admin/AdminTenantsView.vue \
        app/resources/js/components/admin/tenants/ \
        app/tests/Vue/components/
git commit -m "refactor(frontend): Sprint 4 Phase B/1 — split 3 admin/layout views (audit O-refactor-04 хвост)

AppLayout 466→<200 (+ AppSidebar + AppTopbar)
AdminTenantDetailView 436→<200 (+ TenantHeader/UsersTable/BalanceHistory/ActivityList)
AdminTenantsView 377→<200 (+ TenantsFilters/Table/StatusChip)

+N Vitest на новые sub-компоненты с логикой.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

### Task B.2 — Группа 2: 3 user views (BillingView + SecurityTab + RemindersView)

- [ ] **Step B.2.1: BillingView.vue split.**

Создать в `app/resources/js/components/billing/`:

- `BalanceCard.vue` (текущий баланс + auto-topup status), props `:balance`, `:auto-topup-config`
- `TopupDialog.vue` (form: amount, payment-method), v-model:show, emit `@submit`
- `TransactionsTable.vue` (VDataTable + filters), props `:transactions`, `:filter-state`
- `InvoicesTable.vue` (VDataTable + download button), props `:invoices`

После split: <200 строк.

- [ ] **Step B.2.2: SecurityTab.vue split.**

Создать в `app/resources/js/components/settings/security/`:

- `ChangePasswordCard.vue` (form 3 fields: current/new/confirm)
- `TwoFactorCard.vue` (status + enable/disable + qr-dialog)
- `RecoveryCodesCard.vue` (list + regenerate)
- `SessionsTable.vue` (VDataTable + revoke action)

После split: <200 строк.

- [ ] **Step B.2.3: RemindersView.vue split.**

Создать в `app/resources/js/components/reminders/`:

- `RemindersFilters.vue` (status: active/done/overdue + manager-select)
- `RemindersList.vue` (VList с группировкой по дате)
- `ReminderForm.vue` (dialog: text + due_at + assignee)

После split: <200 строк.

- [ ] **Step B.2.4: Vitest для группы 2.**

Шаблон как в Task B.1.4. Минимум `BalanceCard` (computed display), `ChangePasswordCard` (validation), `RemindersFilters` (filter emit), `ReminderForm` (form submit).

- [ ] **Step B.2.5: Регрессия.**

```bash
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build
```

- [ ] **Step B.2.6: Commit.**

```bash
git add app/resources/js/views/BillingView.vue \
        app/resources/js/components/billing/ \
        app/resources/js/views/settings/SecurityTab.vue \
        app/resources/js/components/settings/security/ \
        app/resources/js/views/RemindersView.vue \
        app/resources/js/components/reminders/ \
        app/tests/Vue/components/
git commit -m "refactor(frontend): Sprint 4 Phase B/2 — split 3 user views (audit O-refactor-04 хвост)

BillingView 416→<200 (+ BalanceCard/TopupDialog/Transactions/InvoicesTable)
SecurityTab 354→<200 (+ ChangePassword/TwoFactor/RecoveryCodes/Sessions)
RemindersView 345→<200 (+ RemindersFilters/List/Form)

+N Vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

### Task B.3 — Группа 3: 2 utility views (ErrorView + DashboardView)

- [ ] **Step B.3.1: ErrorView.vue split.**

Создать в `app/resources/js/components/errors/`:

- `ErrorIllustration.vue` (SVG 404/403/500, props `:code`)
- `ErrorActions.vue` (Login/Home/Back buttons, props `:code`, emit `@navigate`)

После split: <200 строк.

- [ ] **Step B.3.2: DashboardView.vue split.**

Создать в `app/resources/js/components/dashboard/`:

- `DashboardKpiRow.vue` (3-4 KPI-карты: leads_today/week/month, props `:metrics`)
- `DashboardBalance.vue` (баланс + low-balance warning)
- `DashboardRecentDeals.vue` (список последних 10 сделок, props `:deals`)

После split: <200 строк.

- [ ] **Step B.3.3: Vitest для группы 3.**

`ErrorIllustration` (SVG render по props.code), `DashboardKpiRow` (компьютед labels + numerics через JetBrains Mono `tnum`).

- [ ] **Step B.3.4: Регрессия.**

```bash
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build
```

- [ ] **Step B.3.5: Commit.**

```bash
git add app/resources/js/views/errors/ErrorView.vue \
        app/resources/js/components/errors/ \
        app/resources/js/views/DashboardView.vue \
        app/resources/js/components/dashboard/ \
        app/tests/Vue/components/
git commit -m "refactor(frontend): Sprint 4 Phase B/3 — split 2 utility views (audit O-refactor-04 хвост)

ErrorView 320→<200 (+ ErrorIllustration + ErrorActions)
DashboardView 302→<200 (+ KpiRow + Balance + RecentDeals)

+N Vitest. O-refactor-04 закрыт полностью (12 → 0 компонентов >300 строк
после Sprint 3 Phase C + Sprint 4 Phase B/1-3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```

### Task B.4 — Acceptance Phase B

- [ ] **Step B.4.1: Проверить, что 0 Vue-компонентов >300 строк.**

```bash
find app/resources/js -name "*.vue" -exec wc -l {} + | awk '$1 > 300 && $2 != "total" {print}'
```

Expected: пустой output (либо 1-2 с явным обоснованием в комментарии — например, гигантский `app.vue` шаблон если есть).

- [ ] **Step B.4.2: Histoire build smoke.**

```bash
cd app && npm run story:build
```

Expected: build successful, новые stories видны (опционально — по 1 story на каждый sub-компонент с логикой).

---

## Phase C — O-refactor-06: dead-code detection + удаление unused exports

**Контекст:** Bundle size analyzer покажет, какие модули включены в финальный build, и какие exports никогда не импортируются. Цель — найти и удалить мёртвый код в `app/resources/js/utils/` и `app/resources/js/helpers/` (если есть). Установка `rollup-plugin-visualizer` через Vite-плагин — стандартный способ.

**Files:**

- Create: `app/scripts/analyze-bundle.sh` (опционально, или просто в README)
- Modify: `app/vite.config.js` (добавить visualizer plugin условно)
- Modify: `app/package.json` (новый script `npm run build:analyze`)
- Modify: файлы в `app/resources/js/utils/` и `helpers/` (удаление unused exports)

### Task C.1 — Установить bundle analyzer

- [ ] **Step C.1.1: Установить rollup-plugin-visualizer как devDependency.**

```bash
cd app && npm install --save-dev rollup-plugin-visualizer
```

Expected: пакет в `devDependencies` секции `package.json`, никаких peer-dep warnings (если есть — `--legacy-peer-deps`).

- [ ] **Step C.1.2: Modify `app/vite.config.js` — добавить visualizer условно.**

```javascript
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import vuetify from 'vite-plugin-vuetify';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.ts'],
            refresh: true,
        }),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
        vuetify({ autoImport: true }),
        // Bundle analyzer — активен только при `BUILD_ANALYZE=1` или `vite build --mode analyze`.
        // Генерирует storage/bundle-analyze.html (открыть в браузере).
        process.env.BUILD_ANALYZE === '1' && visualizer({
            filename: 'storage/bundle-analyze.html',
            open: false,
            gzipSize: true,
            brotliSize: true,
        }),
    ].filter(Boolean),
    server: {
        watch: {
            ignored: ['**/storage/framework/views/**'],
        },
    },
});
```

- [ ] **Step C.1.3: Modify `app/package.json` — добавить script.**

В секцию `scripts`:

```json
"build:analyze": "BUILD_ANALYZE=1 vite build"
```

(на Windows — кросс-платформенно через `cross-env` если будет нужно; пока на dev-стенде используем PowerShell `$env:BUILD_ANALYZE='1'; npm run build`).

- [ ] **Step C.1.4: Запустить analyzer.**

```bash
cd app && BUILD_ANALYZE=1 npm run build
# Windows PowerShell альтернатива:
# cd app; $env:BUILD_ANALYZE='1'; npm run build
```

Expected: `storage/bundle-analyze.html` создан, vite build успешен.

### Task C.2 — Найти и удалить unused exports

- [ ] **Step C.2.1: Открыть `storage/bundle-analyze.html` в браузере.**

Просмотреть treemap. Найти:

- Модули с подозрительно маленьким footprint (1-2 импорта на весь bundle — кандидаты на inline)
- Модули из `utils/` и `helpers/` которые не входят в build вообще (они unused — удалить)

Альтернатива через CLI: использовать `knip` или `ts-prune` для систематического поиска. Установка опциональна, если visualizer достаточно.

- [ ] **Step C.2.2: Установить `knip` для автоматического поиска unused exports.**

```bash
cd app && npm install --save-dev knip
```

- [ ] **Step C.2.3: Запустить knip с базовым конфигом.**

Создать `app/knip.config.ts`:

```typescript
import type { KnipConfig } from 'knip';

const config: KnipConfig = {
    entry: ['resources/js/app.ts', 'resources/js/router/index.ts'],
    project: ['resources/js/**/*.{ts,vue}'],
    ignore: ['**/*.story.vue', 'tests/**'],
    ignoreDependencies: ['@vue/test-utils', 'jsdom', 'vitest'],
};

export default config;
```

```bash
cd app && npx knip
```

Expected: список unused files / unused exports / unused dependencies.

- [ ] **Step C.2.4: Удалить найденные dead exports.**

Идти по списку knip:

- **Unused files** → если правда не нужен (проверить через `Grep` импорты по имени) → `git rm`.
- **Unused exports** → удалить export, оставить функцию приватной если используется внутри файла; иначе `git rm` функцию целиком.
- **Unused dependencies** → `npm uninstall <pkg>`.

Аккуратно: `knip` может ложно-положительно ругаться на dynamic imports, Vuetify auto-import, pinia stores. Перед удалением — `Grep` проверка.

- [ ] **Step C.2.5: Регрессия.**

```bash
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build
```

Expected: всё зелёное. Если что-то сломалось — `git checkout` файл, перепроверить.

- [ ] **Step C.2.6: Повторный knip — должен показать 0 unused (или явно объяснимые false-positive).**

```bash
cd app && npx knip
```

- [ ] **Step C.2.7: Commit.**

```bash
git add app/vite.config.js app/package.json app/package-lock.json app/knip.config.ts \
        app/resources/js/utils/ app/resources/js/helpers/
git commit -m "$(cat <<'EOF'
chore(frontend): Sprint 4 Phase C — bundle analyzer + dead-code cleanup (audit O-refactor-06)

- rollup-plugin-visualizer + script `npm run build:analyze` (env BUILD_ANALYZE=1)
- knip + конфиг + cleanup unused exports/files в utils/ и helpers/
- удалено N exports, M dependencies (точное число — в diff)

Bundle size снижение: ~X% gzip (точные цифры из storage/bundle-analyze.html).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Финальная регрессия + summary

### Task R.1 — Полный sweep после Sprint 4

- [ ] **Step R.1.1: Backend полная регрессия.**

```bash
cd app && composer pint && composer stan && composer test
```

Expected: Pint без diff, Larastan 0, Pest 419+ (baseline 418 + 3 keyset).

- [ ] **Step R.1.2: Frontend полная регрессия.**

```bash
cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build && npm run story:build
```

Expected: ESLint 0, vue-tsc 0, Vitest 430+ (baseline 416 + ~15 за новые sub-components), build OK, Histoire build OK.

- [ ] **Step R.1.3: Doc-sweep.**

```bash
npm run check:docs
```

Expected: markdownlint 0, cspell 0, lychee 0, pa11y 0 (skip если HTML концепты убраны).

- [ ] **Step R.1.4: Squawk + format:sql:check.**

```bash
npm run lint:sql && npm run format:sql:check
```

Expected: 0 issues, format-check без diff.

- [ ] **Step R.1.5: Обновить CLAUDE.md и Открытые_вопросы_v8_3.md через `claude-md-management`.**

Через skill `/claude-md-management:claude-md-improver` — bump CLAUDE.md шапки (новые метрики Pest/Vitest/Vue-counts), обновить §6 «Текущая фаза» с записью «Sprint 4 «Audit tail» закрыт».

В `docs/Открытые_вопросы_v8_3.md` — добавить запись в начало с детальной разбивкой Sprint 4.

В `db/CHANGELOG_schema.md` — без изменений (Sprint 4 не трогает schema).

- [ ] **Step R.1.6: Commit doc-sync.**

```bash
git add CLAUDE.md docs/Открытые_вопросы_v8_3.md docs/CHANGELOG_claude_md.md
git commit -m "docs(narrative): sync versions + Sprint 4 acceptance (audit O-perf-04 + O-refactor-04 хвост + O-refactor-06)"
```

### Acceptance Sprint 4

- ✅ keyset pagination в `DealController::index` работает + Pest +3 теста
- ✅ 0 Vue-компонентов >300 строк (12 audit-кандидатов закрыты: Top-3 в Sprint 3 + 8 в Sprint 4 + ImpersonationDialog уже <300)
- ✅ Bundle analyzer + knip активны, dead exports удалены
- ✅ Регрессия зелёная (Pest, Vitest, Larastan, vue-tsc, ESLint, squawk, markdownlint, cspell, lychee, build, Histoire)
- ✅ CLAUDE.md / Открытые_вопросы синхронизированы
- ✅ 6 коммитов: Phase A (keyset) + Phase B/1 (3 admin) + Phase B/2 (3 user) + Phase B/3 (2 utility) + Phase C (cleanup) + R (doc-sync). При минимальном doc-sync R можно слить с C — итого 5.
