# Sprint 3D — Admin Actions (G4/G5/G6) 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:** Дать SaaS-админу действия над тенантами в биллинге (приостановка/возврат/смена тарифа), карточку инцидента с drill-down и кнопку РКН-уведомления.

**Architecture:** Backend — новые методы на `AdminBillingController` и `AdminIncidentsController` (паттерн существующих admin-контроллеров: `DB::table` + `DB::transaction`, audit-trail в `saas_admin_audit_log`, MVP без auth-middleware). Frontend — выпадающее меню действий в `AdminBillingView`, новая карточка `AdminIncidentDetailView` по образцу `AdminTenantDetailView`. Аудит-источник: `docs/superpowers/specs/2026-05-15-portal-audit-design.md` строки G4/G5/G6.

**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 / Pest 4 — Vue 3 / Vuetify 3 / vue-router 4 / Vitest 4 / TypeScript.

**Branch:** `feat/sprint3d-admin-actions` от `origin/main`.

---

## File Structure

**Backend create:**

- `app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php` — трейт-помощник: резолв `admin_user_id` для audit-log (выносит дублирующийся метод из `AdminPricingTiersController`/`AdminSystemSettingsController`; используется только новым кодом).
- `app/tests/Feature/AdminBillingActionsTest.php` — Pest G4.
- `app/tests/Feature/AdminIncidentShowTest.php` — Pest G5.
- `app/tests/Feature/AdminIncidentRknNotifyTest.php` — Pest G6.

**Backend modify:**

- `app/app/Http/Controllers/Api/AdminBillingController.php` — +`tariffPlans()`, +`updateStatus()`, +`refund()`, +`changeTariff()`.
- `app/app/Http/Controllers/Api/AdminIncidentsController.php` — +`show()`, +`notifyRkn()`.
- `app/routes/web.php` — +6 маршрутов.

**Frontend create:**

- `app/resources/js/views/admin/AdminIncidentDetailView.vue` — карточка инцидента.
- `app/tests/Frontend/AdminBillingViewActions.spec.ts` — Vitest G4.
- `app/tests/Frontend/AdminIncidentDetailView.spec.ts` — Vitest G5/G6.

**Frontend modify:**

- `app/resources/js/api/admin.ts` — +6 функций + типы.
- `app/resources/js/views/admin/AdminBillingView.vue` — +колонка действий.
- `app/resources/js/views/admin/AdminIncidentsView.vue` — строки кликабельны → детальная.
- `app/resources/js/router/index.ts` — +маршрут `/admin/incidents/:id`.
- `app/tests/Frontend/AdminIncidentsView.spec.ts` — +тест навигации.
- `app/tests/Frontend/admin-api.spec.ts` — +тесты новых функций.

**Известная мелочь:** `resolveAdminUserId` остаётся продублированным в `AdminPricingTiersController` и `AdminSystemSettingsController` — рефактор этих рабочих контроллеров вне scope; новый трейт применяется только к новому коду.

---

## Task 1: G4 backend — billing actions (status / refund / change-tariff)

**Files:**

- Create: `app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php`
- Modify: `app/app/Http/Controllers/Api/AdminBillingController.php`
- Modify: `app/routes/web.php` (после строки 99 `GET /api/admin/billing`)
- Test: `app/tests/Feature/AdminBillingActionsTest.php`

**Контракт endpoint'ов** (все MVP без auth-middleware — паритет с `GET /api/admin/billing`):

| Метод | Путь | Тело | Ответ |
|---|---|---|---|
| GET | `/api/admin/billing/tariff-plans` | — | `{plans:[{id,name,price_monthly}]}` |
| PATCH | `/api/admin/billing/tenants/{id}/status` | `{status,reason}` | `{id,status}` |
| POST | `/api/admin/billing/tenants/{id}/refund` | `{amount_rub,reason}` | `{id,balance_rub,transaction_id}` |
| PATCH | `/api/admin/billing/tenants/{id}/tariff` | `{tariff_id,reason}` | `{id,tariff_id,tariff_name}` |

Правила: `status` ∈ `active,suspended`; `reason` строка `min:10 max:1000`; `amount_rub` numeric `>0`, не больше текущего `balance_rub` (иначе 422); `tariff_id` `exists:tariff_plans,id`. Несуществующий или soft-deleted тенант (`deleted_at` не NULL) → 404. Каждое мутирующее действие пишет строку в `saas_admin_audit_log`.

- [ ] **Step 1: Создать трейт `ResolvesAdminUserId`**

`app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php`:

```php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Concerns;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

/**
 * Резолв saas_admin_users.id для audit-trail на MVP (saas-admin SSO ⏸ Б-1).
 *
 * Берёт admin_user_id из request-параметра; при отсутствии валидного —
 * создаёт/переиспользует системный стаб-аккаунт (не loginable, is_active=false),
 * чтобы соблюсти NOT NULL + FK на saas_admin_users в saas_admin_audit_log.
 *
 * Паттерн ранее дублировался в AdminPricingTiersController /
 * AdminSystemSettingsController; новый код использует этот трейт.
 */
trait ResolvesAdminUserId
{
    protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
    {
        $requested = $request->input('admin_user_id');
        if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
            $existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
            if ($existing !== null) {
                return (int) $existing;
            }
        }

        $existingId = DB::table('saas_admin_users')->where('email', $stubEmail)->value('id');
        if ($existingId !== null) {
            return (int) $existingId;
        }

        return (int) DB::table('saas_admin_users')->insertGetId([
            'email' => $stubEmail,
            'full_name' => $stubName,
            'password_hash' => '$2y$04$system-stub-not-loginable',
            'role' => 'super_admin',
            'is_active' => false,
            'sso_provider' => 'local',
            'is_break_glass' => false,
        ]);
    }
}
```

- [ ] **Step 2: Написать failing-тест `AdminBillingActionsTest.php`**

```php
<?php

declare(strict_types=1);

use App\Models\BalanceTransaction;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

function makeBillingTenant(array $overrides = []): int
{
    return (int) DB::table('tenants')->insertGetId(array_merge([
        'subdomain' => 'bt-'.bin2hex(random_bytes(4)),
        'organization_name' => 'Billing Test Co',
        'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local',
        'webhook_token' => bin2hex(random_bytes(16)),
        'status' => 'active',
        'balance_rub' => '5000.00',
        'is_trial' => false,
        'created_at' => now(),
    ], $overrides));
}

test('GET tariff-plans возвращает список планов', function () {
    $r = $this->getJson('/api/admin/billing/tariff-plans');
    $r->assertOk();
    expect($r->json('plans'))->toBeArray();
});

test('PATCH status suspended меняет статус + пишет audit-log', function () {
    $id = makeBillingTenant(['status' => 'active']);
    $r = $this->patchJson("/api/admin/billing/tenants/{$id}/status", [
        'status' => 'suspended',
        'reason' => 'Просрочка оплаты более 30 дней.',
    ]);
    $r->assertOk();
    expect($r->json('status'))->toBe('suspended');
    expect(DB::table('tenants')->where('id', $id)->value('status'))->toBe('suspended');
    expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.suspend')->where('target_id', $id)->exists())->toBeTrue();
});

test('PATCH status active разблокирует', function () {
    $id = makeBillingTenant(['status' => 'suspended']);
    $this->patchJson("/api/admin/billing/tenants/{$id}/status", [
        'status' => 'active', 'reason' => 'Оплата получена, блокировка снята.',
    ])->assertOk();
    expect(DB::table('tenants')->where('id', $id)->value('status'))->toBe('active');
});

test('PATCH status reason короче 10 символов → 422', function () {
    $id = makeBillingTenant();
    $this->patchJson("/api/admin/billing/tenants/{$id}/status", ['status' => 'suspended', 'reason' => 'мало'])
        ->assertStatus(422);
});

test('PATCH status несуществующий тенант → 404', function () {
    $this->patchJson('/api/admin/billing/tenants/99999999/status', [
        'status' => 'suspended', 'reason' => 'Любое основание длиной более десяти.',
    ])->assertStatus(404);
});

test('PATCH status soft-deleted тенант → 404', function () {
    $id = makeBillingTenant(['deleted_at' => now()]);
    $this->patchJson("/api/admin/billing/tenants/{$id}/status", [
        'status' => 'suspended', 'reason' => 'Любое основание длиной более десяти.',
    ])->assertStatus(404);
});

test('POST refund списывает с баланса + создаёт balance_transactions refund', function () {
    $id = makeBillingTenant(['balance_rub' => '5000.00']);
    $r = $this->postJson("/api/admin/billing/tenants/{$id}/refund", [
        'amount_rub' => 1500, 'reason' => 'Возврат по обращению клиента №42.',
    ]);
    $r->assertOk();
    expect($r->json('balance_rub'))->toBe('3500.00');
    expect(DB::table('tenants')->where('id', $id)->value('balance_rub'))->toBe('3500.00');
    $tx = BalanceTransaction::where('tenant_id', $id)->where('type', 'refund')->first();
    expect($tx)->not->toBeNull();
    expect((string) $tx->amount_rub)->toBe('-1500.00');
    expect((string) $tx->balance_rub_after)->toBe('3500.00');
    expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.refund')->where('target_id', $id)->exists())->toBeTrue();
});

test('POST refund больше баланса → 422, баланс не меняется', function () {
    $id = makeBillingTenant(['balance_rub' => '1000.00']);
    $this->postJson("/api/admin/billing/tenants/{$id}/refund", [
        'amount_rub' => 5000, 'reason' => 'Возврат по обращению клиента №7.',
    ])->assertStatus(422);
    expect(DB::table('tenants')->where('id', $id)->value('balance_rub'))->toBe('1000.00');
    expect(BalanceTransaction::where('tenant_id', $id)->count())->toBe(0);
});

test('POST refund неположительная сумма → 422', function () {
    $id = makeBillingTenant();
    $this->postJson("/api/admin/billing/tenants/{$id}/refund", ['amount_rub' => 0, 'reason' => 'Основание длиннее десяти символов.'])
        ->assertStatus(422);
});

test('PATCH tariff меняет current_tariff_id + audit-log', function () {
    $id = makeBillingTenant();
    $tariffId = (int) DB::table('tariff_plans')->orderBy('id')->value('id');
    expect($tariffId)->toBeGreaterThan(0);
    $r = $this->patchJson("/api/admin/billing/tenants/{$id}/tariff", [
        'tariff_id' => $tariffId, 'reason' => 'Переход на тариф по договорённости с клиентом.',
    ]);
    $r->assertOk();
    expect((int) DB::table('tenants')->where('id', $id)->value('current_tariff_id'))->toBe($tariffId);
    expect(DB::table('saas_admin_audit_log')->where('action', 'tenant.change_tariff')->where('target_id', $id)->exists())->toBeTrue();
});

test('PATCH tariff несуществующий tariff_id → 422', function () {
    $id = makeBillingTenant();
    $this->patchJson("/api/admin/billing/tenants/{$id}/tariff", [
        'tariff_id' => 88888888, 'reason' => 'Основание длиннее десяти символов.',
    ])->assertStatus(422);
});
```

**NB:** тест `PATCH tariff` опирается на наличие хотя бы одной строки в `tariff_plans`. `db/schema.sql` загружает справочник тарифов в секции 11. Если на тестовой БД таблица пуста — тест провалится на `expect($tariffId)->toBeGreaterThan(0)`; в этом случае добавить в тест `DB::table('tariff_plans')->insert([...])` перед резолвом id (колонки: `name`, `price_monthly`, `slug` если есть — свериться с `db/schema.sql` `CREATE TABLE tariff_plans`).

- [ ] **Step 3: Прогнать тест — убедиться, что падает**

Run: `cd app && php artisan test tests/Feature/AdminBillingActionsTest.php`
Expected: FAIL — route not defined (404 на всех новых путях).

- [ ] **Step 4: Добавить методы в `AdminBillingController`**

В шапку класса — `use App\Http\Controllers\Concerns\ResolvesAdminUserId;`, `use App\Models\BalanceTransaction;`, `use App\Models\SaasAdminAuditLog;`, `use Illuminate\Http\Response;`. В тело класса — `use ResolvesAdminUserId;`.

Добавить 4 метода:

```php
/** GET /api/admin/billing/tariff-plans — список планов для диалога смены тарифа. */
public function tariffPlans(): JsonResponse
{
    $plans = DB::table('tariff_plans')
        ->select(['id', 'name', 'price_monthly'])
        ->orderBy('price_monthly')
        ->get()
        ->map(fn ($p) => [
            'id' => (int) $p->id,
            'name' => $p->name,
            'price_monthly' => (string) $p->price_monthly,
        ]);

    return response()->json(['plans' => $plans]);
}

/** PATCH /api/admin/billing/tenants/{id}/status — приостановить/разблокировать тенанта. */
public function updateStatus(Request $request, int $id): JsonResponse
{
    $validated = $request->validate([
        'status' => ['required', 'in:active,suspended'],
        'reason' => ['required', 'string', 'min:10', 'max:1000'],
    ]);

    $tenant = $this->findActiveTenant($id);
    $adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');

    DB::transaction(function () use ($tenant, $validated, $adminUserId, $request): void {
        DB::table('tenants')->where('id', $tenant->id)->update([
            'status' => $validated['status'],
            'updated_at' => now(),
        ]);

        SaasAdminAuditLog::create([
            'admin_user_id' => $adminUserId,
            'action' => $validated['status'] === 'suspended' ? 'tenant.suspend' : 'tenant.activate',
            'target_type' => 'tenant',
            'target_id' => $tenant->id,
            'target_tenant_id' => $tenant->id,
            'payload_before' => ['status' => $tenant->status],
            'payload_after' => ['status' => $validated['status']],
            'reason' => $validated['reason'],
            'ip_address' => $request->ip() ?? '127.0.0.1',
            'user_agent' => $request->userAgent(),
        ]);
    });

    return response()->json(['id' => $tenant->id, 'status' => $validated['status']]);
}

/** POST /api/admin/billing/tenants/{id}/refund — возврат средств: списание с баланса + ledger-запись. */
public function refund(Request $request, int $id): JsonResponse
{
    $validated = $request->validate([
        'amount_rub' => ['required', 'numeric', 'gt:0'],
        'reason' => ['required', 'string', 'min:10', 'max:1000'],
    ]);

    $tenant = $this->findActiveTenant($id);

    $amount = number_format((float) $validated['amount_rub'], 2, '.', '');
    $balance = (string) $tenant->balance_rub;
    if (bccomp($amount, $balance, 2) === 1) {
        abort(422, 'refund amount exceeds tenant balance');
    }
    $newBalance = bcsub($balance, $amount, 2);
    $adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');

    $transactionId = DB::transaction(function () use ($tenant, $amount, $newBalance, $validated, $adminUserId, $request): int {
        DB::statement('SET LOCAL app.current_tenant_id = ?', [(string) $tenant->id]);

        DB::table('tenants')->where('id', $tenant->id)->update([
            'balance_rub' => $newBalance,
            'updated_at' => now(),
        ]);

        $tx = BalanceTransaction::create([
            'tenant_id' => $tenant->id,
            'type' => BalanceTransaction::TYPE_REFUND,
            'amount_rub' => '-'.$amount,
            'amount_leads' => 0,
            'balance_rub_after' => $newBalance,
            'description' => $validated['reason'],
            'admin_user_id' => $adminUserId,
            'created_at' => now(),
        ]);

        SaasAdminAuditLog::create([
            'admin_user_id' => $adminUserId,
            'action' => 'tenant.refund',
            'target_type' => 'tenant',
            'target_id' => $tenant->id,
            'target_tenant_id' => $tenant->id,
            'payload_before' => ['balance_rub' => (string) $tenant->balance_rub],
            'payload_after' => ['balance_rub' => $newBalance, 'amount_rub' => $amount, 'transaction_id' => $tx->id],
            'reason' => $validated['reason'],
            'ip_address' => $request->ip() ?? '127.0.0.1',
            'user_agent' => $request->userAgent(),
        ]);

        return (int) $tx->id;
    });

    return response()->json([
        'id' => $tenant->id,
        'balance_rub' => $newBalance,
        'transaction_id' => $transactionId,
    ]);
}

/** PATCH /api/admin/billing/tenants/{id}/tariff — сменить тарифный план тенанта. */
public function changeTariff(Request $request, int $id): JsonResponse
{
    $validated = $request->validate([
        'tariff_id' => ['required', 'integer', 'exists:tariff_plans,id'],
        'reason' => ['required', 'string', 'min:10', 'max:1000'],
    ]);

    $tenant = $this->findActiveTenant($id);
    $tariff = DB::table('tariff_plans')->where('id', $validated['tariff_id'])->first();
    $adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');

    DB::transaction(function () use ($tenant, $tariff, $validated, $adminUserId, $request): void {
        DB::table('tenants')->where('id', $tenant->id)->update([
            'current_tariff_id' => $tariff->id,
            'updated_at' => now(),
        ]);

        SaasAdminAuditLog::create([
            'admin_user_id' => $adminUserId,
            'action' => 'tenant.change_tariff',
            'target_type' => 'tenant',
            'target_id' => $tenant->id,
            'target_tenant_id' => $tenant->id,
            'payload_before' => ['current_tariff_id' => $tenant->current_tariff_id],
            'payload_after' => ['current_tariff_id' => (int) $tariff->id],
            'reason' => $validated['reason'],
            'ip_address' => $request->ip() ?? '127.0.0.1',
            'user_agent' => $request->userAgent(),
        ]);
    });

    return response()->json([
        'id' => $tenant->id,
        'tariff_id' => (int) $tariff->id,
        'tariff_name' => $tariff->name,
    ]);
}

/** Возвращает не-удалённого тенанта либо abort(404). */
private function findActiveTenant(int $id): object
{
    $tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')->first();
    if ($tenant === null) {
        abort(404, 'tenant not found');
    }

    return $tenant;
}
```

- [ ] **Step 5: Добавить маршруты в `routes/web.php`**

Сразу после строки `Route::get('/api/admin/billing', ...)` (≈строка 99):

```php
// Sprint 3D (G4): SaaS-admin billing row-actions — приостановка/возврат/смена тарифа.
Route::get('/api/admin/billing/tariff-plans', 'App\Http\Controllers\Api\AdminBillingController@tariffPlans');
Route::patch('/api/admin/billing/tenants/{id}/status', 'App\Http\Controllers\Api\AdminBillingController@updateStatus')
    ->where('id', '[0-9]+');
Route::post('/api/admin/billing/tenants/{id}/refund', 'App\Http\Controllers\Api\AdminBillingController@refund')
    ->where('id', '[0-9]+');
Route::patch('/api/admin/billing/tenants/{id}/tariff', 'App\Http\Controllers\Api\AdminBillingController@changeTariff')
    ->where('id', '[0-9]+');
```

- [ ] **Step 6: Прогнать тест — убедиться, что проходит**

Run: `cd app && php artisan test tests/Feature/AdminBillingActionsTest.php`
Expected: PASS — все кейсы зелёные.

- [ ] **Step 7: Регенерация phpstan-baseline (quirk 25) + pint**

Новый Pest-файл порождает Larastan false-positives `TestCall::$tenant`. Регенерация:

```bash
cd app
composer pint
# 1. убрать строку "- phpstan-baseline.neon" из includes: в phpstan.neon
php vendor/bin/phpstan analyse --generate-baseline=phpstan-baseline.neon --memory-limit=512M
# 2. вернуть строку "- phpstan-baseline.neon" в includes: в phpstan.neon
composer stan
```

Expected: `composer stan` — `[OK] No errors`.

- [ ] **Step 8: Commit**

```bash
git add app/app/Http/Controllers/Concerns/ResolvesAdminUserId.php \
  app/app/Http/Controllers/Api/AdminBillingController.php \
  app/routes/web.php app/tests/Feature/AdminBillingActionsTest.php \
  app/phpstan-baseline.neon
git commit -m "feat(admin): G4 backend — billing tenant actions (status/refund/tariff)"
```

---

## Task 2: G4 frontend — billing row-actions UI

**Files:**

- Modify: `app/resources/js/api/admin.ts`
- Modify: `app/resources/js/views/admin/AdminBillingView.vue`
- Test: `app/tests/Frontend/AdminBillingViewActions.spec.ts`

- [ ] **Step 1: Добавить API-функции в `api/admin.ts`**

В конец файла:

```typescript
// === SaaS-admin → Биллинг: row-actions (Sprint 3D G4) ===

export interface AdminTariffPlan {
    id: number;
    name: string;
    price_monthly: string;
}

export async function listAdminTariffPlans(): Promise<AdminTariffPlan[]> {
    const { data } = await apiClient.get<{ plans: AdminTariffPlan[] }>('/api/admin/billing/tariff-plans');
    return data.plans;
}

export async function updateTenantStatus(
    id: number,
    status: 'active' | 'suspended',
    reason: string,
): Promise<{ id: number; status: string }> {
    await ensureCsrfCookie();
    const { data } = await apiClient.patch<{ id: number; status: string }>(
        `/api/admin/billing/tenants/${id}/status`,
        { status, reason },
    );
    return data;
}

export async function refundTenant(
    id: number,
    amountRub: number,
    reason: string,
): Promise<{ id: number; balance_rub: string; transaction_id: number }> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<{ id: number; balance_rub: string; transaction_id: number }>(
        `/api/admin/billing/tenants/${id}/refund`,
        { amount_rub: amountRub, reason },
    );
    return data;
}

export async function changeTenantTariff(
    id: number,
    tariffId: number,
    reason: string,
): Promise<{ id: number; tariff_id: number; tariff_name: string }> {
    await ensureCsrfCookie();
    const { data } = await apiClient.patch<{ id: number; tariff_id: number; tariff_name: string }>(
        `/api/admin/billing/tenants/${id}/tariff`,
        { tariff_id: tariffId, reason },
    );
    return data;
}
```

- [ ] **Step 2: Написать failing-тест `AdminBillingViewActions.spec.ts`**

Образец монтирования и моков — `app/tests/Frontend/AdminBillingViewApi.spec.ts` (read it). Тест мокает `../../resources/js/api/admin` через `vi.mock`. Кейсы:

```
1. в строке таблицы есть кнопка-меню действий (data-testid="row-actions-{id}")
2. клик «Приостановить» → открывается диалог с textarea основания
3. submit диалога приостановки с валидным основанием → вызывает updateTenantStatus(id,'suspended',reason) и перечитывает loadBilling
4. диалог возврата: submit вызывает refundTenant(id, amount, reason)
5. диалог смены тарифа: на открытии тянет listAdminTariffPlans(); submit вызывает changeTenantTariff(id, tariffId, reason)
6. ошибка API в действии → показывается v-alert/snackbar (НЕ молчаливый провал — урок аудита G1/G2)
7. основание короче 10 символов → submit-кнопка disabled или показывает ошибку валидации
```

Запустить: `cd app && npm run test:vue -- AdminBillingViewActions` → Expected: FAIL (нет колонки действий).

- [ ] **Step 3: Реализовать UI действий в `AdminBillingView.vue`**

Добавить в `<v-data-table>` колонку `actions` (последняя, `sortable:false`, `align:'end'`) со слотом `#[`item.actions`]` — `<v-menu>` с `<v-btn icon="mdi-dots-vertical" data-testid="row-actions-{id}">` активатором и `<v-list>` из 3 пунктов:

- «Приостановить» (если `status !== 'suspended'`) / «Разблокировать» (если `suspended`).
- «Возврат средств».
- «Сменить тариф».

Состояние: `actionDialog = ref<null | 'status' | 'refund' | 'tariff'>(null)`, `actionRow = ref<BillingRow | null>(null)`, `actionReason = ref('')`, `actionAmount = ref<number | null>(null)`, `actionTariffId = ref<number | null>(null)`, `actionLoading = ref(false)`, `actionError = ref('')`, `tariffPlans = ref<AdminTariffPlan[]>([])`.

Три `<v-dialog>` (или один с переключаемым телом):

- **status** — текст подтверждения + `<v-textarea v-model="actionReason" data-testid="action-reason">`.
- **refund** — `<v-text-field type="number" v-model.number="actionAmount" data-testid="refund-amount">` + textarea основания. Подсказка: «доступно к возврату: {balance}».
- **tariff** — `<v-select :items="tariffPlans" item-title="name" item-value="id" v-model="actionTariffId" data-testid="tariff-select">` + textarea основания. При открытии диалога — `tariffPlans.value = await listAdminTariffPlans()`.

Submit-handler: общий `confirmAction()`:

1. валидация: `actionReason.length >= 10`; для refund — `actionAmount > 0`; для tariff — `actionTariffId !== null`. Иначе `actionError` + ранний return.
2. `actionLoading = true`, `actionError = ''`.
3. try: вызвать соответствующую API-функцию; `await loadBilling()`; закрыть диалог (`actionDialog = null`).
4. catch: `actionError = extractErrorMessage(e)` (импортировать из `../../api/client`) — **ошибка обязана быть видимой** в диалоге через `<v-alert v-if="actionError" type="error" data-testid="action-error">`.
5. finally: `actionLoading = false`.

Кнопка submit — `:loading="actionLoading"`, `:disabled` при невалидном основании.

`defineExpose` дополнить: `actionDialog, actionRow, actionReason, actionAmount, actionTariffId, actionError, confirmAction, openAction` (где `openAction(type, row)` ставит state и для tariff подгружает планы) — чтобы Vitest мог дёргать flow без полного UI-драйва.

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

Run: `cd app && npm run test:vue -- AdminBillingViewActions AdminBillingView`
Expected: PASS — новый файл зелёный, существующий `AdminBillingView.spec.ts`/`AdminBillingViewApi.spec.ts` не сломаны.

- [ ] **Step 5: vue-tsc + ESLint**

Run: `cd app && npm run type-check && npm run lint:vue`
Expected: 0 ошибок.

- [ ] **Step 6: Commit**

```bash
git add app/resources/js/api/admin.ts \
  app/resources/js/views/admin/AdminBillingView.vue \
  app/tests/Frontend/AdminBillingViewActions.spec.ts
git commit -m "feat(admin): G4 frontend — billing row-actions menu + dialogs"
```

---

## Task 3: G5 backend — incident detail endpoint

**Files:**

- Modify: `app/app/Http/Controllers/Api/AdminIncidentsController.php`
- Modify: `app/routes/web.php` (после строки 102 `GET /api/admin/incidents`)
- Test: `app/tests/Feature/AdminIncidentShowTest.php`

**Контракт:** `GET /api/admin/incidents/{id}` → 200 c полной карточкой инцидента либо 404. Ответ — объект `incident` со всеми полезными колонками `incidents_log` + `incident_id` (формат `INC-YYYY-MMDD-NNNN`), `status` (derive как в `index`), `rkn_deadline_at`, разрешённые имена `affected_tenants` (`[{id,organization_name}]`) и `created_by_admin` / `closed_by_admin` (full_name либо null).

- [ ] **Step 1: Написать failing-тест `AdminIncidentShowTest.php`**

```php
<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

beforeEach(function () {
    DB::table('incidents_log')->delete();
    $this->adminId = (int) DB::table('saas_admin_users')->insertGetId([
        'email' => 'inc-'.bin2hex(random_bytes(3)).'@test',
        'full_name' => 'Incident Admin',
        'password_hash' => bcrypt('test1234'),
        'is_active' => true,
        'role' => 'support',
        'created_at' => now(),
    ]);
});

function makeShowIncident(int $adminId, array $overrides = []): int
{
    $started = $overrides['started_at'] ?? now()->subHours(3);
    $detected = $overrides['detected_at'] ?? $started;

    return (int) DB::table('incidents_log')->insertGetId(array_merge([
        'type' => 'service_outage',
        'severity' => 'high',
        'started_at' => $started,
        'detected_at' => $detected,
        'resolved_at' => null,
        'summary' => 'Show test incident',
        'created_by_admin_id' => $adminId,
        'created_at' => now(),
    ], $overrides));
}

test('GET /api/admin/incidents/{id} 200 + полная карточка', function () {
    $id = makeShowIncident($this->adminId, ['summary' => 'API 502 burst', 'severity' => 'critical']);
    $r = $this->getJson("/api/admin/incidents/{$id}");
    $r->assertOk();
    expect($r->json('incident.id'))->toBe($id);
    expect($r->json('incident.summary'))->toBe('API 502 burst');
    expect($r->json('incident.severity'))->toBe('critical');
    expect($r->json('incident.incident_id'))->toMatch('/^INC-\d{4}-\d{4}-\d{4}$/');
    expect($r->json('incident.status'))->toBe('investigating');
    expect($r->json('incident.created_by_admin'))->toBe('Incident Admin');
});

test('GET /api/admin/incidents/{id} несуществующий → 404', function () {
    $this->getJson('/api/admin/incidents/99999999')->assertStatus(404);
});

test('GET /api/admin/incidents/{id} data_breach без rkn_notified_at → rkn_deadline_at +24ч', function () {
    $id = makeShowIncident($this->adminId, ['type' => 'data_breach', 'detected_at' => now()->subHour()]);
    $r = $this->getJson("/api/admin/incidents/{$id}");
    expect($r->json('incident.rkn_notified'))->toBeFalse();
    expect($r->json('incident.rkn_deadline_at'))->toBeString();
});

test('GET /api/admin/incidents/{id} разрешает имена affected_tenants', function () {
    $tenantId = (int) DB::table('tenants')->insertGetId([
        'subdomain' => 'inc-'.bin2hex(random_bytes(4)),
        'organization_name' => 'Affected Org',
        'contact_email' => 'a@test.local',
        'webhook_token' => bin2hex(random_bytes(16)),
        'created_at' => now(),
    ]);
    $id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']);
    $r = $this->getJson("/api/admin/incidents/{$id}");
    expect($r->json('incident.affected_tenants'))->toHaveCount(1);
    expect($r->json('incident.affected_tenants.0.organization_name'))->toBe('Affected Org');
});

test('GET /api/admin/incidents/{id} resolved инцидент → status resolved', function () {
    $id = makeShowIncident($this->adminId, ['resolved_at' => now()]);
    expect($this->getJson("/api/admin/incidents/{$id}")->json('incident.status'))->toBe('resolved');
});
```

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

Run: `cd app && php artisan test tests/Feature/AdminIncidentShowTest.php`
Expected: FAIL — route not defined.

- [ ] **Step 3: Добавить метод `show()` в `AdminIncidentsController`**

```php
/** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */
public function show(int $id): JsonResponse
{
    $row = DB::table('incidents_log')->where('id', $id)->first();
    if ($row === null) {
        abort(404, 'incident not found');
    }

    $tenantIds = is_array($row->affected_tenant_ids)
        ? $row->affected_tenant_ids
        : ($row->affected_tenant_ids !== null ? $this->parsePgArrayValues((string) $row->affected_tenant_ids) : []);

    $tenants = $tenantIds === []
        ? collect()
        : DB::table('tenants')->whereIn('id', $tenantIds)
            ->select(['id', 'organization_name'])->get();

    $admins = DB::table('saas_admin_users')
        ->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id]))
        ->pluck('full_name', 'id');

    return response()->json([
        'incident' => [
            'id' => (int) $row->id,
            'incident_id' => $this->formatIncidentId($row),
            'type' => $row->type,
            'severity' => $row->severity,
            'summary' => $row->summary,
            'root_cause' => $row->root_cause,
            'postmortem_url' => $row->postmortem_url,
            'started_at' => CarbonImmutable::parse($row->started_at)->toIso8601String(),
            'detected_at' => CarbonImmutable::parse($row->detected_at)->toIso8601String(),
            'resolved_at' => $row->resolved_at !== null
                ? CarbonImmutable::parse($row->resolved_at)->toIso8601String() : null,
            'status' => $this->deriveStatus($row),
            'affected_tenants' => $tenants->map(fn ($t) => [
                'id' => (int) $t->id,
                'organization_name' => $t->organization_name,
            ])->values(),
            'affected_users_count' => $row->affected_users_count !== null ? (int) $row->affected_users_count : null,
            'notification_sent_at' => $row->notification_sent_at !== null
                ? CarbonImmutable::parse($row->notification_sent_at)->toIso8601String() : null,
            'rkn_notified' => $row->rkn_notified_at !== null,
            'rkn_notified_at' => $row->rkn_notified_at !== null
                ? CarbonImmutable::parse($row->rkn_notified_at)->toIso8601String() : null,
            'rkn_deadline_at' => $row->type === 'data_breach' && $row->rkn_notified_at === null
                ? CarbonImmutable::parse($row->detected_at)->addHours(24)->toIso8601String() : null,
            'created_by_admin' => $admins->get($row->created_by_admin_id),
            'closed_by_admin' => $row->closed_by_admin_id !== null ? $admins->get($row->closed_by_admin_id) : null,
            'created_at' => $row->created_at !== null
                ? CarbonImmutable::parse($row->created_at)->toIso8601String() : null,
            'updated_at' => $row->updated_at !== null
                ? CarbonImmutable::parse($row->updated_at)->toIso8601String() : null,
        ],
    ]);
}

/**
 * PG-array literal '{1,2,3}' → массив int.
 *
 * @return list<int>
 */
private function parsePgArrayValues(string $literal): array
{
    $trimmed = trim($literal, '{}');
    if ($trimmed === '') {
        return [];
    }

    return array_map('intval', explode(',', $trimmed));
}
```

- [ ] **Step 4: Добавить маршрут в `routes/web.php`**

После `Route::get('/api/admin/incidents', ...)` (≈строка 102):

```php
// Sprint 3D (G5): SaaS-admin incident detail-view drill-down.
Route::get('/api/admin/incidents/{id}', 'App\Http\Controllers\Api\AdminIncidentsController@show')
    ->where('id', '[0-9]+');
```

**NB:** маршрут `{id}` идёт ПОСЛЕ списочного `GET /api/admin/incidents` — конфликта нет (разные пути; `{id}` ограничен `[0-9]+`).

- [ ] **Step 5: Прогнать тест**

Run: `cd app && php artisan test tests/Feature/AdminIncidentShowTest.php`
Expected: PASS.

- [ ] **Step 6: Регенерация phpstan-baseline (quirk 25) + pint + stan**

Та же 3-шаговая процедура, что в Task 1 Step 7. Затем `composer stan` → `[OK]`.

- [ ] **Step 7: Commit**

```bash
git add app/app/Http/Controllers/Api/AdminIncidentsController.php \
  app/routes/web.php app/tests/Feature/AdminIncidentShowTest.php \
  app/phpstan-baseline.neon
git commit -m "feat(admin): G5 backend — incident detail endpoint"
```

---

## Task 4: G6 backend — РКН-notify endpoint

**Files:**

- Modify: `app/app/Http/Controllers/Api/AdminIncidentsController.php`
- Modify: `app/routes/web.php`
- Test: `app/tests/Feature/AdminIncidentRknNotifyTest.php`

**Контракт:** `POST /api/admin/incidents/{id}/rkn-notify` — фиксирует факт уведомления Роскомнадзора об утечке ПДн (152-ФЗ). 404 — инцидента нет; 422 — `type !== 'data_breach'` (РКН-уведомление применимо только к утечке ПДн); 409 — `rkn_notified_at` уже проставлен; 200 — успех: `rkn_notified_at = now()`, `updated_at = now()`, строка в `saas_admin_audit_log` (`action = incident.rkn_notify`), в ответе — обновлённая карточка (та же форма, что у `show()`).

- [ ] **Step 1: Написать failing-тест `AdminIncidentRknNotifyTest.php`**

```php
<?php

declare(strict_types=1);

use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;

uses(DatabaseTransactions::class);

beforeEach(function () {
    DB::table('incidents_log')->delete();
    $this->adminId = (int) DB::table('saas_admin_users')->insertGetId([
        'email' => 'rkn-'.bin2hex(random_bytes(3)).'@test',
        'full_name' => 'RKN Admin',
        'password_hash' => bcrypt('test1234'),
        'is_active' => true,
        'role' => 'support',
        'created_at' => now(),
    ]);
});

function makeRknIncident(int $adminId, array $overrides = []): int
{
    $started = $overrides['started_at'] ?? now()->subHours(2);

    return (int) DB::table('incidents_log')->insertGetId(array_merge([
        'type' => 'data_breach',
        'severity' => 'critical',
        'started_at' => $started,
        'detected_at' => $started,
        'summary' => 'PDN leak test',
        'created_by_admin_id' => $adminId,
        'created_at' => now(),
    ], $overrides));
}

test('POST rkn-notify проставляет rkn_notified_at + audit-log', function () {
    $id = makeRknIncident($this->adminId);
    $r = $this->postJson("/api/admin/incidents/{$id}/rkn-notify");
    $r->assertOk();
    expect($r->json('incident.rkn_notified'))->toBeTrue();
    expect($r->json('incident.rkn_notified_at'))->toBeString();
    expect(DB::table('incidents_log')->where('id', $id)->value('rkn_notified_at'))->not->toBeNull();
    expect(DB::table('saas_admin_audit_log')->where('action', 'incident.rkn_notify')->where('target_id', $id)->exists())
        ->toBeTrue();
});

test('POST rkn-notify несуществующий инцидент → 404', function () {
    $this->postJson('/api/admin/incidents/99999999/rkn-notify')->assertStatus(404);
});

test('POST rkn-notify не data_breach → 422', function () {
    $id = makeRknIncident($this->adminId, ['type' => 'service_outage']);
    $this->postJson("/api/admin/incidents/{$id}/rkn-notify")->assertStatus(422);
    expect(DB::table('incidents_log')->where('id', $id)->value('rkn_notified_at'))->toBeNull();
});

test('POST rkn-notify повторно → 409', function () {
    $id = makeRknIncident($this->adminId, ['rkn_notified_at' => now()->subHour()]);
    $this->postJson("/api/admin/incidents/{$id}/rkn-notify")->assertStatus(409);
});
```

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

Run: `cd app && php artisan test tests/Feature/AdminIncidentRknNotifyTest.php`
Expected: FAIL — route not defined.

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

В шапку — `use App\Http\Controllers\Concerns\ResolvesAdminUserId;`, `use App\Models\SaasAdminAuditLog;`, `use Illuminate\Http\Request;` (Request уже импортирован). В тело класса — `use ResolvesAdminUserId;`.

```php
/** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */
public function notifyRkn(Request $request, int $id): JsonResponse
{
    $row = DB::table('incidents_log')->where('id', $id)->first();
    if ($row === null) {
        abort(404, 'incident not found');
    }
    if ($row->type !== 'data_breach') {
        abort(422, 'РКН-уведомление применимо только к инцидентам типа data_breach');
    }
    if ($row->rkn_notified_at !== null) {
        abort(409, 'РКН уже уведомлён по этому инциденту');
    }

    $adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot');

    DB::transaction(function () use ($row, $adminUserId, $request): void {
        DB::table('incidents_log')->where('id', $row->id)->update([
            'rkn_notified_at' => now(),
            'updated_at' => now(),
        ]);

        SaasAdminAuditLog::create([
            'admin_user_id' => $adminUserId,
            'action' => 'incident.rkn_notify',
            'target_type' => 'incident',
            'target_id' => $row->id,
            'payload_before' => ['rkn_notified_at' => null],
            'payload_after' => ['rkn_notified_at' => now()->toIso8601String()],
            'reason' => 'Роскомнадзор уведомлён об утечке ПДн через админ-интерфейс (152-ФЗ).',
            'ip_address' => $request->ip() ?? '127.0.0.1',
            'user_agent' => $request->userAgent(),
        ]);
    });

    return $this->show($id);
}
```

(`notifyRkn` переиспользует `show($id)` для тела ответа — обновлённая карточка.)

- [ ] **Step 4: Добавить маршрут в `routes/web.php`**

После маршрута `GET /api/admin/incidents/{id}` из Task 3:

```php
Route::post('/api/admin/incidents/{id}/rkn-notify', 'App\Http\Controllers\Api\AdminIncidentsController@notifyRkn')
    ->where('id', '[0-9]+');
```

- [ ] **Step 5: Прогнать тест**

Run: `cd app && php artisan test tests/Feature/AdminIncidentRknNotifyTest.php`
Expected: PASS.

- [ ] **Step 6: Регенерация phpstan-baseline (quirk 25) + pint + stan**

Та же 3-шаговая процедура. `composer stan` → `[OK]`.

- [ ] **Step 7: Commit**

```bash
git add app/app/Http/Controllers/Api/AdminIncidentsController.php \
  app/routes/web.php app/tests/Feature/AdminIncidentRknNotifyTest.php \
  app/phpstan-baseline.neon
git commit -m "feat(admin): G6 backend — incident РКН-notify endpoint"
```

---

## Task 5: G5+G6 frontend — incident detail view + РКН-notify button

**Files:**

- Create: `app/resources/js/views/admin/AdminIncidentDetailView.vue`
- Modify: `app/resources/js/api/admin.ts`
- Modify: `app/resources/js/router/index.ts`
- Modify: `app/resources/js/views/admin/AdminIncidentsView.vue`
- Test: `app/tests/Frontend/AdminIncidentDetailView.spec.ts`
- Test: `app/tests/Frontend/AdminIncidentsView.spec.ts` (модификация)
- Test: `app/tests/Frontend/admin-api.spec.ts` (модификация)

- [ ] **Step 1: Добавить API-функции в `api/admin.ts`**

```typescript
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===

export interface ApiIncidentAffectedTenant {
    id: number;
    organization_name: string;
}

export interface ApiAdminIncidentDetail {
    id: number;
    incident_id: string;
    type: string;
    severity: 'low' | 'medium' | 'high' | 'critical';
    summary: string;
    root_cause: string | null;
    postmortem_url: string | null;
    started_at: string;
    detected_at: string;
    resolved_at: string | null;
    status: 'open' | 'investigating' | 'resolved';
    affected_tenants: ApiIncidentAffectedTenant[];
    affected_users_count: number | null;
    notification_sent_at: string | null;
    rkn_notified: boolean;
    rkn_notified_at: string | null;
    rkn_deadline_at: string | null;
    created_by_admin: string | null;
    closed_by_admin: string | null;
    created_at: string | null;
    updated_at: string | null;
}

export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
    const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
    return data.incident;
}

export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
        `/api/admin/incidents/${id}/rkn-notify`,
        {},
    );
    return data.incident;
}
```

- [ ] **Step 2: Написать failing-тест `AdminIncidentDetailView.spec.ts`**

Образец — `app/tests/Frontend/AdminTenantDetailView.spec.ts` (read it: монтирование с router-mock, `vi.mock` на `api/admin`, проверка loading/404/error). Кейсы:

```
1. на mount вызывает getAdminIncidentDetail(route.params.id) и рендерит summary/incident_id/severity
2. 404 от API → блок data-testid="incident-not-found"
3. прочая ошибка → блок data-testid="incident-fetch-error" + кнопка повтора
4. data_breach без rkn_notified → видна кнопка data-testid="rkn-notify-btn"
5. клик rkn-notify → confirm → вызывает notifyIncidentRkn(id), карточка обновляется (rkn_notified=true, кнопка исчезает)
6. type !== 'data_breach' → кнопки rkn-notify нет
7. rkn_notified=true → вместо кнопки показан факт «РКН уведомлён {дата}»
8. ошибка notifyIncidentRkn → видимый v-alert (data-testid="rkn-error")
```

Запустить: `cd app && npm run test:vue -- AdminIncidentDetailView` → Expected: FAIL (компонента нет).

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

Структура по образцу `AdminTenantDetailView.vue` (loading / notFound / fetchError / контент). `<script setup lang="ts">`:

- `route` / `router`, `id = computed(() => Number(route.params.id))`.
- `incident = ref<ApiAdminIncidentDetail | null>(null)`, `loading`, `notFound`, `fetchError`, `rknError = ref('')`, `rknLoading = ref(false)`.
- `loadIncident()`: `getAdminIncidentDetail(id.value)`; catch — `status===404` → `notFound=true`, иначе `fetchError = extractErrorMessage(e)`.
- `onMounted(loadIncident)` + `watch(id, loadIncident)`.
- `confirmRkn()`: `rknLoading=true`; try `incident.value = await notifyIncidentRkn(id.value)`; catch `rknError = extractErrorMessage(e)`; finally `rknLoading=false`.
- `goBack()` → `router.push({ name: 'admin-incidents' })`.
- `defineExpose({ incident, loading, notFound, fetchError, rknError, loadIncident, confirmRkn })`.

Шаблон: хедер с `incident_id` + чипы severity/status (мапы — переиспользовать из `AdminIncidentsView` логику; локальные `severityMap`/`statusMap` допустимы — компонент самостоятельный), кнопка «Назад». Тело — `summary`, `root_cause`, `postmortem_url` (ссылкой если есть), даты `started_at`/`detected_at`/`resolved_at`, список `affected_tenants` (имена; каждый — link на `{name:'admin-tenant-detail'}` опционально, но не обязательно), `affected_users_count`. Секция РКН:

- если `incident.type === 'data_breach'`:
  - `rkn_notified === false` → блок с дедлайном `rkn_deadline_at` + `<v-btn data-testid="rkn-notify-btn" color="error" :loading="rknLoading">Уведомить РКН</v-btn>`. Клик — открыть `<v-dialog>` подтверждения (действие юридически значимое) → submit вызывает `confirmRkn()`.
  - `rkn_notified === true` → строка «РКН уведомлён {formatDate(rkn_notified_at)}».
  - `<v-alert v-if="rknError" type="error" data-testid="rkn-error">`.
- иначе секции РКН нет.

`formatDate` — как в `AdminIncidentsView.vue` (скопировать helper).

- [ ] **Step 4: Добавить маршрут в `router/index.ts`**

Сразу после блока `admin-incidents` (≈строка 212):

```typescript
    {
        path: '/admin/incidents/:id',
        name: 'admin-incident-detail',
        component: () => import('../views/admin/AdminIncidentDetailView.vue'),
        meta: { layout: 'admin', title: 'Инцидент', requiresAuth: true },
    },
```

(devIndex намеренно не задан — это временная dev-фича, новые маршруты её не требуют; коллизий индексов нет.)

- [ ] **Step 5: Сделать строки `AdminIncidentsView` кликабельными**

В `AdminIncidentsView.vue`: импортировать `useRouter`, добавить `const router = useRouter()`. На `<v-list-item>` добавить `@click="router.push({ name: 'admin-incident-detail', params: { id: row.id } })"`, `:data-testid="\`incident-row-${row.id}\`"`,`style="cursor: pointer"`. Список-бейдж «РКН pending» остаётся (информативный).

- [ ] **Step 6: Дописать тесты `AdminIncidentsView.spec.ts` и `admin-api.spec.ts`**

`AdminIncidentsView.spec.ts` — +кейс: клик по строке инцидента вызывает `router.push` с `name:'admin-incident-detail'` и `params.id`. (Смонтировать с router-mock — см. как уже устроен файл.)

`admin-api.spec.ts` — +кейсы: `getAdminIncidentDetail` дёргает `GET /api/admin/incidents/{id}` и возвращает `.incident`; `notifyIncidentRkn` дёргает `POST .../rkn-notify`; `listAdminTariffPlans`/`updateTenantStatus`/`refundTenant`/`changeTenantTariff` бьют по верным путям/методам (по образцу существующих кейсов в файле).

- [ ] **Step 7: Прогнать тесты + type-check + lint**

Run: `cd app && npm run test:vue -- AdminIncidentDetailView AdminIncidentsView admin-api && npm run type-check && npm run lint:vue`
Expected: все PASS, 0 ошибок vue-tsc/ESLint.

- [ ] **Step 8: Commit**

```bash
git add app/resources/js/views/admin/AdminIncidentDetailView.vue \
  app/resources/js/api/admin.ts app/resources/js/router/index.ts \
  app/resources/js/views/admin/AdminIncidentsView.vue \
  app/tests/Frontend/AdminIncidentDetailView.spec.ts \
  app/tests/Frontend/AdminIncidentsView.spec.ts \
  app/tests/Frontend/admin-api.spec.ts
git commit -m "feat(admin): G5/G6 frontend — incident detail view + РКН-notify"
```

---

## Финальная верификация (после всех 5 задач)

- [ ] Pest: `cd app && php artisan test` — выписать `passed/failed`, 0 failed.
- [ ] Vitest: `cd app && npm run test:vue` — 0 failed.
- [ ] `cd app && composer pint -- --test && composer stan && npm run type-check && npm run lint:vue` — всё чисто.
- [ ] Финальное code-review всей ветки (subagent-driven Final reviewer).

---

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

**Spec coverage:**

- G4 «AdminBillingView row-actions — Dropdown 3 actions + backend» → Task 1 (3 действия + tariff-plans helper) + Task 2 (выпадающее меню + 3 диалога). ✓
- G5 «AdminIncidentsView drill-down — `/admin/incidents/:id`» → Task 3 (endpoint) + Task 5 (карточка + маршрут + кликабельные строки). ✓
- G6 «AdminIncidentsView РКН-notify — Button + endpoint» → Task 4 (endpoint) + Task 5 (кнопка на карточке). ✓

**Дизайн-решение:** кнопка РКН-уведомления размещена на карточке инцидента (G5), а не в списке — там есть место под confirm-диалог юридически значимого действия; список сохраняет информативный бейдж «РКН pending» и получает click-through на карточку. Аудит-намерение (превратить пассивный бейдж в actionable flow) выполнено.

**Out-of-scope (осознанно):** auth:saas-admin middleware (J1/J2, ⏸ Б-1) — все новые endpoint'ы на MVP без auth, паритет с существующими `/api/admin/*`; кросс-гард «нельзя разблокировать тенанта с `chargeback_unrecovered_rub > 0`» — отдельная бизнес-логика, не в G4; РКН-notify не шлёт реальное письмо/форму в Роскомнадзор — фиксирует только факт (`rkn_notified_at`), интеграция вне MVP.

**Type consistency:** `ApiAdminIncidentDetail` (Task 5) — поля совпадают с ответом `show()` (Task 3). `AdminTariffPlan` (Task 2) — совпадает с `tariffPlans()` (Task 1). Имена audit-действий: `tenant.suspend`/`tenant.activate`/`tenant.refund`/`tenant.change_tariff`/`incident.rkn_notify` — единообразны backend ↔ тесты.

**Placeholder scan:** код приведён полностью для backend; для Vue-компонентов задана точная структура + явные `data-testid` + список тест-кейсов (реализация по образцу указанных существующих файлов — паттерн subagent-driven).
